为什么 AI 写代码时,总喜欢“防御性编程”?

0 阅读8分钟

为什么 AI 写代码时,总喜欢“防御性编程”?

让 AI 实现一个简单方法,原本只需要几行代码,结果它加上了空值判断、参数校验、异常捕获、默认返回值,甚至重试逻辑。

代码看起来很周全,但仔细检查又会发现:有些判断根本不会触发,有些默认值改变了业务含义,还有些异常处理让真正的问题消失了。

为什么 AI 容易写出这样的代码?这些“保护措施”到底是在提高可靠性,还是增加维护成本?

理解这个问题,需要先区分两件事:合理的防御性编程,与缺乏依据的过度防御。

一、什么是防御性编程?

防御性编程,就是在编写代码时,主动考虑不符合预期的输入、状态和外部故障,并明确处理方式。

例如,计算平均值时,需要考虑集合为空的情况:

public double average(List<Integer> values) {
    if (values == null || values.isEmpty()) {
        throw new IllegalArgumentException("values 不能为空");
    }

    return values.stream()
            .mapToInt(Integer::intValue)
            .average()
            .orElseThrow();
}

这里的判断,是在明确方法契约:调用方必须提供一个非空、至少包含一个元素的集合。

但防御并不等于“任何问题都返回默认值”:

public double average(List<Integer> values) {
    if (values == null || values.isEmpty()) {
        return 0;
    }

    // ...
}

第二种写法未必正确。没有数据和平均值为零,是两种不同状态。

防御性编程的重点,是让异常情况具有明确语义,而不是让程序永远不报错。

二、为什么 AI 容易增加防御代码?

下面讨论的是常见代码表现的解释,并不意味着所有模型都具有相同的内部机制。

1. 上下文不完整,就难以判断哪些情况不可能发生

开发者知道某个参数已经在 Controller 层校验过,也知道数据库字段不允许为空。

但 AI 如果只看到一个 Service 方法,就未必知道这些约束。

例如:

public UserDTO getUser(Long userId) {
    return convert(userRepository.findById(userId));
}

只看这个方法,会出现很多问题:

  • userId 是否允许为空?
  • 查不到用户时返回什么?
  • convert() 是否接受空值?
  • 数据访问异常由哪一层处理?

当这些信息缺失时,AI 可能选择增加判断:

if (userId == null) {
    return null;
}

这段代码看似避免了一次异常,却没有回答一个更重要的问题:非法参数为什么应该返回 null

判断可以补上,但业务契约不能靠猜。

2. “健壮一点”容易被理解成“多处理几种情况”

很多开发需求会这样描述:

帮我优化一下,考虑边界情况,保证程序稳定。

这些要求并没有定义哪些边界需要处理,也没有说明失败时应当怎样表现。

于是,AI 可能把任务转化成一组可见的动作:

  • 增加空值判断。
  • 捕获异常。
  • 返回默认值。
  • 增加重试。
  • 设置备用路径。

这些动作容易体现“做了更多工作”,却不一定提高正确性。

“稳定”需要落实为具体规则。例如:

用户不存在时返回业务错误;数据库不可用时保留失败状态;不要统一返回空对象。

这比“保证不报错”更有指导意义。

3. 通用示例中的保护措施,不一定适合当前项目

公开代码和教学示例中,经常出现参数校验、异常处理和兼容分支。这些都是常见的代码模式。

生成代码时,如果没有足够的项目约束,AI 可能采用这种通用写法。但通用写法并不自动适配项目架构。

例如,项目已经通过统一异常处理器转换错误响应,Service 层却又加了一层:

try {
    return userRepository.findById(userId);
} catch (Exception e) {
    log.error("查询用户失败", e);
    return null;
}

此时,统一异常处理反而收不到真正的故障。

代码局部看起来“保护得很好”,整个系统却失去了正确表达失败的能力。

4. 容易优先避免显眼的错误,忽略隐蔽的语义错误

空指针异常很显眼:测试会失败,日志会报错。

错误地返回零、空集合或成功状态,却可能暂时不触发异常。

