代码设计
AI WARNING
未进行对照审核。
代码设计的目标是编写高质量的代码,其核心在于提升代码的可读性、可维护性和可靠性。良好的代码设计不仅方便团队协作,减少软件维护成本,还能构建稳健的系统。
设计易读的代码
易读的代码如同清晰的文章,能够让他人(以及未来的自己)快速理解其意图和逻辑。这对于软件维护和团队协作至关重要。
代码规范
统一的代码规范是提高代码可读性的基础,常见的规范包括格式、命名和注释。
布局格式
良好的布局能够清晰地展现代码的逻辑结构。
-
使用缩进与对齐表达逻辑结构
- 一致的缩进(通常为 4 个空格)能够清晰地反映代码块的层次。
- 对齐相似的代码行可以增强可读性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 示例:良好的缩进和对齐
public class Sales extends DomainObject {
// ...
public double getTotal() {
Iterator iter = salesLineItemMap.entrySet().iterator();
while (iter.hasNext()) { // 缩进体现循环体
Map.Entry entry = (Map.Entry) iter.next();
Object val = entry.getValue();
total += ((SalesLineItem)val).getSubTotal();
}
return total;
}
public void addSalesLineItem(long commodityID, long quantity) {
SalesLineItem item = new SalesLineItem(commodityID, quantity); // 对齐
salesLineItemMap.put(commodityID, item); // 对齐
}
} -
将相关逻辑组织在一起
- 在类定义中,相关的成员(如属性、构造方法、公有方法、保护方法、私有方法)应该组织在一起,形成逻辑分组。
- 例如,可以将所有成员变量声明放在类的顶部,接着是构造方法,然后是不同访问级别的方法。
-
使用空行分割逻辑
- 在不同的逻辑单元之间使用空行,可以像段落一样分隔代码,使得结构更清晰。
- 例如,在
switch
语句的各个case
块之间,或在不同功能的方法调用序列之间。
-
语句分行
- 避免将过长的语句放在一行,这会影响可读性,尤其是在屏幕或打印输出时。
- 复杂的条件表达式或方法调用链可以适当换行并对齐。
1
2
3
4
5
6// 示例:长语句断行
if ( (this.obj instanceof MyClass) &&
(this.field == obj.field) &&
(isConditionMet()) ) {
// ...
}
命名
有意义的命名是代码自解释能力的关键。
- 使用有意义的名称:变量名、函数名、类名应准确反映其用途或代表的实体。例如,表示销售信息的类命名为
Sales
而不是ClassA
。 - 遵循命名约定:
- 类、接口:名词或名词短语,每个单词首字母大写(PascalCase),如
SalesOrder
。 - 方法/函数:动词或动宾短语,第一个单词小写,后续单词首字母大写(camelCase),如
calculateTotalPrice()
。 - 变量:名词或名词短语,同方法名(camelCase),如
customerName
。 - 常量:全大写,单词间用下划线分隔,如
MAX_CONNECTIONS
。
- 类、接口:名词或名词短语,每个单词首字母大写(PascalCase),如
- 名称与实际内容相符:例如,一个不仅计算变化还维护账单数据的类,用
Payment
或Bill
比ChangeCalculator
更合适。 - 临时变量命名:对于生命周期极短的临时变量(如
for
循环计数器),可以使用i
,j
,k
等;但若有特定含义,仍应赋予有意义的名称。 - 避免使用易混淆的字符:如
l
(小写 L)和1
(数字一),O
(大写 O)和0
(数字零)。 - 避免使用无逻辑的缩写:如
wrttn
代表written
,这会降低可读性。
注释
注释是对代码的补充说明,帮助理解代码功能、设计思路和使用方法。
-
注释类型(以 Java 为例)
- 语句注释:
// ...
用于单行注释。 - 标准注释:
/* ... */
用于多行注释。 - 文档注释:
/** ... */
用于生成 API 文档(如 Javadoc)。
- 语句注释:
-
文档注释
- 目的:为包、类、接口、方法、字段等提供规范化的说明,以便工具(如 Javadoc)提取生成 API 文档。
- 内容:
- 包:总结和概述。
- 类/接口:描述其功能、用途。
- 方法:描述其功能、参数、返回值、可能抛出的异常。
- 字段:描述重要字段的含义、用法和约束。
- 常用 Javadoc 标签:
- 类/接口级别:
@author
(作者名)、@version
(版本号)、@see
(相关引用)、@since
(最早使用的 JDK 版本)、@deprecated
(不推荐使用的警告)。 - 方法级别:
@param
(参数及其意义)、@return
(返回值)、@throws
(异常类及抛出条件)、@see
、@since
、@deprecated
。
- 类/接口级别:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22/**
* LoginController 负责处理来自 LoginDialog 的用户登录请求。
* 它验证用户凭据并将结果反馈给界面。
* @author Your Name
* @version 1.0
* @see presentation.LoginDialog
* @since 2023-01-01
*/
public class LoginController {
/**
* 验证用户登录。
* @param id 用户 ID,由界面传入。
* @param password 用户密码,由界面传入。
* @return 如果验证成功返回 true,否则返回 false。
* @throws DBException 如果数据库连接失败。
* @see businesslogic.domain.User
*/
public boolean login(long id, String password) throws DBException {
User user = new User(id);
return user.login(password);
}
} -
内部注释
- 要有意义:解释代码「为什么」这么做,而不是「做什么」(代码本身应清晰表达「做什么」)。避免简单重复代码的含义。
- 重视对数据类型的注释:解释复杂数据结构、关键变量的含义和取值范围。
- 重视对复杂控制结构的注释:解释复杂算法、决策逻辑的思路。
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 示例:内部注释
// 为了快速存取,使用 HashMap 组织销售商品列表
// Key 是商品 ID,取值范围是 1...MAX_ID
HashMap<Long, SalesLineItem> salesLineItemMap;
// ...
// 更新 Member 信息(此注释意义不大,代码已清晰)
member.update();
// 遍历销售商品项,更新每个商品的状态
while (iter.hasNext()) {
// ...
}
设计易维护的代码
易维护的代码意味着当需求变更或缺陷修复时,可以轻松、安全地修改代码,而不会引入新的问题。
-
小型任务(高内聚)
- 每个函数或方法应专注于完成一个单一、明确的任务(高内聚)。
- 如果一个内聚的任务本身逻辑简单、代码量少,则其可维护性较好。
- 如果一个内聚的任务依然复杂、代码冗长,应将其进一步分解为多个高内聚、低耦合的小型任务。
-
处理复杂决策
复杂的条件判断是维护的难点。- 使用新的布尔变量简化:将复杂的布尔表达式赋值给一个有意义名称的布尔变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 不推荐
if ((atEndOfStream) && (error != inputError) &&
(MIN_LINES <= lineCount) && (lineCount <= MAX_LINES) &&
(!errorProcessing(error))) {
// ...
}
// 推荐
boolean allDataRead = (atEndOfStream) && (error != inputError);
boolean validLineCount = (MIN_LINES <= lineCount) && (lineCount <= MAX_LINES);
boolean noProcessingError = !errorProcessing(error);
if (allDataRead && validLineCount && noProcessingError) {
// ...
} - 使用有意义的名称封装复杂决策:将复杂的判断逻辑封装到一个独立的、名称清晰的方法中。
1
2
3
4
5
6
7
8
9// 不推荐
if ((id > 0) && (id <= MAX_ID)) { /* ... */ }
// 推荐
if (isIdValid(id)) { /* ... */ }
// ...
private boolean isIdValid(int id) {
return (id > 0) && (id <= MAX_ID);
} - 表驱动编程:对于多重条件判断(尤其是
if-else if-else
或switch
结构),可以使用表(如数组、Map)来存储条件和对应的动作/结果,通过查表代替复杂的逻辑判断。这使得逻辑更清晰,且易于修改和扩展。
- 使用新的布尔变量简化:将复杂的布尔表达式赋值给一个有意义名称的布尔变量。
表驱动示例:根据积分赠送礼品等级
假设积分规则如下:
- 积分 1000:无礼品
- 1000 积分 2000:礼品等级 1
- 2000 积分 5000:礼品等级 2
- 积分 5000:礼品等级 3
传统 if-else
:
1 | int getGiftLevel(int points) { |
表驱动实现:
1 | // 定义阈值和对应的礼品等级 |
表驱动的核心思想是将逻辑判断转化为数据查询,当规则频繁变动或规则数量很多时,维护数据表通常比维护复杂的
if-else
语句更容易。
-
数据使用
- 专变量专用:不要将变量用于与其命名不符的目的(如用
total
临时做循环计数器)。 - 单一用途:一个变量只用于一个目的。避免在代码不同部分让同一变量表示不同含义。
- 限制全局变量:全局变量会增加模块间的耦合,难以追踪修改。如果必须使用,要明确注释其声明和使用处。
- 避免魔法数值/字符串:将代码中直接出现的、含义不明的数值或字符串(如
15
代表天数,"MALE"
代表性别)定义为有意义名称的常量。
- 专变量专用:不要将变量用于与其命名不符的目的(如用
-
明确依赖关系
- 类之间、模块之间的依赖关系应清晰明了。模糊的依赖关系(如通过全局变量间接交互)会使代码难以理解和修改,容易产生未预期的连锁反应。
- 使用 Javadoc 的
@see
或其他文档方式明确依赖。
设计可靠的代码
可靠的代码能在各种情况下正确执行,或在发生错误时能以可控的方式处理。
-
契约式设计(Design by Contract, DbC)
- 核心思想:软件模块(如类、方法)之间的交互建立在明确的「契约」之上。契约规定了调用方和被调用方的责任和权利。
- 组成:
- 前置条件(Precondition):调用方必须满足的条件,才能调用该方法。
- 后置条件(Postcondition):方法完成后,被调用方保证满足的条件。
- 不变式(Invariant):在方法执行期间(对于类而言,是对象生命周期中稳定状态时)必须保持为真的条件。
- 实现方式:
- 异常方式:在方法入口检查前置条件,若不满足则抛出异常。在方法出口(或
return
前)检查后置条件,若不满足也抛出异常。1
2
3
4
5
6
7
8
9
10
11
12
13public double getChange(double payment, double total) throws PreException, PostException {
// 前置条件检查
if (payment <= 0 || payment < total) {
throw new PreException("Sales.getChange: Payment " + String.valueOf(payment) +
", Total " + String.valueOf(total));
}
double result = payment - total;
// 后置条件检查(示例,实际可能更复杂)
if (result < 0) { // 假设找零不能为负
throw new PostException("Sales.getChange: Result " + String.valueOf(result));
}
return result;
} - 断言方式:使用断言(Assertion)来检查前置条件和后置条件。
- Java 断言语句:
assert Expression1 (: Expression2);
Expression1
:一个布尔表达式。Expression2
(可选):一个值,如果Expression1
为false
,Expression2
的字符串形式会作为AssertionError
的一部分。- 如果
Expression1
为true
,断言不影响程序执行。 - 如果
Expression1
为false
,抛出AssertionError
。
- 断言通常用于开发和测试阶段,默认情况下在运行时可能被禁用以提高性能。
1
2
3
4
5
6
7
8
9
10
11
12public double getChange(double payment, double total) { // throws AssertionError (隐式)
// 前置条件检查
assert (payment > 0 && payment >= total) :
"Sales.getChange: Payment " + String.valueOf(payment) +
", Total " + String.valueOf(total);
double result = payment - total;
// 后置条件检查
assert (result >= 0) : "Sales.getChange: Result " + String.valueOf(result);
return result;
} - Java 断言语句:
- 比较:异常是方法契约的一部分,调用者应准备处理。断言主要用于内部一致性检查,通常不期望调用者捕获
AssertionError
。
- 异常方式:在方法入口检查前置条件,若不满足则抛出异常。在方法出口(或
-
防御式编程
- 核心思想:方法在与外部环境(其他方法、操作系统、用户输入等)交互时,不能完全信任外部的正确性。要在发生错误时保护方法内部不受损害。
- 常见场景:
- 输入参数是否合法?
- 用户输入是否有效?
- 外部文件是否存在/可读/可写?
- 对象引用是否为
NULL
? - 其他对象是否已正确初始化/执行了必要方法?
- 数据库/网络连接是否正常?
- 异常和断言都可以用于实现防御式编程。
使用模型辅助设计复杂代码
对于复杂的逻辑,使用模型可以帮助梳理思路,减少错误。
-
决策表
- 一种表格化的方式,用于表示复杂的条件组合及其对应的动作。
- 结构:
- 条件声明:列出所有相关的条件。
- 行动声明:列出所有可能的行动。
- 条件选项:针对每条规则,标出条件的取值(如 True/False, Y/N, 具体值范围)。
- 行动选项:针对每条规则,标出应执行的行动(如 X 标记)。
- 优点:清晰、无歧义,易于检查完备性和一致性。
| 条件/行动 | 规则 1 | 规则 2 | 规则 3 |
| :-- | :-- | :-- | :-- |
| 条件声明 | - | - | - |
| prePoint < 1000 | 是 | 否 | 否 |
| postPoint >= 1000 | - | 是 | 否 |
| prePoint < 2000 | - | 是 | 否 |
| postPoint >= 2000 | - | - | 是 |
| … | | | |
| 行动声明 | - | - | - |
| Gift Event Level 1 | X | - | - |
| Gift Event Level 2 | - | X | - |
| Gift Event Level 3 | - | - | X | -
伪代码
- 介于自然语言和编程语言之间的一种描述算法的方式。
- 使用编程语言的关键字(如
IF-THEN-ELSE
,WHILE
,FOR
)和结构,但允许使用自然语言描述具体操作。 - 优点:专注于逻辑流程,忽略具体语法细节,便于沟通和快速原型设计。
1
2
3
4
5
6
7
8
9得到 SalesLineItem 的迭代器
WHILE 迭代器有下一个元素
找到 SalesLineItem 对象
按照更新步骤进行更新:
得到相应的 Mapper
将自己的信息转为层间传递的 PO 对象
将 PO 对象交给 Mapper
Mapper 完成更新
END WHILE -
程序流程图
- 使用标准化的图形符号表示程序的控制流和操作步骤。
- 常用符号:圆角矩形(开始/结束)、矩形(处理步骤)、菱形(判断)、平行四边形(输入/输出)。
- 优点:直观展示复杂逻辑路径。
graph TD A[开始] --> B{读入 N}; B --> C[M=1, F=1]; C --> D{M=N?}; D -- 是 --> E[打印 F]; E --> F[结束]; D -- 否 --> G[F = F * M]; G --> H[M = M + 1]; H --> D;
为代码开发单元测试用例
单元测试是验证代码模块(通常是方法或类)功能正确性的重要手段。
-
为方法开发测试用例
- 基于规格的测试(黑盒):不关心内部实现,只根据方法的功能说明(规格)设计测试用例。
- 等价类划分:将输入数据划分为若干个等价类,从每个类中选取代表性数据作为测试用例。
- 边界值分析:针对等价类的边界以及略过边界的值设计测试用例,这些地方容易出错。
- 基于代码的测试(白盒):根据代码的内部逻辑结构设计测试用例,以达到一定的代码覆盖率。
- 语句覆盖:每个可执行语句至少执行一次。
- 分支覆盖(判定覆盖):每个判断条件的真假分支至少执行一次。
- 路径覆盖:程序中所有可能的执行路径至少执行一次(通常难以完全实现)。
- Mock 对象:当被测方法依赖于其他复杂对象或外部系统时,可以使用 Mock 对象(模拟对象)来替代这些依赖,使得测试环境更可控、测试执行更快。例如,测试
Sales.total()
方法时,它依赖SalesLineItem.subTotal()
,可以创建一个MockSalesLineItem
类来提供固定的subTotal()
返回值。
- 基于规格的测试(黑盒):不关心内部实现,只根据方法的功能说明(规格)设计测试用例。
-
为类开发测试用例
- 复杂类通常有多种状态,方法的执行会改变类的状态,并可能影响其他方法的行为。
- 除了测试类的每个独立方法外,还需要测试不同方法之间因状态改变而产生的交互影响。
- 状态图可以帮助分析类的状态转换,并据此设计测试序列(方法调用顺序)来覆盖不同的状态和转换。
代码复杂度度量
程序复杂度是导致编程困难和维护成本高的主要原因之一。
圈复杂度(Cyclomatic Complexity) - McCabe
- 基本思路:衡量程序中独立路径的最大数量。
- 计算方法:
- 基于流程图:
- 其中, 是程序的控制流程图, 是图中边的数量, 是图中节点的数量。
- 基于决策点:
- 是程序中决策点(如
if
,while
,for
中的条件,case
语句等)的数量。每个case
分支算一个决策点。
- 基于流程图:
- 度量的意义(McConnell 建议):
- 0-5:子程序可能还不错。
- 6-10:需要考虑简化子程序。
- 10+:应将子程序的某部分拆分成独立的子程序并调用它。
- 这只是一个警示,说明子程序可能需要重新设计。
- 类的复杂度:可以定义为类中所有方法代码复杂度的总和。
问题代码警示
借鉴《代码大全》
以下是一些常见的导致代码质量下降的做法,应尽量避免:
-
变量处理不当
- 定义与初始化:
- 避免隐式声明(某些语言特性)。
- 声明所有变量,并在靠近首次使用的地方初始化。
- 尽可能使用
final
(Java)或const
(C++)声明不应改变的变量。
- 作用域:
- 使变量作用域尽可能小(局部化)。
- 直到即将使用时再赋值。
- 持续性:
- 编写代码时假设数据没有持续性,除非显式处理。
- 养成在使用所有数据前声明和初始化的习惯。
- 单一用途:一个变量只用于一个明确的目的,避免复用导致含义混淆。
- 避免隐含意义:如用
pageCount = -1
表示错误,应使用更明确的方式(如异常或专门的状态变量)。
- 定义与初始化:
-
数值处理问题
- 避免「神秘数值」:使用有意义的常量名代替硬编码的数字。
- 整数:注意整数除法(截断)、整数溢出(尤其在中间计算步骤)。
- 浮点数:
- 避免数量级相差巨大的数之间的加减运算(精度损失)。
- 避免直接进行等量判断(
float_a == float_b
),应使用一个小的容差范围(Math.abs(float_a - float_b) < EPSILON
)。 - 注意舍入误差。
-
子程序(方法/函数)设计
- 创建理由:降低复杂度、引入抽象、避免代码重复、支持子类化、隐藏实现细节(顺序、指针操作)、提高可移植性、简化复杂布尔判断、改善性能(有时通过集中优化)。
- 良好命名:
- 准确描述子程序所做的所有事情。
- 避免无意义、模糊的动词(如
handleData
,processInput
)。 - 函数命名应能体现返回值(如
isReady()
,getColor()
)。 - 过程命名使用「动词+宾语」形式(如
printDocument()
)。
-
一般控制问题
- 布尔表达式:
- 用
true
和false
做布尔判断,而不是0
和1
(除非语言特性如此)。 - 简化复杂表达式(引入布尔变量或布尔函数)。
- 使用肯定形式的布尔表达式(
if (statusOk)
通常比if (!statusNotOk)
好)。 - 使用括号明确优先级。
- 用
- 复合语句:始终使用花括号
{}
包围if
,else
,while
,for
的执行体,即使只有一条语句,以避免悬挂else
等问题。 - 空语句:如果循环体为空(如
while(processNext() != DONE);
),应使其明显,例如:while(processNext() != DONE) { /* 空循环体 */ }
或while(processNext() != DONE) continue;
。 - 驯服深层嵌套:过深的嵌套难以理解和维护。可以通过以下方式简化:
- 将部分条件检测提取到前面。
- 使用
break
或return
提前退出。 - 将嵌套
if
转换为if-then-else if
序列或switch/case
。 - 将深层嵌套的代码抽取到独立的子程序中。
- 布尔表达式:
-
如何编写难以维护的代码(反面教材 - Green1997)
- 在注释中「说谎」,或不让代码和注释同步。
- 到处使用无意义的注释,如
/* add 1 to i */
,从不注释整体意图。 - 让每个方法都比它的名字多做点事(副作用)。
- 大量使用首字母缩写命名。
- 以效率为名,避免封装,暴露内部实现。
- 修改一处功能,需要改动 N 个地方,并且不记录这些地方。
- 大量使用复制/粘贴/克隆/修改。
- 从不对变量注释其用法、边界、有效值等。
- 一行中写尽可能多的代码。
- 不要使用任何代码格式整理工具,手动制造错误的对齐。
- 绝不使用
{}
界定if/else
的代码块,尤其在嵌套时。 - 只要生命周期许可,就重用那些无关的变量。
- 从不处理异常,名义上是因为「好的代码不会失败」。
- 到处硬编码常量值,如数组大小
100
。 - 保留过期的、不再使用的变量或方法,并加上令人困惑的注释。
- 把所有成员方法和变量都声明为
public
。