《软件设计的哲学》——13. 注释应该描述代码中不明显的内容

57 阅读19分钟

🎭 从第12章到第13章的关键转变

还记得第12章我们破除的那个最大谎言吗?"好的代码是自解释的"。我们发现这个看似合理的观点实际上是程序员最常用的借口。

现在第13章面临新的挑战:既然要写注释,那么注释中应该写什么?

想象一个场景:六个月后的你看到自己写的代码:

// 😰 没有注释的代码
public void processData(List<Object> items, int threshold, boolean flag) {
    Map<String, List<Object>> groups = items.stream()
        .filter(i -> calculateScore(i) > threshold)
        .collect(groupingBy(i -> extractKey(i)));
        
    groups.forEach((k, v) -> {
        if (v.size() > 10 && flag) {
            v.subList(0, 10).forEach(this::specialProcess);
        }
    });
}

你的内心独白:

  • 🤔 "threshold是什么阈值?为什么是这个值?"
  • 😵 "这个flag控制什么?什么时候应该是true?"
  • 🤯 "为什么是10?有什么业务含义?"

这就引出了本章的核心问题:什么是"不明显"的内容?

💡 核心原则:描述不明显的内容

金科玉律:注释应该描述代码中不明显的内容。

三类不明显的内容

  1. 底层细节:精确的单位、边界条件、null值处理等
  2. 代码存在的原因:为什么这样设计?为什么不用其他方法?
  3. 隐含的依赖和规则:调用顺序、全局约束等

抽象是最重要的不明显信息

"注释的最重要原因之一是抽象...代码是如此详细,以至于仅通过阅读代码就很难看到抽象。"

// ✨ 加上抽象层面的注释后
/**
 * 处理用户评论,筛选高质量评论并进行精选推荐
 * 
 * 业务背景:为了提升用户体验,我们需要从海量评论中筛选出
 * 高质量的评论进行精选推荐
 * 
 * @param items 待处理的用户评论列表
 * @param qualityThreshold 评论质量阈值(基于点赞数、回复数等计算)
 * @param enableSampling 是否启用采样(高峰期限制处理量)
 */
public void processHighQualityComments(List<Comment> items, int qualityThreshold, boolean enableSampling) {
    // 按作者分组,筛选高质量评论
    Map<String, List<Comment>> commentsByAuthor = items.stream()
        .filter(comment -> comment.getQualityScore() > qualityThreshold)
        .collect(groupingBy(comment -> comment.getAuthorId()));
        
    commentsByAuthor.forEach((authorId, comments) -> {
        // 限制每个作者最多推荐10条评论,避免刷屏
        // 高峰期启用采样以控制系统负载
        if (comments.size() > 10 && enableSampling) {
            comments.subList(0, 10).forEach(this::addToRecommendationPool);
        }
    });
}

📋 13.1 选择约定:建立团队标准

从第12章的觉醒到第13章的实践,我们需要从混乱走向有序,建立明确的注释约定。

约定的两个重要目的

  1. 确保一致性:让注释更易于阅读和理解
  2. 确保实际写注释:有明确规范就不会忘记写

四种注释类别的优先级

注释可以分为四类,按重要性排序:

1. Interface(接口注释)⭐⭐⭐⭐⭐ - 最重要

/**
 * 用户管理服务 - 核心用户生命周期管理
 * 
 * 这个类提供了用户从注册到删除的完整生命周期管理。
 * 它是用户相关功能的核心抽象,所有用户操作都应该通过这个接口。
 * 
 * 重要设计原则:
 * - 所有方法都是事务性的:要么全部成功,要么全部失败
 * - 所有操作都会记录审计日志
 * - 所有密码相关操作都会进行强加密
 */
public interface UserService {
    /**
     * 创建新用户账户
     * 
     * 这个方法启动完整的用户引导流程:验证信息、创建账户、
     * 发送欢迎邮件、初始化偏好设置、触发用户画像分析(异步)
     * 
     * @param userInfo 用户基本信息,邮箱字段必须唯一且有效
     * @return 创建的用户对象,包含系统生成的ID和时间戳
     * @throws DuplicateEmailException 邮箱已被注册
     * @throws InvalidUserDataException 用户信息不符合要求
     */
    User createUser(UserRegistrationInfo userInfo) throws DuplicateEmailException, InvalidUserDataException;
}