public BigDecimal queryBalance(Long accountId) {
    try {
        return accountClient.queryBalance(accountId);
    } catch (Exception e) {
        return BigDecimal.ZERO;
    }
}

程序确实继续运行了,但“余额查询失败”被变成了“余额为零”。

这会误导页面展示,甚至影响后续业务判断。

因此,评价防御代码时,不能只看它是否避免异常,还要看它是否保留了事实。

三、过度防御最常见的四种表现

表现看似解决的问题可能引入的问题
到处判断 null避免空指针重复校验,隐藏上游契约被破坏
捕获所有异常防止程序报错吞掉故障,让调用方误判成功
返回空值或默认值保持流程继续混淆“失败”“不存在”和“正常为空”
对所有失败自动重试提高成功率重复写入、重复扣款或放大故障

其中,重试尤其需要谨慎。

一次请求超时,不代表服务端没有执行成功。如果业务操作不具备幂等性,再试一次可能造成重复操作。

合理的重试至少需要明确:哪些错误可重试、操作是否幂等、最多尝试几次,以及是否受到总体超时约束。

四、合理的防御应该放在哪里?

一个实用原则是:在边界验证不可信信息,在内部遵守明确契约。

这里的边界不只是 HTTP 接口,也包括消息队列、文件导入、第三方服务响应,以及其他可能绕过原有校验的调用入口。

外部输入:校验明确、错误可解释

用户输入和第三方返回值不能直接视为可信,应验证格式、范围和业务约束。

例如,下单数量必须大于零,就应清楚表达这个规则,而不是自动把负数改成一。

内部调用:避免无意义地重复兜底

如果参数契约已经明确,而且所有调用路径都能够保证它,就没有必要在每一层重复相同判断。

但“某个 Controller 校验过”不代表所有入口都校验过。Service 如果也会被定时任务或消息消费者调用,就要重新审视校验位置。

关键不变量:被破坏时及时失败

有些状态不应该被掩盖,例如订单必须关联用户、金额必须满足约束。

遇到这类问题,明确失败往往比返回一个“差不多”的结果更安全。

可选能力:允许经过设计的降级

推荐服务失败时,展示经过约定的热门列表,可能是合理降级。

余额查询失败时,显示余额为零,则会改变业务事实。

区别在于:降级之后的结果,是否仍然符合产品与业务约定。

五、如何让 AI 少写无效防御代码?

最有效的方法,是把方法契约和系统边界告诉它。

不要只说:

写得健壮一些。

可以改成:

请按以下约束实现:

1. 参数已在所有调用入口完成校验,内部不要重复做空值兜底。
2. 用户不存在时抛出项目已有的业务异常。
3. 数据访问异常由统一异常处理机制接管。
4. 不捕获无法处理的异常,不返回空对象掩盖失败。
5. 不新增重试或降级,除非业务要求明确允许。
6. 如发现上述约束与实际代码不一致,先指出问题。

对于 AI 已经写好的代码,可以要求它进行一次专项审查:

请检查本次修改中新增的防御性代码。

逐项说明:
- 防御的是哪一种真实风险?
- 现有契约或框架是否已经处理?
- 默认值是否改变业务语义?
- 删除这段判断会影响什么具体场景?

删除没有依据的重复判断,保留必要的边界校验。

这比简单要求“代码简洁一点”更准确,也不容易误删必要保护。

六、代码审查时,问三个问题

面对一段新增的防御逻辑,可以依次问:

  1. 这种异常情况真的可能发生吗?
    要看所有调用路径和实际约束,不能只凭感觉。
  2. 处理后有没有改变事实?
    查询失败不能变成查询为空,非法金额不能自动变成零。
  3. 这一层有能力处理它吗?
    如果只能记录日志然后假装成功,通常不算真正处理。

能够回答这些问题的防御代码,才有明确价值。

结语

AI 容易写出防御性代码,往往与上下文缺失、需求模糊和通用代码模式有关。多几个判断、几层异常捕获,看起来更周全,却可能让错误更难发现。

好的防御性编程,应该保护业务契约、保留失败语义,并让问题发生在可理解、可定位的位置。

代码的目标是正确运行,也包括在无法正确运行时,明确地失败。