14、契约式设计:前置条件、后置条件、不变式、契约继承、契约与测试

契约式设计,说白了就是给函数签个合同。

我早年做嵌入式开发时,最怕的就是那种「传个指针进来,鬼知道是不是NULL」的代码。后来接触了契约式设计,才明白——与其在函数内部猜来猜去,不如在入口就把规矩讲清楚

14.1 什么是契约式设计?

契约式设计(Design by Contract,DbC)由 Bertrand Meyer 提出。核心思想很简单:每个函数都应该有一个明确的契约

这个契约包含三部分:

  • 前置条件:调用方必须满足的条件
  • 后置条件:函数执行后保证的结果
  • 不变式:在整个执行过程中始终成立的条件

我个人习惯把契约想象成「接口的说明书」。你想想看,如果每个函数都白纸黑字写清楚「你给我什么,我保证给你什么」,调试起来能省多少时间?

核心原则:契约是双方的承诺。调用方负责满足前置条件,函数负责保证后置条件。谁违约谁负责。

14.2 前置条件:调用方的责任

前置条件就是函数能正常工作的前提。比如:

  • 指针不能为NULL
  • 数组索引必须在有效范围内
  • 除数不能为0
  • 缓冲区长度必须足够
// 前置条件:buf != NULL, len > 0, len <= sizeof(buf)
void process_buffer(uint8_t *buf, size_t len) {
    // 函数内部不再检查这些条件
    // 因为调用方已经承诺满足前置条件
    for (size_t i = 0; i < len; i++) {
        buf[i] = buf[i] ^ 0x55;
    }
}

我在项目中遇到过一种情况:某个模块的初始化函数要求调用前必须先调用另一个配置函数。结果新同事不知道这个隐含条件,直接调了初始化,系统直接挂掉。后来我们在函数开头加了个断言,问题立刻暴露。

注意:前置条件不是用来做输入验证的。如果你需要防御非法输入,应该用错误处理机制,而不是契约。契约是信任机制,不是安全机制。

14.3 后置条件:函数的承诺

后置条件描述函数执行完成后,系统状态应该满足的条件。比如:

  • 返回值必须在某个范围内
  • 全局状态被正确更新
  • 资源被正确释放
  • 缓冲区内容被正确填充
// 后置条件:返回值 >= 0 且 <= 100
// 后置条件:如果返回值 > 0,则 data 指向的缓冲区被填充
int read_sensor_data(uint8_t *data, size_t max_len) {
    int result = 0;
    // ... 读取传感器数据 ...
    // 确保后置条件成立
    assert(result >= 0 && result <= 100);
    if (result > 0) {
        assert(data != NULL);
    }
    return result;
}

后置条件有个好处:它让你敢在函数末尾加断言。我曾经在一个通信协议栈里,每个发送函数末尾都检查「发送队列长度应该比发送前少1」。有一次断言触发了,发现是中断处理程序里有个竞态条件。嗯,要不是这个断言,那个bug可能要在现场跑几个月才能复现。

14.4 不变式:始终成立的规则

不变式是那些在整个对象生命周期内,或者在整个函数执行过程中,始终必须成立的条件。

在C语言里,不变式通常用于:

  • 结构体字段之间的约束关系
  • 循环中的不变条件
  • 模块内部状态的合法性
typedef struct {
    uint8_t *buffer;
    size_t   capacity;
    size_t   length;  // 不变式:length <= capacity
} RingBuffer;

// 检查不变式
bool ring_buffer_invariant(const RingBuffer *rb) {
    return rb != NULL && rb->length <= rb->capacity;
}

void ring_buffer_push(RingBuffer *rb, uint8_t data) {
    assert(ring_buffer_invariant(rb));  // 进入时检查
    // ... 执行操作 ...
    assert(ring_buffer_invariant(rb));  // 退出时检查
}

你想想看,如果每个函数进出都检查不变式,很多隐蔽的bug根本藏不住。我习惯在模块的每个公共函数开头和结尾都调用不变式检查函数。虽然有点啰嗦,但调试时真能救命。