2. Data structure member(数据结构成员)⭐⭐⭐⭐ - 很重要

public class OrderStatus {
    /**
     * 订单创建时间戳(毫秒)
     * 
     * 这是订单在系统中的创建时间,不是用户下单时间。
     * 用户下单时间可能因为网络延迟、系统处理等原因与此时间有差异。
     * 
     * 时区:UTC+8(北京时间)
     * 用途:用于订单超时计算、统计分析、审计追踪
     * 
     * 注意:这个时间是不可变的,创建后不会更新
     */
    private long createdTimestamp;
    
    /**
     * 订单状态枚举
     * 
     * 状态流转规则:
     * PENDING -> PAID -> SHIPPED -> DELIVERED -> COMPLETED
     * 任何状态都可以转换为 CANCELLED 或 REFUNDED
     * 
     * 业务含义:
     * - PENDING: 等待支付,超时30分钟自动取消
     * - PAID: 已支付,等待发货,24小时内必须发货
     * - SHIPPED: 已发货,物流信息已更新
     * - DELIVERED: 已送达,等待用户确认收货
     * - COMPLETED: 交易完成,不可撤销
     * - CANCELLED: 已取消,如果已支付则自动退款
     * - REFUNDED: 已退款,退款金额已返还用户账户
     */
    private OrderStatusEnum status;
    
    /**
     * 乐观锁版本号
     * 
     * 用于防止并发修改冲突。每次更新订单时版本号+1。
     * 如果更新时版本号不匹配,说明订单已被其他操作修改,
     * 需要重新读取最新数据后再尝试更新。
     * 
     * 初始值:0
     * 更新策略:每次写操作自动递增
     * 
     * 注意:这个字段对业务逻辑透明,只用于并发控制
     */
    private int version;
}

3. Implementation(实现注释)⭐⭐⭐ - 适度使用

public class PaymentProcessor {
    public void processPayment(PaymentRequest request) {
        // 预授权检查:避免无效支付请求占用系统资源
        // 这里只做基本验证,详细验证在支付网关进行
        if (!isValidPaymentRequest(request)) {
            throw new InvalidPaymentException("支付请求格式错误");
        }
        
        // 风控检查:基于用户历史行为和当前交易特征
        // 如果风险评分 > 80,需要额外验证(短信验证码)
        // 如果风险评分 > 90,直接拒绝交易
        RiskScore riskScore = riskEngine.evaluate(request);
        if (riskScore.getScore() > 90) {
            logSecurityEvent(request, "高风险交易被拒绝");
            throw new HighRiskTransactionException("交易风险过高");
        }
        
        // 支付处理:这里使用异步方式处理
        // 原因:支付网关响应时间不稳定(1-10秒),异步处理避免阻塞
        // 结果通过回调接口异步返回
        PaymentJobId jobId = paymentGateway.submitPaymentAsync(request);
        
        // 状态追踪:记录支付任务,用于后续状态查询和异常处理
        paymentJobRepository.save(new PaymentJob(jobId, request.getOrderId()));
    }
}

4. Cross-module(跨模块注释)⭐⭐ - 稀有但关键

public class OrderService {
    /**
     * 处理订单取消逻辑
     * 
     * 跨模块影响分析:
     * 
     * 1. 库存服务 (InventoryService):
     *    - 需要释放已预占的库存
     *    - 如果库存释放失败,订单状态保持为"取消中"
     *    - 会触发库存预警重新计算
     * 
     * 2. 支付服务 (PaymentService):
     *    - 如果已支付,需要发起退款流程
     *    - 退款是异步的,可能需要1-3个工作日
     *    - 退款失败会触发人工处理流程
     * 
     * 3. 物流服务 (LogisticsService):
     *    - 如果已发货,需要发起退货流程
     *    - 退货物流费用处理规则复杂,需要调用费用计算服务
     * 
     * 4. 用户服务 (UserService):
     *    - 需要发送订单取消通知
     *    - 更新用户信誉度评分
     *    - 频繁取消订单可能触发风控审核
     * 
     * 异常处理策略:
     * - 核心操作(库存释放、支付退款)失败时,订单进入"异常"状态
     * - 辅助操作(通知发送、数据统计)失败时,记录错误但不影响主流程
     * - 所有跨模块调用都有超时和重试机制
     */
    public void cancelOrder(Long orderId) {
        // 复杂的跨模块协调逻辑...
    }
}

