1. 从一次“不可能复现”的崩溃说起我调过最憋屈的一个bug线上服务偶发性崩溃日志里连崩溃点都对不上号。排查了一整天内存踩踏、并发竞态、编译器优化嫌疑都轮了一遍最后被老同事一句话点醒——“这个指针在调用时还活着吗”回头翻接口文档白纸黑字写着“调用方负责保证指针在调用期间有效”而调用方早在上一轮循环里就把那块缓冲区释放了。Debug构建下它侥幸幸存Release构建由于优化与内存布局不同恐怖故事终于浮出水面。这类问题在C项目里实在太常见。我们习惯把锅甩给“内存问题”“并发问题”但刨根问底很多事故的本质是同一件事调用方与实现方之间对接口语义的共识被单方面打破。这个共识在设计方法学里有一个专门的名字——契约Contract。以契约为核心的开发方法叫Design by Contract中文常译作契约式设计也有人直接叫契约编程。契约编程不是一个新语法也不是某个框架的专利。它最早由Eiffel语言的创始人Bertrand Meyer系统化提出核心主张是接口不只是一组函数签名还包括三个可以被验证的条款——前置条件、后置条件和类不变量。调用方承诺满足前置条件实现方承诺兑现后置条件类自身承诺在生命周期内维持不变量。任何一方违约错误必须在第一时间变得可见而不是潜伏到万米深的海底再爆炸。在C生态里契约编程尤其值得认真对待原因很硬核C默认不做边界检查vector::operator[]越界是未定义行为而不是自动抛异常裸指针、引用、迭代器、生命周期这类“隐式约定”比任何主流语言都多模板、虚函数、继承让接口的语义约束远比“参数类型正确”复杂得多。这篇文章会从原理讲到落地覆盖前置条件、后置条件、类不变量三个核心概念梳理现阶段C实现契约的几种流派与工具再用一个连接池案例完整演示如何把契约“焊”进接口。最后是我在实际项目里踩过的坑和几条真正能帮你活下来的建议。不管你是写底层库、业务服务还是维护一个几百万行的老项目这套方法论都能帮你砍掉一大部分“玄学bug”。2. 契约三件套前置条件、后置条件与类不变量2.1 前置条件接口的安检门前置条件Precondition是调用方在调用接口之前必须满足的条件。它划定了实现方的责任边界只要前置条件成立实现方就必须保证函数正常完成如果前置条件被违反实现方可以不做任何保证——标准库称之为“未定义行为”。标准库就是前置条件的重度使用者。std::vectorint::operator[]要求索引小于size()std::sort要求迭代器有效且落在同一容器范围内std::mutex::lock要求当前线程没有持有该锁。这些要求写在标准文档里但代码层面往往没有任何检查。一旦违反典型表现不是“温和报错”而是静默的内存破坏或诡异的逻辑错乱。自己写接口时我建议把前置条件变成可执行的守卫而不是留在注释里当摆设。最简单的做法是这样double safeRoot(double x) { // GSL 的 Expects 宏语义上明确表达这是接口的前置条件 Expects(x 0.0); // 或者用自定义宏见第 3 节 CONTRACT_PRE(x 0.0); return std::sqrt(x); }这里有一个很多新手会混淆的点前置条件 ≠ 输入校验。两者面对的错误来源完全不同。输入校验处理的是“外部不可信输入”——比如用户传了一个负数给“转账金额”这属于预期行为应当返回错误码或抛异常让上层有机会恢复前置条件处理的是“调用方违反了接口协议”——比如调用方把已经释放的指针传进来或者违反“余额必须充足”的约束。这类违约意味着程序已经处于逻辑错误状态最理智的应对是尽快失败fail fast而不是收拾残局继续跑。打个比方。输入校验像商场保安查证件查出来顶多不让进门前置条件像工地上的安全绳——你不系绳就上高空安全绳的作用不是挽回你而是让你在最显眼的地方暴露风险、立刻止损。2.2 后置条件实现方写给调用方的承诺书后置条件Postcondition描述函数成功返回后系统状态应当满足的性质。它是对调用方的承诺也是调用方可以放心依赖的行为保证。举一个日常到几乎被忽略的例子std::vector::push_back。调用之前容器满足size() capacity()调用之后size()恰好增加1最后一个元素是新值且size() capacity()依然成立。这个“操作前后size() capacity()恒成立”的约束既是vector自身的类不变量也是push_back这条后置条件的一部分。后置条件里有一个很容易被忽视的细节验证它往往需要“调用前的状态”。比如检查“容量增加1”你得先记住调用前的size()。Boost.Contract里把这个机制叫old值函数实现里可以这样表达auto old_size container.size(); // 保存调用前状态 container.push_back(item); Ensures(container.size() old_size 1);后置条件与实现策略的耦合是非常容易踩坑的地方。我给过自己一个惨痛教训早期实现一个缓存模块时接口承诺“返回的迭代器在下次修改前保持有效”。后来为了优化我把底层数据结构从std::vector换成了std::unordered_map迭代器失效规则完全变了结果一周后线上出现奇怪的悬垂迭代器问题。教训很沉重改变内部实现时必须逐条审视它是否还能兑现对外公开的后置条件。后置条件本质上是接口与实现之间的一份长期契约实现可以换契约不能悄悄违约。2.3 类不变量对象的宪法类不变量Class Invariant是对象从构造完成到析构开始前的整个生命周期内始终为真的性质。构造函数负责建立不变量析构函数负责安全退出所有公有成员函数在执行前和返回后都必须维持不变量。最经典的例子仍然是std::vectorsize() capacity()、迭代器语义正确、内部缓冲区要么为空指针要么指向有效堆内存。std::shared_ptr的控制块引用计数正确性也是一种不变量。类不变量和封装是深度绑定的。为什么我们强调字段要private不是因为“封装是良好习惯”而是因为private是实现不变量的物理屏障。如果hours_和minutes_是公有字段任何外部代码都能直接把它们改成非法值——比如minutes_ 90类不变量被瞬间砸碎后续所有方法的行为都不可信。让我用一个简单类来说明class TimeOfDay { public: TimeOfDay(int h, int m) noexcept : hours_(h), minutes_(m) { // 构造函数的后置条件对象不变量成立 Expects(h 0 h 24); Expects(m 0 m 60); } int hour() const noexcept { return hours_; } int minute() const noexcept { return minutes_; } // 违反不变量先例如果允许直接修改分钟数 // void setMinute(int m) { minutes_ m; } // 应加入守卫检查 private: int hours_; int minutes_; };类不变量还有一个和C对象模型相关的隐蔽陷阱构造函数内调用虚函数。基类构造期间虚函数绑定到基类版本派生类成员尚未初始化此时基类的不变量可能尚未建立。如果你在构造函数里调用一个虚函数而这个虚函数的后置条件依赖派生类状态结果就是契约在尚未生效时就被违约了。正确的做法是构造期间不调用虚函数或者把初始化逻辑拆到派生类构造之后显式调用。3. C中落地契约的四种流派与选型思路3.1 断言派把合约写成可控的守卫最直接的方式就是assert。它简单、零依赖、几乎所有C程序员都认识。但裸assert有三个硬伤任何一个都足以让它在严肃项目里失职。第一assert在定义了NDEBUG时会被整体剥离。也就是说发布构建Release下所有契约检查全部消失典型的后果是“Debug跑得好好的Release一上线就崩”。第二assert无法区分“这是接口的前置条件”和“这只是实现细节的临时检查”。代码可读性变差别人也不知道哪些断言可以安全移除。第三裸assert的条件表达式在Debug下被求值在Release下不被求值。如果条件里不小心带了副作用比如assert(next() ! -1)那么Debug和Release的程序行为会不一致。我的做法是封装一个分级宏把“检查级别”显式化// contract.hpp #pragma once #include cassert #include stdexcept enum class ContractMode : unsigned char { off, debug, always }; #if !defined(CONTRACT_MODE) # if defined(NDEBUG) # define CONTRACT_MODE ContractMode::always # else # define CONTRACT_MODE ContractMode::debug # endif #endif #define CONTRACT_CHECK(cond) \ do { \ if constexpr (CONTRACT_MODE ! ContractMode::off) { \ if (!(cond)) { \ if constexpr (CONTRACT_MODE ContractMode::always) \ throw std::logic_error(contract violated: #cond); \ else \ assert(false #cond); \ } \ } \ } while (0) #define CONTRACT_PRE(cond) CONTRACT_CHECK(cond) #define CONTRACT_POST(cond) CONTRACT_CHECK(cond)这个宏的核心价值在于三个地方条件只求值一次避免assert(expr)在Debug模式下被求值两次的副作用问题off / debug / always三档策略可以由团队在构建配置里统一指定而不是被NDEBUG隐式绑架CONTRACT_PRE和CONTRACT_POST两个宏的名字让“这是契约检查”写进了代码本身的语义层。需要注意这个方案需要C17的if constexpr。如果团队还在用C14可以把枚举判断改成普通if但静态优化就会少一些。3.2 类型派把契约塞进类型系统运行时的契约检查再完善终究是“事后补救”。更优雅的路子是把一部分契约编码进类型里让编译器在编译期就把违约挡在门外。最典型的是gsl::not_null它把“指针不能为空”从前置条件变成了类型约束void process(gsl::not_nullint* p) { // 不需要再写 if (!p) return; 编译器已经保证 p 非空 int v *p; }只要调用方试图传一个可能为空的指针代码在编译阶段就会失败。这一类工具的哲学是能靠类型系统表达的契约就不要等运行时检查。因为运行时检查只能发现问题类型约束可以直接消灭问题。同类的还有std::span。它把“裸指针 长度”这个隐式约定封装成单一类型// 旧的隐式契约调用方必须保证 values 非空、长度是 count long long sum(const int* values, size_t count); // 类型化契约span 自己携带长度与边界语义 long long sum(std::spanconst int values);调用方传std::vector、std::array或C数组都可以隐式构造span长度信息不再依赖人为记牢。C20的Concept就是更高维度的编译期契约。它把模板参数的前置条件从注释里的“类型必须支持XXX”提升为编译器可验证的约束templatetypename T concept SortableContainer requires(T t) { { t.begin() } - std::sortable; { t.end() } - std::sortable; std::same_asdecltype(t.begin()), decltype(t.end()); }; templateSortableContainer T void mySort(T container) { // 编译器保证 T 满足排序所需的所有要求 }如果传入的类型不满足SortableContainer报错发生在编译期而不是运行时成本最低的契约就是这么产生的。3.3 异常派什么时候该抛什么时候该崩关于契约违约我常被问到一个问题“契约检查失败应该抛异常还是直接终止程序”我的原则非常明确契约违约是程序缺陷不是可恢复的运行期错误。绝大多数情况下应该fail fast而不是让异常传播出去。为什么因为前置条件被违反通常意味着程序状态已经不可信。如果你在这个状态下继续执行、捕获异常、试图恢复极大概率只是把崩溃从这一层推迟到更远的地方。就像地基已经裂了你非要在上面继续加盖三层楼最后塌方时只会损失更大。但有一个例外接口的外部使用者可能因为不可控的外部输入而违约。典型场景是库的公开API调用方可能根据不完整的外部配置来调用接口。这时候用异常把违约错误抛出去让调用方有机会恢复反而比直接std::terminate对用户更友好。因此我建议的划分是违约方典型场景推荐处理库内部实现函数计算出非法状态assert(false)或CONTRACT_CHECK失败即终止调用方可信内部模块传入了非法参数、重复释放fail fast让问题在测试期暴露调用方外部不可信输入网络请求、用户配置导致参数非法抛异常由上层恢复或返回错误编译期已知类型不支持所需操作用Concept/类型约束编译期拒绝异常还有一个容易被忽视的坑在noexcept函数里抛异常会直接触发std::terminate。如果你的接口被标记为noexcept那么契约检查失败后的“优雅抛异常”路径根本不存在。标记noexcept之前务必想清楚契约检查失败后到底走哪条路。3.4 工具派Boost.Contract与GSL的现实定位Boost.Contract是C里实现契约编程最“正统”的第三方库它把前置条件、后置条件、类不变量用宏和RAII对象包装起来在函数进出时自动检查。代码大概长这样int add(int x, int y) { int result; boost::contract::check c boost::contract::function() .precondition([] { // 前置条件… }) .postcondition([] { // 后置条件… }); result x y; return result; }说实话我在实际项目中很少看到团队真正用Boost.Contract。原因有三个它改变了函数的书写结构代码侵入性强学习成本高引入了额外的RAII和闭包开销在热路径上性能敏感很多团队评估后认为“投入产出比不如自定义宏 类型约束”。相比之下微软的GSLGuidelines Support Library要轻量得多。Expects和Ensures本质上是宏代价可以忽略配合not_null、span这些类型基本覆盖了日常80%的契约需求。我的默认选型就是GSL只有在某个模块的契约复杂度远超“几个条件检查”时才会考虑Boost.Contract。3.5 C26的Contracts机制语言级支持还会远吗经常有朋友问“C是不是很快就有语言级别的契约支持了”这个话题在WG21C标准委员会确实讨论了很多年。从最初的属性式方案到后来提出的pre/post关键字方案目标是让前置/后置条件成为函数声明的一部分能被编译器理解、检查和优化。例如提案中设想的形式类似int divide(int a, int b) [[expects: b ! 0]] [[ensures result: a / b * b a % b a]];但目前这个提案还没有正式落入C26标准主流编译器也没有稳定的完整实现。我的看法是在语言级契约普及之前先把手头的assert、GSL、类型约束用好远比等一个“完美的C26”有价值。设计方法论先进二十年工具落后一点没关系关键是理念要落地。我把上面的几种流派汇总成一张对比表方便选型流派检查时机失败代价代表工具适用场景断言派运行时断言失败或终止assert、自定义宏内部模块、测试期类型派编译期编译失败gsl::not_null、span、Concept接口交界、模板设计异常派运行时抛异常std::logic_error等外部输入引发的违约工具派运行时可配置Boost.Contract契约复杂的核心模块4. 实例重构把连接池的隐含假设全部显式化4.1 需求现状一个到处是“暗雷”的连接池理论讲再多不如一个完整案例来的实在。我虚构一个贴近实战的模块连接池。连接池的需求本身不复杂——维护一组连接对象调用方从池子“借”一个连接用完再“还”回来。难的是它内部的隐含假设多得惊人借连接时池里必须有空闲连接不能借空归还的连接必须是池子曾经借出去的不能是外部凭空构造的同一个连接不能重复归还两次借出去的连接在池里必须被标记为“已占用”不能同时借给两个人归还后连接要重新变为空闲计数必须与真实状态一致。任何一条假设被打破都可能导致连接泄漏、重复使用同一连接、计数漂移。这些bug在日志里往往表现为“连接被关闭后仍在使用”或“池容量神秘变小”。没有契约保护的版本几乎必然会在某个深夜爆雷。4.2 定义契约让每条假设都有名有姓在写实现之前我先把合同条款写清楚贴到代码注释最上方// 前置条件 // - acquire池中至少存在一个空闲连接 // - release传入的连接确实是本池曾经借出的连接且当前处于已借出状态 // 后置条件 // - acquire返回的连接在池中被标记为已借出空闲计数减一 // - release该连接在池中重新变为空闲空闲计数加一 // 类不变量 // - 每个连接要么空闲、要么已借出不会出现第三种状态 // - free_count_ 等于池中空闲连接的真实数量 // - 借出的连接一定可以通过 acquire 追溯到合法来源这段注释不是形式主义。它把模糊的“连接池应该没问题”变成了一组可验证、可测试、可争论的条款。之后所有检查和测试都围绕这些条款展开。4.3 实现代码把契约逐条焊进去下面是我会落地的方式。为了简洁我用GSL的Expects/Ensures实际项目中你完全可以换成第3.1节的自定义宏#include gsl/gsl #include vector #include utility #include stdexcept class Connection { public: Connection() delete; int id() const noexcept { return id_; } private: explicit Connection(int id) noexcept : id_(id) {} int id_{-1}; // -1 表示无效/已移走 friend class ConnectionPool; }; class ConnectionPool { public: explicit ConnectionPool(size_t capacity) : pool_(capacity), used_(capacity, false), free_count_(capacity) { Expects(capacity 0); for (size_t i 0; i capacity; i) { pool_[i] Connection(static_castint(i)); } Ensures(size() capacity); Ensures(available() capacity); } Connection acquire() { Expects(available() 0); // 前置条件池中有空闲连接 for (size_t i 0; i pool_.size(); i) { if (!used_[i]) { used_[i] true; --free_count_; Connection out pool_[i]; pool_[i] Connection(-1); // 清空槽位防止悬垂复用 Ensures(available() free_count_); return out; } } // 理论上不可达前置条件已经保证至少有一个空闲连接 throw std::logic_error(ConnectionPool::acquire: unreachable); } void release(Connection conn) { // 前置条件连接来自本池且当前处于已借出状态 Expects(conn.id() 0); Expects(static_castsize_t(conn.id()) pool_.size()); Expects(used_.at(static_castsize_t(conn.id()))); // 不能重复归还 const size_t idx static_castsize_t(conn.id()); used_[idx] false; pool_[idx] std::move(conn); conn.id_ -1; // 把移后对象置为无效 free_count_; Ensures(available() free_count_); Ensures(!used_[idx]); } size_t size() const noexcept { return pool_.size(); } size_t available() const noexcept { return free_count_; } private: std::vectorConnection pool_; std::vectorbool used_; size_t free_count_; };这段代码的关键设计点有三个第一Connection的构造函数是私有的只有ConnectionPool可以创建连接对象。这就堵死了“外部凭空构造一个假连接来归还”的路子——契约的漏洞从根源上被类型系统堵住了一部分。第二release接收的是Connection也就是右值引用强制调用方用std::move显式表达“我放弃这个连接”。归还接口拿走对象后立即把内部id_重置为-1。这样即使调用方手滑再release一次同一个对象也会在第一条前置条件上当场失败而不是静默地破坏池状态。第三所有Ensures都放在函数返回前它们是承上启下的“回头检查”确保实现没有悄悄违约。这里要特别说明Ensures在GSL中的行为是断言式检查如果失败会直接终止。对于ConnectionPool这种内部模块这个策略是对的——违约就是严重缺陷不值得恢复。4.4 用测试证明违约时它真的会当场暴露光有检查还不够还要有能触发违约的测试用例。我写测试时的思路是先证明正常路径走得通再故意走几条断路确认契约守卫真的在起作用。void test_normal_usage() { ConnectionPool pool(3); assert(pool.available() 3); auto conn pool.acquire(); assert(conn.id() 0); assert(pool.available() 2); pool.release(std::move(conn)); assert(pool.available() 3); assert(conn.id() -1); // move 之后的对象已被置为无效 } void test_violation_acquire_when_empty() { ConnectionPool pool(1); auto conn pool.acquire(); // 此时池已空再 acquire 应触发前置条件失败 // 在 Debug 构建下会被 assert 拦住 } void test_violation_double_release() { ConnectionPool pool(1); auto conn pool.acquire(); pool.release(std::move(conn)); // 第二次 release 同一个对象conn.id() 已经是 -1前置条件失败 pool.release(std::move(conn)); }这组测试的价值不在于“正确”而在于“错误会被立刻看见”。没有契约保护的连接池第二种和第三种违约可能潜伏到很久以后才导致池计数错乱有了契约以后错误在发生的那一行就暴露了这对调试来说是质变。5. 继承场景下的契约变体虚函数与里氏替换原则5.1 虚函数重写时契约应该怎么变关于契约编程还有一个经常被忽略但又极其重要的场景继承和多态。Bjarne Stroustrup在《C程序设计语言》里专门讨论过“虚函数的前置条件和后置条件必须遵守一定的约束”这条约束被后人概括成契约版的里氏替换原则派生类重写虚函数时前置条件只能比基类更宽松不能更严格后置条件只能比基类更严格不能更宽松类不变量至少与基类一样强。为什么是这个方向因为调用方通过基类指针/引用调用虚函数时他唯一知道的契约就是基类注释里写的那份。如果派生类把前置条件加强了比如基类允许x 0派生类偷偷要求x 0那么调用方传x 0时基类契约说没问题派生类却默默违约。反之如果派生类把后置条件加强了比如基类承诺“返回值非负”派生类承诺“返回值大于0”这对调用方是安全的——调用方可以根据更弱的基类契约编写代码而实际运行得到的是更强的保证。这里的关键认知是调用方只依赖基类契约编写代码因此派生类必须“接得住”基类的全部契约。任何对基类契约的削弱都是对调用方的背叛。5.2 违反继承契约的真实案例最经典的案例是矩形与正方形。假设有这样一个基类class Rectangle { public: virtual void setWidth(double w) noexcept { width_ w; } virtual void setHeight(double h) noexcept { height_ h; } double area() const noexcept { return width_ * height_; } private: double width_ 0.0; double height_ 0.0; };它的隐式后置条件至少包括两条setWidth只修改宽度高度不变area()恒等于width_ * height_。现在定义一个Square继承Rectangle为了维护“正方形”不变量它必须重写setWidth让高度跟着宽度一起变class Square : public Rectangle { public: void setWidth(double w) noexcept override { Rectangle::setWidth(w); Rectangle::setHeight(w); // 强行同步高度 } void setHeight(double h) noexcept override { Rectangle::setWidth(h); Rectangle::setHeight(h); } };看起来“正方形”这个不变量保住了但Square::setWidth偷偷修改了高度破坏了基类Rectangle::setWidth的“高度不变”后置条件。调用方如果持有一个Rectangle引用调用setWidth后理所当然地认为高度没变结果发现宽高同步变了——基类契约被违反。这个案例的教训是在基类契约不匹配的情况下继承本身就是错误的建模。正方形和矩形的关系在数学上是“is-a”在契约编程里却未必是“is-a”因为正方形对矩形有一个不可调和的不变量冲突。遇到这种情况更应该考虑组合而不是继承。5.3 把继承契约写进文档与测试C目前没有编译器强制虚函数继承时的契约约束所以我们必须用文档和测试来兜底。我在团队里推广过一个做法在基类虚函数注释里明确列出前置条件、后置条件和类不变量然后在派生类重写函数的地方用静态断言和单元测试确保派生类契约不弱于基类。比如基类有“前置条件w 0”派生类测试里就故意传w 0确认基类契约允许、派生类也不崩溃。这些测试的代码可能有点“自证清白”的味道但它们像安全绳一样能在代码评审阶段拦住大量不显眼的违约。6. 实战心得让契约真正存活在团队代码里的建议6.1 契约检查不是日志更不是错误处理我见过不少团队把assert和日志混为一谈既想检查契约又想在Release下“记录一下然后继续跑”。这其实是两件不同的事——日志是观测手段契约是正确性约束。契约违约后继续跑就像汽车仪表盘报警了你把灯泡抠掉继续开车子迟早要散架。正确的姿态是契约违约就让它响亮地失败。Debug下断言崩Release下根据策略抛异常或终止然后由测试和监控系统把问题暴露出来。宁可一周崩一次也不要让隐患在线上潜伏三个月后爆一个大的。6.2 Release构建不能无脑全关如果说断言的NDEBUG陷阱是“Release全关”的默认行为那我的建议是核心模块的契约检查在Release下至少要保留一定级别。全部关闭省下的那点性能往往不够一次线上事故的排查成本。你可以在构建系统里定义核心库模块CONTRACT_MODE设为always普通业务模块CONTRACT_MODE设为debug热路径性能敏感函数单独用off或直接不写契约用单元测试替代。说白了契约检查也是一种“保险”关键地方不能省。如果你心疼性能优先做的是用类型契约在编译期解决问题而不是关闭运行时的检查。6.3 契约条件里坚决不带副作用这是我在代码评审里反复强调的一条铁律契约检查的表达式必须是无副作用的纯判断。像assert(pop() expected)这种写法等于是把业务逻辑偷偷塞进了检查代码。一旦某个构建模式关闭了检查pop()就不会被执行程序行为瞬间漂移而且这种漂移极难定位。如果你真的需要“先做某个操作再验证”请把操作拆到检查之外auto item pop(); CONTRACT_POST(item.hasValue()); // 检查的是已经拿到的结果6.4 跨模块接口契约要写进文档并配测试同一团队内部的接口契约靠代码评审就能维持。但跨模块、跨团队、跨部门的接口契约必须成为“接口文档的一部分”否则任何一方的离职或快节奏迭代都可能导致契约被无意识变更。我的习惯是对每个跨模块接口在头文件里写一个“契约块”明确列出前置条件、后置条件和不变量并配套一个契约测试文件。这些测试不一定都要跑但在架构评审时它们是最好的“共同语言”——让争论从“我觉得应该这样”变成“你违反了这条契约”。6.5 不是所有接口都需要满配契约最后一点也是我吃过亏才总结出来的不要矫枉过正。给每一个两行的小函数都写满前置条件、后置条件、不变量只会让代码变得臃肿团队成员也会对契约产生抵触。我的粒度判断标准是非平凡函数超过10行、有状态变更、返回复杂结果——建议写跨模块公开接口——必须写模板和虚函数——必须写一眼看到底的纯查询函数——可以不写用类型约束就够。我个人在实际操作中的体会是契约编程最大的价值不在于“写出永远不会坏的程序”而在于强迫你在一开始就把接口的方向盘握稳调用前的世界长什么样调用后的世界长什么样对象活着的时候什么绝对不能变。这三个问题想清楚了代码通常都简洁不少因为很多含糊的分支根本不需要存在。如果现在再遇到“不可能复现”的崩溃我不会先怀疑编译器而是会先去找那个没有写进代码里的前置条件。多半它就在那安安静静地等着下一次被违约。
阅读完成 · 觉得有帮助?