代码设计

AI WARNING

未进行对照审核。

代码设计的目标是编写高质量的代码,其核心在于提升代码的可读性可维护性可靠性。良好的代码设计不仅方便团队协作,减少软件维护成本,还能构建稳健的系统。

设计易读的代码

易读的代码如同清晰的文章,能够让他人(以及未来的自己)快速理解其意图和逻辑。这对于软件维护和团队协作至关重要。

代码规范

统一的代码规范是提高代码可读性的基础,常见的规范包括格式、命名和注释。

布局格式

良好的布局能够清晰地展现代码的逻辑结构。

  1. 使用缩进与对齐表达逻辑结构

    • 一致的缩进(通常为 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); // 对齐
    }
    }
  2. 将相关逻辑组织在一起

    • 在类定义中,相关的成员(如属性、构造方法、公有方法、保护方法、私有方法)应该组织在一起,形成逻辑分组。
    • 例如,可以将所有成员变量声明放在类的顶部,接着是构造方法,然后是不同访问级别的方法。
  3. 使用空行分割逻辑

    • 在不同的逻辑单元之间使用空行,可以像段落一样分隔代码,使得结构更清晰。
    • 例如,在 switch 语句的各个 case 块之间,或在不同功能的方法调用序列之间。
  4. 语句分行

    • 避免将过长的语句放在一行,这会影响可读性,尤其是在屏幕或打印输出时。
    • 复杂的条件表达式或方法调用链可以适当换行并对齐。
    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
  • 名称与实际内容相符:例如,一个不仅计算变化还维护账单数据的类,用 PaymentBillChangeCalculator 更合适。
  • 临时变量命名:对于生命周期极短的临时变量(如 for 循环计数器),可以使用 i, j, k 等;但若有特定含义,仍应赋予有意义的名称。
  • 避免使用易混淆的字符:如 l(小写 L)和 1(数字一),O(大写 O)和 0(数字零)。
  • 避免使用无逻辑的缩写:如 wrttn 代表 written,这会降低可读性。

注释

注释是对代码的补充说明,帮助理解代码功能、设计思路和使用方法。

  1. 注释类型(以 Java 为例)

    • 语句注释:// ... 用于单行注释。
    • 标准注释:/* ... */ 用于多行注释。
    • 文档注释/** ... */ 用于生成 API 文档(如 Javadoc)。
  2. 文档注释

    • 目的:为包、类、接口、方法、字段等提供规范化的说明,以便工具(如 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);
    }
    }
  3. 内部注释

    • 要有意义:解释代码「为什么」这么做,而不是「做什么」(代码本身应清晰表达「做什么」)。避免简单重复代码的含义。
    • 重视对数据类型的注释:解释复杂数据结构、关键变量的含义和取值范围。
    • 重视对复杂控制结构的注释:解释复杂算法、决策逻辑的思路。
    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. 处理复杂决策
    复杂的条件判断是维护的难点。

    • 使用新的布尔变量简化:将复杂的布尔表达式赋值给一个有意义名称的布尔变量。
      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-elseswitch 结构),可以使用表(如数组、Map)来存储条件和对应的动作/结果,通过查表代替复杂的逻辑判断。这使得逻辑更清晰,且易于修改和扩展。

表驱动示例:根据积分赠送礼品等级

假设积分规则如下:

  • 积分 << 1000:无礼品
  • 1000 \le 积分 << 2000:礼品等级 1
  • 2000 \le 积分 << 5000:礼品等级 2
  • 积分 \ge 5000:礼品等级 3

传统 if-else

1
2
3
4
5
6
7
8
9
10
11
int getGiftLevel(int points) {
if (points < 1000) {
return 0; // 无礼品
} else if (points < 2000) {
return 1;
} else if (points < 5000) {
return 2;
} else {
return 3;
}
}

表驱动实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 定义阈值和对应的礼品等级
int[] pointThresholds = {1000, 2000, 5000, Integer.MAX_VALUE};
int[] giftLevels = {0, 1, 2, 3}; // 对应阈值前的等级

