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

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

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

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

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

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

一、什么是防御性编程?

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

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

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

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

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

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

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

    // ...
}

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

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

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

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

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

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

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

例如:

kotlin 复制代码
public UserDTO getUser(Long userId) {
    return convert(userRepository.findById(userId));
}

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

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

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

kotlin 复制代码
if (userId == null) {
    return null;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

kotlin 复制代码
public BigDecimal queryBalance(Long accountId) {
    try {
        return accountClient.queryBalance(accountId);
    } catch (Exception e) {
        return BigDecimal.ZERO;
    }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

不要只说:

写得健壮一些。

可以改成:

markdown 复制代码
请按以下约束实现:

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

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

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

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

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

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

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

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

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

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

结语

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

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

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

相关推荐
灯澜忆梦43 分钟前
【Redis中间件】#4 | 进阶数据类型语法
redis·后端
广州山泉婚姻1 小时前
前后端分离的核心优势
前端·后端
IT_陈寒1 小时前
Vue的响应式让我加班到凌晨,问题竟出在这个不起眼的地方
前端·人工智能·后端
乱码三千1 小时前
使用 Docker-compose 搭建SRS直播中转服务端
后端·架构·github
y1su1 小时前
【Leetcode】1477. 找两个和为目标值且不重叠的子数组
数据结构·后端·算法·leetcode·职场和发展
愛芳芳2 小时前
Swagger2 多环境动态配置实战指南
java·后端·maven·springboot·swagger
JaguarJack2 小时前
PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
后端·php·服务端
BingoGo2 小时前
PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
后端·php
Bs_MoneyMagnet6 小时前
基于springboot+vue的滑雪场票务与装备租赁系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计