[控场AI]
· 10 分钟阅读· 5,118 字

一次尝试扛起全部评分:C++ 补丁的 noexcept 账本机制

一次尝试扛起全部评分:C++ 补丁的 noexcept 账本机制

用六列账本同时验证C++函数的noexcept声明契约与运行时行为,阻断拼接式绿色构建。

本文提出一套针对C++代码评审的「账本」机制,核心问题是:匹配的打印输出无法验证调用者编译时依赖的`noexcept`契约,二者是两份独立的契约。补丁完全可能令happy-path测试全部通过,同时悄悄移除头文件中的`noexcept`声明,或在标记`noexcept`的函数体内抛出异常——后者会在遇到异常输入时直接触发`std::terminate`。机制的核心设计是:将黄金头文件隔离在模型不可覆写的目录,每个样本使用独立的attempt文件夹,通过六个评分列(编译、`static_assert`契约检查、happy链接与运行、malformed链接与运行)在同一次attempt上全部通过才可晋升,以此从结构上阻断「attempt A通过输出、attempt B通过契约」的拼接式假阳性证据。文章同时清晰划出边界:该机制对函数体内部的未定义行为、数据竞争和内存泄漏均不作承诺。

一个周二的代码评审,面对一个小小的 C++ 帧解析器补丁。候选补丁打印出的负载长度与黄金文件完全一致,happy-path 的二进制也以零状态退出。评审者合入了它。但一个早已针对旧 noexcept 承诺编译的下游调用者,后来在遇到一个短帧时触发了 std::terminate 中止。

问题在于:打印输出从未提及异常,绿色的测试运行也从未给解析器喂过一个空缓冲。这个缺口,正是本文所讨论的「账本」机制要捕捉的东西。

noexcept 账本机制示意

匹配的打印输出,掩盖了什么

happy-path 固件检查的是可观测的文本输出。它并不检查调用者早已依赖并编译进去的异常规格说明(exception specification)。这是两份不同的契约,一个补丁完全可能满足其中一个,却破坏另一个。

从头文件中去掉 noexcept 可以让 happy 输出保持不变。反过来,在声明上保留 noexcept 却在函数体内抛出异常,同样可以让输出看起来正常——直到某个坏输入触发 std::terminate。这两种疏漏,在一个黄金文本文件面前都是「静默」的,不会被发现。

文中提出的工作流会在同一个 attempt id 上同时对这两种疏漏打分。但它并不声称这些评分能证明不存在未定义行为、数据竞争或内存泄漏——它的边界是清晰的。

异常规格说明(exception specification) 是 C++ 头文件中函数声明的一部分,用于向编译器和调用者声明该函数是否可能抛出异常。noexcept 是其中最常见的形式:在函数声明后加上 noexcept 关键字,意味着该函数承诺不抛异常。调用者可以依赖这一承诺进行编译期优化——例如,标准库容器在移动元素时,只有当移动构造函数被标记为 noexcept 时才会选择移动而非拷贝路径。这个契约是编译进调用者二进制文件里的:一旦下游代码已经按照「此函数不会抛」的前提编译完毕,后续如果头文件悄悄去掉了 noexcept,已编译的调用者并不会自动失效,但二者之间的隐式假设已经错位。更危险的是反过来的情况:声明保留了 noexcept,但实现内部却抛出了异常——C++ 标准规定此时运行时必须调用 std::terminate 直接中止程序,没有任何可捕获的异常传播机会。这正是文章开头那个「短帧触发中止」的根本原因。

把契约与样本分离开来

整个设计的核心,是把「黄金文件」放在模型不被允许覆写的目录之外。每个样本获得一个全新的 attempt 文件夹,上一个样本遗留的目标文件(object files)永远不会泄漏进下一次链接。

golden/frame.hpp 是最后一次经过评审的声明。attempt 的 include 文件以这份头文件的拷贝为起点,只接收 prompt 允许的那些编辑。仅有注释差异不算失败——由一个消费端的翻译单元(translation unit)来裁定声明是否仍然是「不抛异常」的。