int getGiftLevelTableDriven(int points) {
for (int i = 0; i < pointThresholds.length; i++) {
if (points < pointThresholds[i]) {
// 找到第一个大于当前积分的阈值,其对应的 giftLevels[i] 就是上一档的等级
// 或者调整表结构使之更直接
if (i == 0) return 0; // 小于最低阈值
return giftLevels[i]; // 实际上这里需要调整表的设计,或逻辑
// 更常见的表驱动是直接映射或区间映射
}
}
return giftLevels[giftLevels.length - 1]; // 超过所有阈值
}
// 更优的表驱动设计(示例):
// prePointArray (小于此值) postPointArray (大于等于此值) level
// 1000 - 0
// 2000 1000 1
// 5000 2000 2
// - 5000 3
// 实际代码会用循环查表
int[] prePointArray = {1000, 2000, 5000}; // 积分上限(不含)
int[] levelArray = {1, 2, 3 }; // 对应礼品等级
// ... 循环查找 ...

表驱动的核心思想是将逻辑判断转化为数据查询,当规则频繁变动或规则数量很多时,维护数据表通常比维护复杂的 if-else 语句更容易。

  1. 数据使用

    • 专变量专用:不要将变量用于与其命名不符的目的(如用 total 临时做循环计数器)。
    • 单一用途:一个变量只用于一个目的。避免在代码不同部分让同一变量表示不同含义。
    • 限制全局变量:全局变量会增加模块间的耦合,难以追踪修改。如果必须使用,要明确注释其声明和使用处。
    • 避免魔法数值/字符串:将代码中直接出现的、含义不明的数值或字符串(如 15 代表天数,"MALE" 代表性别)定义为有意义名称的常量。
  2. 明确依赖关系

    • 类之间、模块之间的依赖关系应清晰明了。模糊的依赖关系(如通过全局变量间接交互)会使代码难以理解和修改,容易产生未预期的连锁反应。
    • 使用 Javadoc 的 @see 或其他文档方式明确依赖。

设计可靠的代码

可靠的代码能在各种情况下正确执行,或在发生错误时能以可控的方式处理。

  1. 契约式设计(Design by Contract, DbC)

    • 核心思想:软件模块(如类、方法)之间的交互建立在明确的「契约」之上。契约规定了调用方和被调用方的责任和权利。
    • 组成
      • 前置条件(Precondition):调用方必须满足的条件,才能调用该方法。
      • 后置条件(Postcondition):方法完成后,被调用方保证满足的条件。
      • 不变式(Invariant):在方法执行期间(对于类而言,是对象生命周期中稳定状态时)必须保持为真的条件。
    • 实现方式
      • 异常方式:在方法入口检查前置条件,若不满足则抛出异常。在方法出口(或 return 前)检查后置条件,若不满足也抛出异常。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        public 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(可选):一个值,如果 Expression1falseExpression2 的字符串形式会作为 AssertionError 的一部分。
          • 如果 Expression1true,断言不影响程序执行。
          • 如果 Expression1false,抛出 AssertionError
        • 断言通常用于开发和测试阶段,默认情况下在运行时可能被禁用以提高性能。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        public 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;
        }
      • 比较:异常是方法契约的一部分,调用者应准备处理。断言主要用于内部一致性检查,通常不期望调用者捕获 AssertionError
  2. 防御式编程

    • 核心思想:方法在与外部环境(其他方法、操作系统、用户输入等)交互时,不能完全信任外部的正确性。要在发生错误时保护方法内部不受损害。
    • 常见场景
      • 输入参数是否合法?
      • 用户输入是否有效?
      • 外部文件是否存在/可读/可写?
      • 对象引用是否为 NULL
      • 其他对象是否已正确初始化/执行了必要方法?
      • 数据库/网络连接是否正常?
    • 异常和断言都可以用于实现防御式编程。

使用模型辅助设计复杂代码