14.5 契约继承:子类如何遵守父类的契约

在面向对象语言里,契约继承是个重要概念。C语言虽然没有类继承,但我们可以用函数指针和结构体模拟类似的行为。

核心原则是:子类可以放宽前置条件,但不能加强;子类可以加强后置条件,但不能放宽

// 基类契约
typedef struct {
    int (*open)(const char *path);  // 前置:path != NULL
    int (*read)(void *buf, size_t len); // 后置:返回值 <= len
} DeviceOps;

// 子类实现:可以接受 path == NULL(放宽前置条件)
// 但不能要求 path 必须非空(加强前置条件)
static int my_device_open(const char *path) {
    if (path == NULL) {
        path = "/dev/default";
    }
    // ...
    return 0;
}

我记得有一次重构代码,把某个接口的契约改了——加强了前置条件。结果所有调用方都炸了。那次教训让我明白:契约一旦发布,就像法律条文一样,不能随便改

技巧:如果你必须修改契约,考虑新增一个函数,而不是修改现有函数的契约。旧函数保留,标记为 deprecated。

14.6 契约与测试:相辅相成

契约式设计和测试不是替代关系,而是互补关系。契约帮你在开发阶段尽早发现问题,测试帮你在发布前验证正确性。

方面 契约式设计 单元测试
时机 开发时(断言触发) 测试时(断言通过)
覆盖范围 所有执行路径 选定的测试用例
目的 尽早发现bug 验证功能正确
性能影响 有(可关闭) 无(测试环境)

我个人的做法是:先用契约把函数的边界条件卡死,再用测试覆盖正常流程和边界情况。契约就像护栏,测试就像探照灯,两者配合才能把bug赶尽杀绝。

// 契约 + 测试的典型模式
int divide(int a, int b) {
    // 前置条件:b != 0
    assert(b != 0);
    
    int result = a / b;
    
    // 后置条件:result * b == a(整数除法)
    assert(result * b == a);
    
    return result;
}

// 测试用例
void test_divide(void) {
    assert(divide(10, 2) == 5);
    assert(divide(0, 5) == 0);
    assert(divide(-10, 2) == -5);
    // 不需要测试 b == 0,因为契约已经禁止了
}

关键认知:契约不是测试的替代品。契约帮你把「不该发生的事」挡在门外,测试帮你验证「该发生的事」是否正确。两者缺一不可。

14.7 嵌入式环境下的实践建议

在嵌入式系统里,契约式设计尤其重要。因为资源受限,很多错误无法通过异常处理来优雅恢复。

  • 使用断言宏:在调试版本启用,发布版本关闭
  • 不要在生产代码里用契约做输入验证:契约是开发期工具,不是运行时防护
  • 契约要写在文档里:光在代码里加断言不够,接口文档也要写清楚
  • 团队统一风格:前置条件、后置条件、不变式的写法要一致

避坑指南:我曾经在一个项目里,把契约断言写成了条件判断——「如果前置条件不满足,就返回错误码」。结果调试版本和发布版本行为不一致,现场问题根本复现不了。记住:契约断言失败就是程序bug,不应该用错误处理来掩盖

14.8 本章小结

契约式设计不是什么高深理论,它就是一套让代码更可靠的实践方法。前置条件、后置条件、不变式,这三个概念你记住了,就能写出更健壮的代码。

我个人觉得,契约式设计最大的价值不是帮你找到bug,而是逼着你把函数的边界条件想清楚。很多时候,bug的根源不是代码写错了,而是设计时就没想清楚「这个函数到底该接受什么参数」。契约式设计,就是逼你想清楚这件事。

契约式设计核心知识体系 契约式设计 前置条件 后置条件 不变式 调用方必须满足 指针非NULL 索引在范围内 函数保证的结果 返回值在范围内 状态正确更新 始终成立的条件 length <= capacity 结构体字段约束 契约继承 契约与测试 契约式设计:明确边界,尽早发现bug

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