原文强调:前两种注释最重要。每个类、每个类变量、每个方法都应该有注释。

🚩 13.2 不要重复代码:最致命的注释反模式

判断标准:黄金测试法则

"从未看过代码的人能否仅通过查看注释旁边的代码来写出这样的注释?如果答案是肯定的,则注释不会使代码更易于理解。"

典型的重复注释示例

// ❌ 完全重复代码
// Add a horizontal scroll bar
hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL);
add(hScrollBar, BorderLayout.SOUTH);

// ❌ 只是重复方法名
/*
 * Obtain a normalized resource name from REQ.
 */
private static String[] getNormalizedResourceNames(HTTPRequest req) {...}

改进策略:使用不同的词汇和层次

// ❌ 重复词汇
/*
 * The horizontal padding of each line in the text.
 */
private static final int textHorizontalPadding = 4;

// ✅ 提供额外信息
/*
 * The amount of blank space to leave on the left and
 * right sides of each line of text, in pixels.
 * 
 * This spacing ensures text doesn't touch the container edges,
 * improving readability especially on mobile devices.
 */
private static final int textHorizontalPadding = 4;

重复注释的危害

  1. 信息噪声:大量无效注释降低了有用信息的密度
  2. 维护负担:每次修改代码都需要修改注释,否则产生不一致
  3. 误导读者:让读者以为所有注释都是无用的,忽略真正有价值的注释

🔍 13.3 低级注释增加精度

适用场景

精度在注释变量声明时最有用:类实例变量、方法参数、返回值。

需要澄清的5个关键问题

  1. 单位:此变量的单位是什么?
  2. 边界条件:是包含还是排除?
  3. 空值含义:如果允许null,意味着什么?
  4. 资源管理:谁负责释放或关闭资源?
  5. 不变量:是否有始终为真的属性?

实战对比示例

// ❌ 太模糊
// Current offset in resp Buffer
uint32_t offset;

// Contains all line-widths inside the document and
// number of appearances.
private TreeMap<Integer, Integer> lineWidths;

// ✅ 精确描述
// Position in this buffer of the first object that hasn't
// been returned to the client.
uint32_t offset;

// Holds statistics about line lengths of the form <length, count>
// where length is the number of characters in a line (including
// the newline), and count is the number of lines with
// exactly that many characters. If there are no lines with
// a particular length, then there is no entry for that length.
private TreeMap<Integer, Integer> numLinesWithLength;

实际应用示例

// ❌ 缺少精确性信息
public void processPayment(double amount, String currency) {
    // 处理支付
}

// ✅ 提供精确性信息
/**
 * 处理支付请求
 * 
 * 数据精度要求:
 * @param amount 支付金额,单位:分(避免浮点精度问题)
 *               有效范围:1-999999999(1分到999万元)
 * @param currency 货币代码,遵循ISO 4217标准(如"CNY", "USD")
 *                 当前支持:CNY, USD, EUR, GBP
 * 
 * 边界条件:
 * - amount <= 0 时抛出IllegalArgumentException
 * - 不支持的currency时抛出UnsupportedCurrencyException
 * - 单日累计支付额度限制:10万元
 * 
 * 空值处理:
 * - amount为null时使用默认值0(会触发异常)
 * - currency为null时默认使用"CNY"
 */
public void processPayment(BigDecimal amount, String currency) {
    // 处理支付
}

关键原则:名词而非动词

"在为变量添加注释时,请考虑使用名词而不是动词。换句话说,关注变量代表什么,而不是如何被操纵。"

🎯 13.4 高级注释增强直觉

作用:提供概念框架

高级注释写在比代码更高的层次,忽略细节,帮助读者理解代码的整体意图和结构。

经典对比例子

// ❌ 太低级,部分重复代码
// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that readRPC.

// ✅ 高级抽象,提供概念框架
// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.

写作技巧:回答三个关键问题