对于复杂的逻辑,使用模型可以帮助梳理思路,减少错误。

  1. 决策表

    • 一种表格化的方式,用于表示复杂的条件组合及其对应的动作。
    • 结构
      • 条件声明:列出所有相关的条件。
      • 行动声明:列出所有可能的行动。
      • 条件选项:针对每条规则,标出条件的取值(如 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 |

  2. 伪代码

    • 介于自然语言和编程语言之间的一种描述算法的方式。
    • 使用编程语言的关键字(如 IF-THEN-ELSE, WHILE, FOR)和结构,但允许使用自然语言描述具体操作。
    • 优点:专注于逻辑流程,忽略具体语法细节,便于沟通和快速原型设计。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    得到 SalesLineItem 的迭代器
    WHILE 迭代器有下一个元素
    找到 SalesLineItem 对象
    按照更新步骤进行更新:
    得到相应的 Mapper
    将自己的信息转为层间传递的 PO 对象
    将 PO 对象交给 Mapper
    Mapper 完成更新
    END WHILE
  3. 程序流程图

    • 使用标准化的图形符号表示程序的控制流和操作步骤。
    • 常用符号:圆角矩形(开始/结束)、矩形(处理步骤)、菱形(判断)、平行四边形(输入/输出)。
    • 优点:直观展示复杂逻辑路径。
    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;

为代码开发单元测试用例

单元测试是验证代码模块(通常是方法或类)功能正确性的重要手段。

  1. 为方法开发测试用例

    • 基于规格的测试(黑盒):不关心内部实现,只根据方法的功能说明(规格)设计测试用例。
      • 等价类划分:将输入数据划分为若干个等价类,从每个类中选取代表性数据作为测试用例。
      • 边界值分析:针对等价类的边界以及略过边界的值设计测试用例,这些地方容易出错。
    • 基于代码的测试(白盒):根据代码的内部逻辑结构设计测试用例,以达到一定的代码覆盖率。
      • 语句覆盖:每个可执行语句至少执行一次。
      • 分支覆盖(判定覆盖):每个判断条件的真假分支至少执行一次。
      • 路径覆盖:程序中所有可能的执行路径至少执行一次(通常难以完全实现)。
    • Mock 对象:当被测方法依赖于其他复杂对象或外部系统时,可以使用 Mock 对象(模拟对象)来替代这些依赖,使得测试环境更可控、测试执行更快。例如,测试 Sales.total() 方法时,它依赖 SalesLineItem.subTotal(),可以创建一个 MockSalesLineItem 类来提供固定的 subTotal() 返回值。
  2. 为类开发测试用例

    • 复杂类通常有多种状态,方法的执行会改变类的状态,并可能影响其他方法的行为。
    • 除了测试类的每个独立方法外,还需要测试不同方法之间因状态改变而产生的交互影响。
    • 状态图可以帮助分析类的状态转换,并据此设计测试序列(方法调用顺序)来覆盖不同的状态和转换。

代码复杂度度量

程序复杂度是导致编程困难和维护成本高的主要原因之一。

圈复杂度(Cyclomatic Complexity) - McCabe

  • 基本思路:衡量程序中独立路径的最大数量。
  • 计算方法:
    1. 基于流程图:
      • V(G)=EN+2V(G) = E - N + 2
      • 其中,GG 是程序的控制流程图,EE 是图中边的数量,NN 是图中节点的数量。
    2. 基于决策点:
      • V(G)=1+DV(G) = 1 + D
      • DD 是程序中决策点(如 if, while, for 中的条件,case 语句等)的数量。每个 case 分支算一个决策点。
  • 度量的意义(McConnell 建议):
    • 0-5:子程序可能还不错。
    • 6-10:需要考虑简化子程序。
    • 10+:应将子程序的某部分拆分成独立的子程序并调用它。
    • 这只是一个警示,说明子程序可能需要重新设计。
  • 类的复杂度:可以定义为类中所有方法代码复杂度的总和。

问题代码警示

借鉴《代码大全》

以下是一些常见的导致代码质量下降的做法,应尽量避免:

  1. 变量处理不当

    • 定义与初始化
      • 避免隐式声明(某些语言特性)。
      • 声明所有变量,并在靠近首次使用的地方初始化。
      • 尽可能使用 final(Java)或 const(C++)声明不应改变的变量。
    • 作用域
      • 使变量作用域尽可能小(局部化)。
      • 直到即将使用时再赋值。
    • 持续性
      • 编写代码时假设数据没有持续性,除非显式处理。
      • 养成在使用所有数据前声明和初始化的习惯。
    • 单一用途:一个变量只用于一个明确的目的,避免复用导致含义混淆。
    • 避免隐含意义:如用 pageCount = -1 表示错误,应使用更明确的方式(如异常或专门的状态变量)。
  2. 数值处理问题

    • 避免「神秘数值」:使用有意义的常量名代替硬编码的数字。
    • 整数:注意整数除法(截断)、整数溢出(尤其在中间计算步骤)。
    • 浮点数
      • 避免数量级相差巨大的数之间的加减运算(精度损失)。
      • 避免直接进行等量判断(float_a == float_b),应使用一个小的容差范围(Math.abs(float_a - float_b) < EPSILON)。
      • 注意舍入误差。
  3. 子程序(方法/函数)设计

    • 创建理由:降低复杂度、引入抽象、避免代码重复、支持子类化、隐藏实现细节(顺序、指针操作)、提高可移植性、简化复杂布尔判断、改善性能(有时通过集中优化)。
    • 良好命名
      • 准确描述子程序所做的所有事情。
      • 避免无意义、模糊的动词(如 handleData, processInput)。
      • 函数命名应能体现返回值(如 isReady(), getColor())。
      • 过程命名使用「动词+宾语」形式(如 printDocument())。
  4. 一般控制问题

    • 布尔表达式
      • truefalse 做布尔判断,而不是 01(除非语言特性如此)。
      • 简化复杂表达式(引入布尔变量或布尔函数)。
      • 使用肯定形式的布尔表达式(if (statusOk) 通常比 if (!statusNotOk) 好)。
      • 使用括号明确优先级。
    • 复合语句:始终使用花括号 {} 包围 if, else, while, for 的执行体,即使只有一条语句,以避免悬挂 else 等问题。
    • 空语句:如果循环体为空(如 while(processNext() != DONE);),应使其明显,例如:while(processNext() != DONE) { /* 空循环体 */ }while(processNext() != DONE) continue;
    • 驯服深层嵌套:过深的嵌套难以理解和维护。可以通过以下方式简化:
      • 将部分条件检测提取到前面。
      • 使用 breakreturn 提前退出。
      • 将嵌套 if 转换为 if-then-else if 序列或 switch/case
      • 将深层嵌套的代码抽取到独立的子程序中。
  5. 如何编写难以维护的代码(反面教材 - Green1997)

    • 在注释中「说谎」,或不让代码和注释同步。
    • 到处使用无意义的注释,如 /* add 1 to i */,从不注释整体意图。
    • 让每个方法都比它的名字多做点事(副作用)。
    • 大量使用首字母缩写命名。
    • 以效率为名,避免封装,暴露内部实现。
    • 修改一处功能,需要改动 N 个地方,并且不记录这些地方。
    • 大量使用复制/粘贴/克隆/修改。
    • 从不对变量注释其用法、边界、有效值等。
    • 一行中写尽可能多的代码。
    • 不要使用任何代码格式整理工具,手动制造错误的对齐。
    • 绝不使用 {} 界定 if/else 的代码块,尤其在嵌套时。
    • 只要生命周期许可,就重用那些无关的变量。
    • 从不处理异常,名义上是因为「好的代码不会失败」。
    • 到处硬编码常量值,如数组大小 100
    • 保留过期的、不再使用的变量或方法,并加上令人困惑的注释。
    • 把所有成员方法和变量都声明为 public