什么是防御性编程
核心思想:不信任任何输入和外部依赖,主动检查边界条件,让程序在意外情况下也能优雅地失败。
典型做法:
- 校验函数参数;
- 检查返回值(尤其是可能失败的操作);
- 处理空值、边界、异常;
- 断言(Assert)——在开发阶段快速暴露问题;
- 失败时给出清晰的错误信息。
为什么需要
墨菲定律:任何可能出错的事,终将出错。
在软件中:
- 用户输入可能超乎想象;
- 外部 API 可能返回意外的数据;
- 网络会超时、磁盘会满、内存会不足;
- 你依赖的代码可能被别人修改。
防御性编程的目标:让这些意外不会导致灾难(数据损坏、安全漏洞、无法诊断的崩溃)。
有效的防御
一、在系统边界做严格校验
边界指的是:用户输入、外部 API、文件读取、数据库查询、配置文件。
在这些地方,数据是不可信的——必须校验。
二、内部代码适度信任
一旦数据通过了边界校验,进入系统内部,就不必层层重复检查——否则代码会被校验逻辑淹没。
这是"边界防御"的核心思路:在入口处把好关,内部保持简洁。
三、快速失败(Fail Fast)
发现问题时立即报错,而不是默默地使用错误的值继续运行。
原因:错误越早暴露,定位越容易。一个被"容错"掩盖的错误,可能在几小时后以完全不同的形式爆发——那时就很难追溯了。
四、具体的错误信息
"参数错误"这样的信息毫无帮助。
更好的:"user_id 必须为正整数,收到:-1"。
这在排查问题时能节省大量时间。
过度防御的问题
一、代码噪音
def add(a, b):
if a is None or b is None:
raise ValueError("...")
if not isinstance(a, (int, float)):
raise TypeError("...")
return a + b对于一个简单的内部函数,这些检查让代码变长,且掩盖了真正的逻辑。
二、掩盖了真正的问题
过度使用"容错"(如捕获所有异常然后返回默认值)会让 bug 隐身——程序不崩溃,但行为错误,而且没人知道。
这比崩溃更危险,因为崩溃至少有日志。
三、不可能状态的处理
有些情况在你的程序中理论上不可能发生。为它们写大量处理代码,是浪费。
更好的做法:用断言——它表达了"这里不该发生",在开发/测试阶段快速失败,且不会污染生产代码(很多语言在生产模式会禁用或优化掉断言)。
一个平衡
关键问题:这段代码信任谁?
- 不信任的输入(外部边界)→ 严格校验,给出清晰错误;
- 内部调用→ 依赖类型系统和测试,适度检查;
- 不可能的情况→ 用断言,而非完整的错误处理。
相关原则
契约式设计(Design by Contract):
- 前置条件(Precondition):调用方必须保证的;
- 后置条件(Postcondition):被调用方保证的;
- 不变量(Invariant):始终成立的条件。
责任清晰:谁该检查什么,由契约界定——而不是每个人都检查一遍。
Postel 定律(健壮性原则):
对自己发送的内容要保守,对接收的内容要宽容。
这在网络协议设计中很有价值(兼容各种实现),但在内部代码中过度应用会导致错误被掩盖。
实用建议
- 在边界严格,在内部简洁;
- 优先使用类型系统来消除整类错误(这是最"便宜"的防御);
- 错误信息要具体;
- 不要捕获异常后什么都不做(至少记日志);
- 用断言处理"不可能"的情况。
说明:本文为编程实践的知识整理。