"问问自己:这段代码要做什么?您能以何种最简单方式来解释代码中的所有内容?这段代码最重要的是什么?"

实际应用示例

public class RecommendationEngine {
    public void optimizeRecommendations(UserProfile profile) {
        /*
         * 个性化推荐优化算法
         * 
         * 核心思路:平衡用户兴趣的多样性和准确性
         * - 70%基于用户历史偏好(准确性)
         * - 20%基于相似用户行为(发现性)
         * - 10%基于热门趋势(时效性)
         * 
         * 这个比例经过A/B测试验证,能最大化用户点击率
         */
        
        // 具体实现细节...
        List<Item> historicalItems = getHistoricalPreferences(profile);
        List<Item> similarUserItems = getSimilarUserBehavior(profile);
        List<Item> trendingItems = getTrendingItems();
        
        // 按权重混合推荐结果
        List<Item> recommendations = mixRecommendations(
            historicalItems, 0.7,
            similarUserItems, 0.2,
            trendingItems, 0.1
        );
    }
}

高级注释的价值

  • 提供业务背景和设计意图
  • 解释算法选择的原因
  • 描述整体架构思路
  • 帮助读者快速理解复杂逻辑

📚 13.5 接口文档:定义抽象的艺术

接口注释的核心作用

"注释最重要的作用之一就是定义抽象...代码不适合描述抽象...描述抽象的唯一方法是使用注释。"

分离原则:接口 vs 实现

  • 接口注释:使用类或方法需要知道的信息(定义抽象)
  • 实现注释:类或方法内部如何工作(实现抽象)

重要:如果接口注释也必须描述实现,则该类或方法很浅。

类的接口注释

/**
 * 分布式缓存管理器 - 高性能多级缓存解决方案
 * 
 * 这个类提供了一个统一的缓存接口,内部管理多级缓存层次:
 * - L1: 本地内存缓存(最快,容量小)
 * - L2: 本地磁盘缓存(中等速度,容量中等)
 * - L3: 分布式Redis缓存(较慢,容量大)
 * 
 * 缓存策略:
 * - 读取:按L1->L2->L3顺序查找,找到后回填上层缓存
 * - 写入:同时写入所有层次,保证一致性
 * - 淘汰:LRU算法,自动管理内存使用
 * 
 * 性能特征:
 * - 缓存命中率:>90%(生产环境统计)
 * - 平均响应时间:<10ms(99%的请求)
 * - 支持并发:1000+ QPS
 * 
 * 使用场景:
 * - 适用于读多写少的场景
 * - 数据一致性要求不是极其严格
 * - 需要高性能数据访问
 */
public class DistributedCacheManager {
    // 实现细节...
}

方法的接口注释:5个必备要素

  1. 行为描述(高级抽象)
  2. 参数和返回值(必须精确)
  3. 副作用(影响系统未来行为)
  4. 异常(可能抛出的异常)
  5. 前置条件(调用前必须满足的条件)

实战示例

/**
 * 处理支付请求并更新订单状态
 * 
 * 这个方法执行完整的支付流程,包括风控检查、支付处理和状态更新。
 * 支付过程是异步的,结果通过回调接口返回。
 * 
 * 副作用:
 * - 更新订单状态为"支付中"
 * - 记录支付日志用于审计
 * - 发送支付确认邮件
 * - 扣减用户账户余额(如果使用钱包支付)
 * 
 * @param paymentRequest 支付请求,必须包含有效的订单ID和金额
 * @return 支付任务ID,用于后续状态查询
 * @throws InvalidPaymentException 支付请求格式错误或金额不匹配
 * @throws InsufficientFundsException 账户余额不足
 * @throws RiskControlException 风控检查未通过
 * 
 * 前置条件:
 * - 订单必须处于"待支付"状态
 * - 用户必须已通过实名认证
 * - 支付金额必须在单笔限额内
 */
public PaymentJobId processPayment(PaymentRequest paymentRequest) 
        throws InvalidPaymentException, InsufficientFundsException, RiskControlException {
    // 实现细节...
}

🚩 红旗警告:实现文档污染接口

"当接口文档记录了使用过程中不需要知道的实现的详细信息时,就会出现此红色标记。"

