为什么 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
请检查本次修改中新增的防御性代码。
逐项说明:
- 防御的是哪一种真实风险?
- 现有契约或框架是否已经处理?
- 默认值是否改变业务语义?
- 删除这段判断会影响什么具体场景?
删除没有依据的重复判断,保留必要的边界校验。
这比简单要求"代码简洁一点"更准确,也不容易误删必要保护。
六、代码审查时,问三个问题
面对一段新增的防御逻辑,可以依次问:
- 这种异常情况真的可能发生吗?
要看所有调用路径和实际约束,不能只凭感觉。 - 处理后有没有改变事实?
查询失败不能变成查询为空,非法金额不能自动变成零。 - 这一层有能力处理它吗?
如果只能记录日志然后假装成功,通常不算真正处理。
能够回答这些问题的防御代码,才有明确价值。
结语
AI 容易写出防御性代码,往往与上下文缺失、需求模糊和通用代码模式有关。多几个判断、几层异常捕获,看起来更周全,却可能让错误更难发现。
好的防御性编程,应该保护业务契约、保留失败语义,并让问题发生在可理解、可定位的位置。
代码的目标是正确运行,也包括在无法正确运行时,明确地失败。