什么是防御性编程

核心思想:不信任任何输入和外部依赖,主动检查边界条件,让程序在意外情况下也能优雅地失败。

典型做法:

  • 校验函数参数;
  • 检查返回值(尤其是可能失败的操作);
  • 处理空值、边界、异常;
  • 断言(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 定律(健壮性原则):

对自己发送的内容要保守,对接收的内容要宽容。

这在网络协议设计中很有价值(兼容各种实现),但在内部代码中过度应用会导致错误被掩盖。

实用建议

  • 在边界严格,在内部简洁;
  • 优先使用类型系统来消除整类错误(这是最"便宜"的防御);
  • 错误信息要具体;
  • 不要捕获异常后什么都不做(至少记日志);
  • 用断言处理"不可能"的情况。

说明:本文为编程实践的知识整理。