// ❌ 接口注释包含实现细节
/**
 * 保存用户信息
 * 
 * 实现细节:
 * - 使用MySQL数据库的users表
 * - 密码使用BCrypt加密,salt长度16位
 * - 头像上传到阿里云OSS,路径格式为/avatars/{userId}.jpg
 * - 使用Redis缓存用户基本信息,TTL为1小时
 * 
 * 这些实现细节对接口用户没有价值,反而增加了认知负担
 */
public void saveUser(User user) {...}

// ✅ 接口注释只描述抽象
/**
 * 保存用户信息到系统中
 * 
 * 这个方法会完整保存用户的基本信息,包括密码加密、头像存储等。
 * 保存成功后用户可以立即使用新信息登录系统。
 * 
 * @param user 用户信息对象,必须包含用户名、邮箱、密码
 * @throws DuplicateUserException 用户名或邮箱已存在
 * @throws InvalidDataException 用户信息格式不正确
 */
public void saveUser(User user) throws DuplicateUserException, InvalidDataException {...}

⚙️ 13.6 实现注释:what和why,而不是how

核心目标

"实现注释的主要目的是帮助读者理解代码在做什么(而不是代码如何工作)。"

三种有效的实现注释

1. 主要代码块前的注释

public void processOrder(Order order) {
    // 订单验证:检查商品库存、用户权限、支付信息
    // 这个步骤确保订单数据的完整性和合法性
    validateOrder(order);
    
    // 价格计算:应用优惠券、计算税费、处理汇率转换
    // 支持多种优惠策略的组合使用
    calculateFinalPrice(order);
    
    // 库存预占:防止超卖,预占期限30分钟
    // 使用悲观锁策略,确保高并发下的数据一致性
    reserveInventory(order);
    
    // 订单持久化:保存到数据库,生成唯一订单号
    // 使用分布式ID生成器,确保全局唯一性
    saveOrder(order);
}

2. 循环注释

// 分批处理大量数据,避免内存溢出
// 每批处理1000条记录,处理间隔100ms防止CPU过载
for (int i = 0; i < totalRecords; i += BATCH_SIZE) {
    List<Record> batch = getRecordBatch(i, BATCH_SIZE);
    processBatch(batch);
    Thread.sleep(100); // 给系统喘息时间
}

// 重试机制:网络请求失败时自动重试
// 使用指数退避算法,最大重试3次
int retryCount = 0;
while (retryCount < MAX_RETRIES) {
    try {
        result = remoteService.call(request);
        break; // 成功则跳出循环
    } catch (NetworkException e) {
        retryCount++;
        long delay = (long) (Math.pow(2, retryCount) * 1000); // 指数退避
        Thread.sleep(delay);
    }
}

3. 解释为什么的注释

// 使用ConcurrentHashMap而不是HashMap
// 原因:这个缓存会被多个线程同时访问
// 虽然性能稍差,但避免了并发修改异常
private Map<String, Object> cache = new ConcurrentHashMap<>();

// 必须在事务提交后发送邮件
// 如果在事务内发送,事务回滚时邮件已经发出,造成数据不一致
transaction.commit();
emailService.sendOrderConfirmation(order);

// 使用软引用缓存图片,允许GC在内存不足时回收
// 这样既提高了性能,又避免了内存泄露
private Map<String, SoftReference<BufferedImage>> imageCache = new HashMap<>();

避免的实现注释

// ❌ 解释代码如何工作(太低级)
// 使用for循环遍历数组
for (int i = 0; i < items.length; i++) {
    // 获取数组元素
    Item item = items[i];
    // 调用处理方法
    processItem(item);
}

// ✅ 解释代码在做什么(合适的级别)
// 逐个处理队列中的待处理项目
// 按照先进先出的顺序保证处理的公平性
for (int i = 0; i < items.length; i++) {
    Item item = items[i];
    processItem(item);
}

🌐 13.7 跨模块设计决策:解决文档归属难题

挑战:文档放在哪里?

跨模块的设计决策影响多个类,但很难找到合适的位置放置文档。传统的解决方案都有问题:

  • 放在某个类中:其他相关类的开发者可能看不到
  • 放在多个类中:容易产生不一致,维护困难
  • 放在外部文档:容易与代码脱节,过时后没人更新

解决方案:designNotes中央文件

