防御性编程基础:断言与参数校验
防御性编程,说白了就是「别信任何人」。
你写的函数,会被谁调用?可能是三个月后的你自己,也可能是隔壁组刚入职的新人。传进来的参数是空指针吗?数组下标会越界吗?这些事,你不能指望调用方替你操心。
我早年吃过一次大亏。一个核心模块的接口没做参数校验,上线后某个极端路径传了个负数进来,直接导致内存越界写,整台机器挂了。从那以后,我养成了一个习惯:所有公开接口,先校验,再干活。
断言(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(感兴趣区域)是否越界。用户传了个超出图像边界的矩形,直接导致内存越界读。从那以后,所有涉及「范围」的参数,我都做上下界双重检查。
知识结构图
下面这张图帮你理清断言、参数校验和边界检查的关系:
实战建议
说了这么多,总结几条我个人的实战经验:
- 公开接口必须做参数校验。内部函数可以用断言,但对外暴露的 API,一个参数都不能放过。
- 断言和校验不冲突。我习惯在同一个函数里同时用:断言检查内部逻辑,if 检查外部输入。
- 边界检查要写注释。为什么是
n-1而不是n?写清楚,三个月后你自己还能看懂。 - 别在性能热点里做过度校验。比如图像处理的内层循环,校验放在外面,循环里面只做一次边界检查。
最后说一句:防御性编程不是让你写一堆 if-else 把代码搞成意大利面。它的核心是「在正确的地方,用正确的方式,做正确的检查」。多一分校验,少一次线上事故。这笔账,怎么算都划算。