具体来说:tests/contract_tu.cpp 包含候选头文件;tests/happy.cpp 和 tests/malformed.cpp 链接候选目标文件并各打印一行。其中 malformed 程序是契约的运行时那一半——退出码为 0,且输出哨兵值 0 加换行,才意味着短帧被正确报告而没有触发中止。

需要说明的是,这是一个「提案加上一个未执行的示例」。它假设环境是接受 -std=c++20 的 POSIX g++。在 Windows 上数值化的中止码会不同,但任何非零状态仍然会使该列失败。

拒绝「拼接式」绿色构建的步骤

评审中最隐蔽的坏习惯,就是「拼接」(stitch):一个被重试的样本看起来像是取得了进展,而工具只打印最新的那条绿色记录,却丢弃了真正守住契约的那次尝试。账本机制正是为阻断这一点而设计。

其执行步骤大致如下:

  1. 创建 attempts/001/include、src、build 三个目录,并在应用候选补丁之前,把 golden/frame.hpp 拷贝进 attempt 的 include 树。
  2. 把该样本的 payload.cpp 放到 attempts/001/src 下。若之前的样本已失败,保留它的目录不动。
  3. 把候选编译成 payload.o。非零状态会使 patch_compile 列失败,并且仅阻止本次 attempt 的晋升。
  4. 针对候选头文件和新目标文件编译 tests/contract_tu.cpp。失败的 static_assert 会使 noexcept_contract 列失败。
  5. 链接并运行 happy.cpp,逐字节(含末尾换行)比对标准输出与 golden/happy.out。
  6. 链接并以短超时运行 malformed.cpp,要求退出码为 0 且匹配 golden/malformed.out。信号、逃逸的异常或超时都会使 malformed_run 失败。
  7. 向 ledger.jsonl 追加一个 JSON 对象,包含 attempt id、payload.cpp 的哈希和每一列。只有当同一个对象标记所有必需列为 ok 时,才予以晋升。
  8. 保留失败行。晚些的样本必须凭自己的列通过——「attempt 1 的 stdout 通过」加上「attempt 2 的契约通过」不构成证据。