原文提出了一个创新的方法:将跨模块问题记录在一个名为designNotes的中央文件中。

实际应用示例

/**
 * 跨模块设计决策:用户状态一致性策略
 * 
 * 问题背景:
 * 用户信息分布在多个模块中(用户服务、订单服务、积分服务、推荐服务)
 * 如何保证用户状态在所有模块中的一致性?
 * 
 * 解决方案:事件驱动的最终一致性
 * 1. 用户服务作为主数据源,发布用户状态变更事件
 * 2. 其他服务订阅事件,异步更新本地数据
 * 3. 使用消息队列保证事件传递的可靠性
 * 4. 实现补偿机制处理异常情况
 * 
 * 设计权衡:
 * - 优点:各服务解耦,性能好,扩展性强
 * - 缺点:数据可能短暂不一致,复杂性增加
 * - 业务可接受性:用户信息变更频率低,短暂不一致可接受
 * 
 * 实现要点:
 * - 事件格式:JSON格式,包含用户ID、变更类型、变更内容、时间戳
 * - 重试策略:失败重试3次,指数退避,最终放入死信队列
 * - 监控告警:事件处理延迟超过5分钟时告警
 * - 数据修复:定期全量同步,修复可能的数据不一致
 * 
 * 涉及模块:UserService, OrderService, PointService, RecommendationService, EventBus
 * 
 * 更新记录:
 * - 2023-08-15: 初始设计 by 张三
 * - 2023-09-20: 增加重试机制 by 李四
 * - 2023-10-10: 添加监控告警 by 王五
 */

designNotes文件结构建议

designNotes/
├── authentication.md          # 认证鉴权策略
├── data-consistency.md        # 数据一致性策略
├── error-handling.md          # 错误处理规范
├── performance-optimization.md # 性能优化决策
├── security-considerations.md  # 安全考虑
├── deployment-strategy.md     # 部署策略
└── monitoring-alerting.md     # 监控告警规范

designNotes的优势

  1. 集中管理:所有跨模块决策集中在一个地方
  2. 版本控制:跟随代码一起进行版本管理
  3. 搜索友好:开发者容易找到相关设计决策
  4. 维护性好:修改设计决策时只需要更新一个地方
  5. 知识传承:新人可以快速了解系统的设计思路

🎯 总结:注释的最终目标

"注释的目的是确保系统的结构和行为对读者来说是显而易见的,因此他们可以快速找到所需的信息,并有信心对其进行修改。"

从读者视角思考

"当遵循注释应描述代码中不明显的内容的规则时,'明显'是从第一次读取您的代码的人(不是您)的角度出发。"

注释的层次结构

  1. 类级注释:定义抽象,解释类的用途和设计理念
  2. 方法级注释:描述行为,说明参数、返回值、异常
  3. 变量级注释:增加精度,澄清单位、边界、约束
  4. 实现级注释:解释复杂逻辑,说明设计决策
  5. 跨模块注释:处理系统级设计决策

与前面章节的关系

  • 第2章(复杂性):注释降低复杂性中的"模糊性"
  • 第4章(深模块):注释是创建简单接口的关键工具
  • 第5章(信息隐藏):注释正确暴露抽象,隐藏实现细节
  • 第11章(设计两次):注释是设计思考的体现
  • 第12章(注释价值):从"为什么写"到"怎么写"的实践转换

实用建议

  • 进入读者心态:问自己"他们需要知道什么关键信息?"
  • 使用黄金测试:从未见过代码的人能否仅从代码写出这个注释?
  • 关注不明显内容:底层细节、设计原因、隐含规则
  • 持续改进:在代码审查中重视注释质量
  • 建立约定:为团队制定清晰的注释规范

行动指南

  1. 建立约定:为团队制定注释规范
  2. 优先级排序:接口注释 > 数据成员注释 > 实现注释
  3. 质量检查:使用"读者能否仅从代码写出注释"的标准
  4. 持续改进:在代码审查中重视注释质量

"好的注释通常以与代码不同的详细程度来解释事物,在某些情况下更详细,在某些情况下更抽象。" —— John Ousterhout

记住:注释不是代码的注脚,而是设计思维的体现。它们让代码从死的文本变成活的知识传承!