防御性编程基础:断言与参数校验

防御性编程,说白了就是「别信任何人」。

你写的函数,会被谁调用?可能是三个月后的你自己,也可能是隔壁组刚入职的新人。传进来的参数是空指针吗?数组下标会越界吗?这些事,你不能指望调用方替你操心。

我早年吃过一次大亏。一个核心模块的接口没做参数校验,上线后某个极端路径传了个负数进来,直接导致内存越界写,整台机器挂了。从那以后,我养成了一个习惯:所有公开接口,先校验,再干活。

断言(assert)的使用场景

断言是开发阶段的「安全网」。它帮你捕获那些「理论上不可能发生」的情况。

核心原则:断言只用于检查内部逻辑错误,不用于处理外部输入异常。

我个人的习惯是,断言用在三个地方:

  • 函数前置条件:比如二分查找要求输入数组必须有序
  • 函数后置条件:比如排序后检查结果确实有序
  • 类不变量:比如链表节点数必须等于实际元素个数
// 好的例子:检查内部逻辑
void sort(int* arr, int n) {
    assert(arr != nullptr);
    assert(n > 0);
    // 排序逻辑...
    // 排序后验证
    for (int i = 1; i < n; i++) {
        assert(arr[i-1] <= arr[i]);
    }
}

// 坏例子:用断言检查用户输入
void process_age(int age) {
    assert(age >= 0 && age <= 150);  // 别这么干!
    // 应该用 if 判断 + 错误返回
}

注意:断言在 Release 版本中默认是关闭的。你不能指望断言来做运行时保护。

我曾经在一个项目中看到有人用断言检查 malloc 的返回值。嗯,这很危险。Release 版本下断言被禁用,内存分配失败就直接静默崩溃了。

参数校验的最佳实践

参数校验和断言是两回事。断言是开发期的事,参数校验是运行期的事。两者都要做,但分工不同。

维度 断言 参数校验
生效阶段 Debug 模式 所有模式
检查对象 内部逻辑错误 外部输入异常
失败行为 触发 abort 返回错误码/抛异常
性能影响 无(Release 关闭) 始终存在

参数校验要遵循「早检查、早返回」的原则。我一般会在函数入口处集中做校验:

ErrorCode process_order(const Order* order) {
    // 集中校验区
    if (order == nullptr) return ERR_NULL_POINTER;
    if (order->id <= 0) return ERR_INVALID_ID;
    if (order->amount <= 0) return ERR_INVALID_AMOUNT;
    if (order->items.empty()) return ERR_EMPTY_ORDER;
    
    // 业务逻辑
    // ...
    return OK;
}

小技巧:校验失败时,除了返回错误码,最好打一条日志。记录谁调了你、传了什么参数、为什么失败。排查问题时,这些日志就是救命稻草。

边界检查:最容易翻车的地方

你想想看,程序里最容易出 bug 的地方是哪儿?边界。数组的第一个和最后一个元素、循环的起始和终止条件、字符串的结尾空字符……这些地方,稍微偏一点就崩了。

边界检查的核心就一句话:确认「能访问」和「不能访问」的分界线在哪里。

// 常见的边界错误
void copy_data(char* dst, const char* src, size_t dst_size) {
    // 错误:没检查目标缓冲区大小
    // strcpy(dst, src);  
    
    // 正确:明确边界
    if (dst == nullptr || src == nullptr) return;
    if (dst_size == 0) return;
    
    size_t i = 0;
    while (src[i] != '\0' && i < dst_size - 1) {
        dst[i] = src[i];
        i++;
    }
    dst[i] = '\0';
}

我见过最经典的边界 bug 是这样的:一个循环写成了 i <= n,结果多访问了一次。就这一次,把堆上的元数据给踩了,崩溃现场极其难看。

避坑指南:我曾经在一个图像处理模块里,没检查 ROI(感兴趣区域)是否越界。用户传了个超出图像边界的矩形,直接导致内存越界读。从那以后,所有涉及「范围」的参数,我都做上下界双重检查。

知识结构图

下面这张图帮你理清断言、参数校验和边界检查的关系:

防御性编程核心体系 防御性编程 断言(assert) 参数校验 边界检查 前置条件 后置条件 类不变量 非空检查 范围检查 类型检查 数组边界 循环边界 字符串边界 关键原则 断言:开发期捕获内部逻辑错误,Release 关闭 参数校验:运行期防御外部非法输入,始终生效 边界检查:重点关注数组、循环、字符串的临界值

实战建议

说了这么多,总结几条我个人的实战经验:

  1. 公开接口必须做参数校验。内部函数可以用断言,但对外暴露的 API,一个参数都不能放过。
  2. 断言和校验不冲突。我习惯在同一个函数里同时用:断言检查内部逻辑,if 检查外部输入。
  3. 边界检查要写注释。为什么是 n-1 而不是 n?写清楚,三个月后你自己还能看懂。
  4. 别在性能热点里做过度校验。比如图像处理的内层循环,校验放在外面,循环里面只做一次边界检查。

最后说一句:防御性编程不是让你写一堆 if-else 把代码搞成意大利面。它的核心是「在正确的地方,用正确的方式,做正确的检查」。多一分校验,少一次线上事故。这笔账,怎么算都划算。