翻译单元(translation unit) 是 C++ 编译的基本单位,通常对应一个 .cpp 源文件连同它所包含(#include)的所有头文件展开后的整体。每个翻译单元独立编译成一个目标文件(.o),再由链接器合并成最终的可执行文件或库。文章中 contract_tu.cpp 作为独立翻译单元的意义在于:它以自己的视角去解析候选头文件,模拟真实下游调用者的编译环境。如果一次补丁修改了头文件的声明,这个翻译单元在编译时就会感知到变化并触发 static_assert 失败,而不依赖链接或运行阶段。目标文件隔离(每个 attempt 拥有独立的 build 目录)则防止了另一种污染:旧 attempt 编译出的 .o 文件被后续链接步骤复用,导致「新头文件 + 旧实现」的组合悄悄通过链接却行为不一致。

声明、固件与评分器

经过评审的契约 golden/frame.hpp 中,payload_size 函数被声明为 noexcept,注释明确要求「短帧返回 0,不抛异常」。

消费端检查 tests/contract_tu.cpp 使用 static_assert(noexcept(payload_size(FrameView{}))) 与 std::is_nothrow_invocable_v。关键在于:noexcept 运算符检查的是编译器能看到的「声明」,它并不执行函数体。这就是为什么它能在编译期捕获头文件被偷偷去掉 noexcept 的情况。

一个微妙的实现细节:如果一个定义被标记为 noexcept 却仍然抛出,程序会先正常编译,然后在运行时调用 std::terminate。Python 的 subprocess 模块会把 POSIX 信号报告为负返回码——对 SIGABRT 通常是 -6。评分器把每一个非零码都视为失败列,因此它不去特判 134 或 -6。而如果候选同时从头文件和定义中去掉了 noexcept,即便两个文本固件都仍然匹配,契约翻译单元也会编译失败。

评分脚本 grade_attempt.py 把六个必需列——patch_compile、noexcept_contract、happy_link、happy_stdout、malformed_link、malformed_run——统一记录。它用 all(...) 判定 promotable,并把 payload.cpp 的 sha256 一并写入账本,让评审者能确认磁盘上的文件就是被评分的那个文件。

noexcept 运算符(operator)与 noexcept 说明符(specifier)是两个不同的语言机制,容易混淆。说明符用于函数声明,告诉编译器「此函数不抛」;运算符则用于表达式求值,在编译期返回一个布尔值,表示给定表达式是否被声明为不抛异常。noexcept(payload_size(FrameView{})) 中,编译器只查看 payload_size 的声明,而不执行函数体——这是一个纯粹的静态检查。std::is_nothrow_invocable_v<F, Args...> 是标准库提供的类型特征(type trait),底层同样依赖 noexcept 运算符,语义等价但写法更适合泛型场景。把这两者放进 static_assert 意味着:一旦头文件中的 noexcept 声明被移除,翻译单元在编译阶段就会产生硬错误,而不是等到运行时才暴露问题。这是「编译期契约」与「运行时行为」之间的关键分层。

如何解读一行账本记录

账本的每一行都对应一个可操作的判断:

  • 六列全部 ok:同一样本同时守住了声明和两次运行。这是「评审候选」,而非自动合入。
  • happy stdout 通过,但 noexcept 契约失败:头文件丢掉 noexcept 后文本仍然匹配——不予晋升。
  • happy stdout 通过,但 malformed 运行非零:短帧触发了中止或逃逸异常——不予晋升。
  • attempt A 过了 stdout,attempt B 过了契约:拼接式证据——不予晋升。
  • malformed 运行超时:可能是卡死,也可能是机器上的时限设得太紧——不予晋升,需人工检查后重跑同一 attempt。
  • 缺少 payload.cpp:样本不完整——不予晋升。

这里的「晋升」仅意味着账本进程以零状态退出,并不代表维护者可以跳过 diff。失败的 noexcept_contract 行通常以编译器的静态断言提示结尾;失败的 patch_compile 往往指向异常规格不匹配,意味着头文件与定义产生了分歧;带负返回码的 malformed_run 失败则是 terminate 路径。读懂这个顺序,再去向模型要下一个样本——换一个样本并不能修复一个针对错误头文件拷贝评分的账本。

谁不该用这道闸门

作者坦诚地划出了这套机制的边界,这是它值得信赖的地方。

声明检查对函数体是「盲」的;malformed 进程只覆盖一个输入,而非整个输入空间;泄漏和竞争需要各自独立的闸门;五秒的超时在读到那一行记录之前,会把慢磁盘和死锁混为一谈。

更重要的是适用性问题:允许特定函数抛异常的代码库,不应该对那些函数套用 static_assert(noexcept(...))——否则每一次诚实的编辑都会失败。需要形式化证明或完整未定义行为审计的团队,需要比「六个进程列」重得多的工具。此外,一个目的本就是「放松公共契约」的变更,不应被拿去撞旧的黄金头文件——正确做法是在一次经过评审的提交中更新 golden/frame.hpp 和期望文件,然后针对新契约取样。阻挡一个已批准的 API 修改,本身就是流程错误。

(披露:本文作为 MonkeyCode 产品外联的一部分准备。MonkeyCode 的免费模型访问可以提供候选 payload.cpp 及允许时的替换头文件,其免费服务器选项可存放 attempts/ 与 ledger.jsonl;但产品本身不评分补丁,评分的是脚本。脚本不调用模型、不登录服务器——把网络步骤留在评分器之外,意味着一个来自 API 的超时无法被记成一次契约通过。)

#noexcept#C++补丁评审#异常规格#static_assert#AI代码生成#契约测试#代码评审工作流
分享:

相关推荐