24、断言:assert宏,调试阶段的守护神

调试代码,说白了就是跟 bug 斗智斗勇。

你写了一个函数,传进去一个指针,结果程序跑着跑着就崩了。你翻遍代码,最后发现——哦,原来那个指针是 NULL。这种问题,我年轻时几乎天天遇到。后来我养成了一个习惯:在关键位置插入断言。嗯,就是今天要聊的 assert 宏。

什么是 assert?

assert 是 C 标准库 <assert.h> 提供的一个宏。它的作用很简单:检查一个表达式是否为真。如果为假,程序立即终止,并打印出出错的文件名、行号和表达式内容。

说白了,它就是你在代码里埋下的一个「哨兵」。一旦条件不满足,哨兵立刻报警。

核心用法:

#include <assert.h>

void set_value(int *p, int val) {
    assert(p != NULL);  // 如果 p 是 NULL,程序直接崩,并告诉你哪里崩了
    *p = val;
}

你看,就这么一行。运行时如果 p 是 NULL,程序会输出类似这样的信息:

Assertion failed: p != NULL, file test.c, line 5

然后终止。你不需要再费劲去猜是哪个文件、哪一行出的问题。它直接告诉你。

assert 的开关机制

你可能会想:调试时用 assert 挺好,但发布版本总不能带着这些检查吧?会影响性能的。

没错。C 语言的设计者早就想到了。你只需要在包含 <assert.h> 之前定义 NDEBUG 宏,所有 assert 就会自动消失。

#define NDEBUG
#include <assert.h>

// 从此以后,所有 assert 都不生效了
assert(1 == 0);  // 这行在 release 版本中什么都不做

我个人习惯在 Makefile 或编译选项中统一控制:

// debug 版本
gcc -DDEBUG -o test test.c

// release 版本
gcc -DNDEBUG -O2 -o test test.c

这样你只需要改一个编译选项,就能切换所有 assert 的开关。方便吧?

assert 的典型应用场景

我在项目中遇到过不少因为参数检查不到位导致的 bug。后来我总结了几种必须用 assert 的场景:

  • 函数前置条件检查:比如指针不能为 NULL,数组下标不能越界。
  • 函数后置条件检查:比如排序后数组应该有序,计算结果应该在合理范围内。
  • 不可能发生的情况:比如 switch 的 default 分支,理论上不会执行到,但万一呢?
  • 关键状态机状态检查:比如状态转换是否合法。

我的经验:assert 最适合检查「内部逻辑错误」,而不是「外部输入错误」。外部输入错误(比如用户输入非法)应该用 if 判断并返回错误码,而不是直接断言崩溃。

一个完整的例子

假设你写了一个二分查找函数。你希望确保传入的数组是有序的。你可以这样写:

#include <assert.h>
#include <stdbool.h>

bool is_sorted(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        if (arr[i-1] > arr[i]) return false;
    }
    return true;
}

int binary_search(int arr[], int n, int target) {
    // 前置条件:数组不能为 NULL,长度必须大于 0,数组必须有序
    assert(arr != NULL);
    assert(n > 0);
    assert(is_sorted(arr, n));

    int left = 0, right = n - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) return mid;
        else if (arr[mid] < target) left = mid + 1;
        else right = mid - 1;
    }

    // 后置条件:如果没找到,返回 -1
    return -1;
}

你看,这里用了三个 assert 来保证函数被正确调用。如果调用者传了一个无序数组,程序会立刻告诉你:数组不是有序的。而不是让你在二分查找里莫名其妙地找不到结果。

assert 的注意事项

嗯,这里要注意几个坑。我曾经踩过,所以特别提醒你:

警告:

  • 不要在 assert 里写有副作用的表达式。比如 assert(++i < 10),因为 release 版本里这行被删掉了,i 就不会自增。程序逻辑就变了。
  • 不要用 assert 处理用户输入。用户输入错误是预期行为,应该优雅处理,而不是直接崩溃。
  • assert 不是错误处理机制。它只是调试工具。真正的错误处理(比如内存分配失败)应该用 if 判断并返回错误码。

我曾经在一个项目里看到有人这样写:

FILE *fp = fopen("data.txt", "r");
assert(fp != NULL);  // 如果文件不存在,程序直接崩

这其实是不对的。文件不存在是外部环境问题,不是程序逻辑错误。应该用 if 判断并提示用户,而不是直接断言。

assert 与静态断言的对比

C11 引入了 static_assert,它是在编译期检查的。而 assert 是在运行时检查的。两者各有用途:

特性 assert static_assert
检查时机 运行时 编译时
检查内容 变量值、指针、状态等 类型大小、宏定义、常量表达式等
能否被 NDEBUG 关闭 不能
典型用途 参数检查、状态验证 结构体大小、对齐方式检查

举个例子,如果你要确保某个结构体的大小是 4 的倍数,可以用 static_assert

struct Packet {
    uint16_t id;
    uint16_t len;
    uint8_t  data[64];
};

static_assert(sizeof(struct Packet) % 4 == 0, "Packet size must be 4-byte aligned");

这个检查在编译时就完成了,不会影响运行时性能。

知识体系图

下面这张图总结了 assert 的核心知识点和它在调试中的位置:

assert 宏 调试阶段(DEBUG) 检查前置/后置条件 验证不可能发生的情况 快速定位崩溃位置 发布阶段(RELEASE) 定义 NDEBUG 关闭 assert assert 代码被预处理器移除 零运行时开销 ⚠ 不要在 assert 中写有副作用的表达式

避坑指南

最后,分享几个我亲身踩过的坑:

  • 我曾经在 assert 里调用了 malloc。结果 release 版本里内存没分配,程序跑得莫名其妙。从那以后,我坚决不在 assert 里写任何有副作用的代码。
  • 我曾经用 assert 检查文件打开是否成功。结果用户反馈说程序一运行就闪退。后来改成 if 判断并提示错误信息,问题就解决了。
  • 我曾经忘记在 release 版本里定义 NDEBUG。结果发布版本里 assert 还在,用户看到「Assertion failed」直接懵了。嗯,现在我把 NDEBUG 写死在编译脚本里了。

assert 是个好东西,但要用对地方。它就像你代码里的哨兵,只在调试阶段站岗。发布时,哨兵撤走,不影响性能。但如果你让哨兵去干不该干的事——比如处理用户输入——那就会出问题。

记住:assert 检查的是「程序不应该出错」的地方,而不是「程序可能出错」的地方。前者是逻辑错误,后者是外部异常。分清这两点,你就能用好 assert。


公众号:蓝海资料掘金营,微信 deep3321