从零开始 / 一次只解释眼前的一步

测试与调试:
独立定位失败。

先写出独立的期望,再观察实际结果。用边界输入发现偏差,用调试器停在具体语句前,并检查每个登记的测试是否真正执行。

先备:基础17(含此前基础课与M1)4 小节 · 预计共 5.5 小时阅读、推演与编码合计

5.5小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。

这一章怎样学

先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。

每次先写输入与期望,再记录实际输出、退出码和执行的检查数量。先完成第17章多文件编译、链接与构建,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。

示例核验状态:本机已在Debug、Release和ASan/UBSan三种构建下执行C++示例。LLDB的实际断点观察尚未完成;下文操作步骤与14/5/2仍是源码预测,不能当作调试器运行实录。

18.1 先写期望,再问程序:14 件东西需要几批?

先完成17章的构建与链接。本章主线研究一个小问题:有items件东西,每批最多放capacity件,需要多少批?主线函数只处理0≤items≤100、1≤capacity≤10的输入;这些前提由下面的固定测试数据满足。我们不把容量0当作可以运行的找错实验。18.1末尾另有浮点判据的短补充,使用自己的独立输入合同。

先别写公式。14件、每批最多5件:第一批5件,剩9件;第二批5件,剩4件;最后4件仍需要一批,所以独立期望是3。如果直接计算整数14/5,得到2。运算遵守C++规则,但它没有回答“剩下的4件怎么办”。

期望值不能让被测函数替你回答

测试用例test case至少说明输入、期望和怎样判断。这里的输入是14与5,期望3由装批过程得到;被测函数交回的值叫actual。比较actual与expected,就是在检查实现是否符合这条事先写好的规则。负责给出独立正确答案的依据常叫测试判据 test oracle

如果先调用被测函数得到expected,再调用它得到actual,两次同样算错也会相等。“两次一致”和“符合问题要求”是两件不同的事。对于这里的小输入,画批次、手算固定答案已经足够,不需要引入测试框架。

正确的计算先得到完整批数items/capacity;再看余数items%capacity。余数不为0时补1批,否则补0批。表达式里的条件 ? 1 : 0沿用03章的条件运算符。所有参与运算的数都在上述小范围内,容量不为0,结果可由int表示。

保存下面的single-check.cpp。batch_count的局部batches保存计算结果;main把它与独立的3比较。checks记录这次确实进行了几项检查,failures记录其中有几项不匹配。普通if负责累计失败;末尾的return failures == 0 ? 0 : 1;沿用03章与01章:没有失败返回0,否则返回1。

#include <iostream>

int batch_count(int items, int capacity) {
    int batches{items / capacity + (items % capacity != 0 ? 1 : 0)};
    return batches;
}

int main() {
    const int actual{batch_count(14, 5)};
    const int expected{3};
    int checks{0};
    int failures{0};
    ++checks;
    if (actual != expected) {
        ++failures;
    }
    std::cout << "actual=" << actual << " expected=" << expected << '\n';
    std::cout << "checks=" << checks << " failures=" << failures << '\n';
    return failures == 0 ? 0 : 1;
}

正常输出两行:actual=3 expected=3,以及checks=1 failures=0,退出状态0。这说明这一个用例通过了,并不说明所有输入都正确。

现在保存一个独立的single-check-bug.cpp副本,保留正确文件。可以在编辑器中“另存为”;终端里的cp 源文件 目标文件会复制文件,目标已存在时可能被覆盖,因此选一个没有自己作品的新文件名。然后只修改副本中batches初始化的计算式:把items / capacity + (items % capacity != 0 ? 1 : 0)换成items / capacity。期望3、检查和退出逻辑都不改。这是明确注入的逻辑错误

先预测,再编译并运行这个副本。下面的选项沿用前章:C++20和严格诊断,-O0与-g为随后调试保留信息;-o给这一次产物独立的名字。&&保证编译失败时不运行旧产物,echo $?立即显示上一条命令的退出状态。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -O0 -g single-check-bug.cpp -o single-check-bug && ./single-check-bug
echo $?

错误副本输出actual=2 expected=3、checks=1 failures=1,随后显示退出状态1。它没有发生越界或除零;失败来自我们自己写的比较。不要把expected改成2来消掉失败。那是在改问题要求。

边界不是多写几个随机数字

一个普通输入暴露了一种错误;接着选择能区分行为的相邻输入。容量仍为5:

items 独立装批过程 期望
0 没有东西,不需要空跑一批 0
1 未满的一批也算一批 1
5 恰好装满,不另加空批次 1
6 一批装5件,还有1件 2

保存boundary-suite.cpp。inputs和expected分别保存输入与手算期望。先比较两组元素数量,数量不相等就打印拒绝信息并返回1;检查必须发生在按相同下标访问两组数据之前。之后每个输入交给同一函数,记录actual、增加checks,并在不匹配时增加failures。最后的检查不能被“打印完结果”代替。

#include <iostream>
#include <vector>

int batch_count(int items, int capacity) {
    int batches{items / capacity + (items % capacity != 0 ? 1 : 0)};
    return batches;
}

int main() {
    const int capacity{5};
    const std::vector<int> inputs{0, 1, 5, 6};
    const std::vector<int> expected{0, 1, 1, 2};
    if (inputs.size() != expected.size()) {
        std::cout << "case-count-mismatch\n";
        return 1;
    }
    int checks{0};
    int failures{0};
    for (std::size_t i{0}; i < inputs.size(); ++i) {
        const int actual{batch_count(inputs[i], capacity)};
        ++checks;
        if (actual != expected[i]) {
            ++failures;
        }
        if (i != 0) {
            std::cout << ' ';
        }
        std::cout << actual;
    }
    std::cout << '\n';
    std::cout << "checks=" << checks << " failures=" << failures << '\n';
    return failures == 0 ? 0 : 1;
}

正常结果依次为0、1、1、2,checks=4 failures=0。若只把计算式改成直接除法,结果变成0、0、1、1:输入1和6分别少了一批,共两项失败,程序退出1。两项失败不意味着有两个不同的根因,它们可以指向同一个遗漏余数的问题。

另一个安全找错实验是只给inputs末尾加上10,expected保持原样。数量检查应在循环之前拒绝,不能为了观察结果继续读不存在的第5个期望。这个实验检查测试程序自身的边界保护,不能拿它当作新输入10已经获得了正确性验证。

每次修复后,把能够暴露原错误的最小输入留下来,形成回归测试 regression test。在本题中,输入1已经能暴露“余数被丢弃”;14则保留了最初的复现背景。小反例方便解释,但不能因此删除其他有不同用途的边界。

补充 · 浮点结果多接近,才算符合要求?(另估30分钟)

整数装批的期望可以直接比较相等;浮点计算有舍入,必须先决定允许多大误差。本例只接受有限double值。有限值包括0与普通正负数;正负无穷大(infinity)不有限,NaN(not a number)也不有限。NaN不是一个可按普通大小关系排序的数;不能让“没有大于阈值”自动变成接受它的理由。

<cmath>中的std::isfinite(value)在value有限时返回true;std::abs(value)对这里的double返回绝对值。先分类与检查输入范围,再做相减和阈值计算。我们的near_small是小数值教学判据,只处理实际值和期望值的绝对值不超过100、atol与rtol均有限且在0到1之间的请求。不满足就返回false。这个范围使差值绝对值不超过200、计算出的阈值不超过101,避免判据自己因极端输入发生溢出。

atol是绝对误差额度,rtol是相对误差比例;二者是调用者按问题选定的要求,不能看完错误结果再调大。取实际值和期望值绝对值中较大的一个作为scale,接受条件为:

abs(actual - expected) <= atol + rtol * scale

绝对项让零附近也有明确容差;相对项随数值尺度变化。本例实际值0.0000005、期望0、atol=0.000001、rtol=0.01:误差约0.0000005,小于阈值约0.000001005,所以接受。实际值1.01、期望1、两个额度都为0.001时,误差约0.01大于阈值约0.00201,所以拒绝。这里的小数推演不是声称十进制数都能被double精确表示。

为了安全提供特殊测试输入,<limits>std::numeric_limits<double>::has_quiet_NaNhas_infinity报告实现是否支持相应特殊值;本例用15章的static_assert要求两者成立。quiet_NaN()取得一个quiet NaN,infinity()取得正无穷大。我们不通过除零制造它们,也不运行会触发浮点异常陷阱的环境设置。

#include <cmath>
#include <iostream>
#include <limits>
#include <vector>

// A finite, bounded teaching contract; not a general extreme-value comparator.
bool near_small(double actual, double expected, double atol, double rtol) {
    if (!std::isfinite(actual) || !std::isfinite(expected) ||
        !std::isfinite(atol) || !std::isfinite(rtol)) return false;
    if (std::abs(actual) > 100.0 || std::abs(expected) > 100.0 ||
        atol < 0.0 || atol > 1.0 || rtol < 0.0 || rtol > 1.0) return false;
    const double scale = std::abs(actual) > std::abs(expected)
        ? std::abs(actual) : std::abs(expected);
    return std::abs(actual - expected) <= atol + rtol * scale;
}

struct Case {
    double actual;
    double expected;
    double atol;
    double rtol;
    bool accepted;
};

int main() {
    static_assert(std::numeric_limits<double>::has_quiet_NaN);
    static_assert(std::numeric_limits<double>::has_infinity);
    const double nan = std::numeric_limits<double>::quiet_NaN();
    const double inf = std::numeric_limits<double>::infinity();
    const std::vector<Case> cases{
        {1.0, 1.0, 0.0, 0.0, true},
        {0.0000005, 0.0, 0.000001, 0.01, true},
        {1.01, 1.0, 0.001, 0.001, false},
        {nan, 1.0, 0.001, 0.001, false},
        {inf, inf, 0.001, 0.001, false},
        {1.0, 1.0, -0.001, 0.001, false},
        {1.0, 1.0, 0.001, nan, false},
        {101.0, 101.0, 0.001, 0.001, false}
    };
    int checks{0};
    int failures{0};
    for (const auto& c : cases) {
        const bool accepted = near_small(c.actual, c.expected, c.atol, c.rtol);
        ++checks;
        if (accepted != c.accepted) ++failures;
        std::cout << "case=" << checks << " accepted=" << accepted << '\n';
    }
    if (checks != 8) ++failures;
    std::cout << "checks=" << checks << " failures=" << failures << '\n';
    return failures == 0 ? 0 : 1;
}

Case里的accepted是事先写好的“本输入应被判据接受吗”;逐项把near_small的实际布尔结果与它比较。拒绝一个非法输入,也可以是该项测试通过。 八项的接受结果依次为1、1、0、0、0、0、0、0:

用例 检查的分界 预期判据结果
1 1与1、两个额度均0 接受
2 零附近的小误差 接受
3 误差大于当前额度 拒绝
4–5 NaN;即使两边同为无穷大 拒绝,合同只接受有限值
6–7 负atol;NaN作为rtol 拒绝
8 两边都是101 拒绝,超出本例范围;不是相等测试失败

正常末行是checks=8 failures=0。这八项同时检查合法结果、非法输入和判据范围,不证明任意浮点算法都应该使用同样的额度。后面的reduction章节会结合累加顺序与范围,选择它自己的数值验证合同。

下载此例。编译方式沿用本章单文件命令;另估30分钟,不计入原18章的5.5小时,也不作为真人校准。分类接口依据:C++数学分类函数

18.2 构建模式与有效检查:没有报警,也可能没有检查

17章已经区分构建选项与运行结果。这里进一步追问:同一份测试换成Release后,比较语句还会执行吗?

assert会怎样影响程序

包含<cassert>后可以写assert(条件表达式);。assert是一个宏,不是本章定义的普通函数。在没有定义NDEBUG的通常调试配置下,它会检查表达式:为真就继续;为假会报告断言失败并终止程序。这种终止不是正常的return 1,也不是抛出一个可以由普通catch接住的C++异常。具体诊断文字和终止状态由环境决定,本章不把它们编成固定数值答案。

若包含该头文件时已经定义NDEBUG,assert不求值其参数表达式。是否启用由这个宏决定,不由“文件夹叫Debug还是Release”决定。沿用17章的读法,-DNDEBUG是在编译时定义这个宏;定义NDEBUG=0也仍然是“已经定义”。

因此,不能把必须发生的操作塞在assert里。例如assert(++evaluations == 1);在启用时会把evaluations从0变成1,在禁用时则连自增也不执行。下面故意用这一点观察检查是否发生;这是教学观察,不是建议应用程序依靠断言来完成计数或更新

assert-evaluation.cpp用17章已教的#ifdef NDEBUG在两种宏状态下分别选择独立期望0或1。这里的#else表示前面的#ifdef条件不成立时,选择它后面的那一段;#endif结束这次二选一。所以同一次编译只保留一个expected_evaluations声明。然后用普通if比较真实evaluations与这个期望,并保存failures。最外层的检查自身没有放进assert,所以两种构建都必须检查实际计数。

#include <cassert>
#include <iostream>

int main() {
    int evaluations{0};
    assert(++evaluations == 1);
#ifdef NDEBUG
    const int expected_evaluations{0};
#else
    const int expected_evaluations{1};
#endif
    const bool matches{evaluations == expected_evaluations};
    int checks{0};
    int failures{0};
    ++checks;
    if (!matches) {
        ++failures;
    }
    std::cout << "assert-count-matches-build=" << matches << '\n';
    std::cout << "checks=" << checks << " failures=" << failures << '\n';
    return failures == 0 ? 0 : 1;
}
构建条件 assert里的自增 实际evaluations 普通检查应得到什么
未定义NDEBUG,本章Debug与sanitizer配置 执行一次 1 与该宏状态的独立期望1相等
已定义NDEBUG,本章Release配置 不执行 0 与该宏状态的独立期望0相等

两种情况下都输出assert-count-matches-build=1、checks=1 failures=0。第一行的1表示“实际计数与当前宏状态匹配”,不是说两种配置都执行了断言。固定输出由后面的持续检查支持,不能只凭那一行文字推断evaluations。

如果一项检查承担提交验收,它应该像前面的if比较一样始终存在。assert仍可用于表达调试时应成立的内部条件,但不能单独承担“结果错误必须让这次测试失败”的责任。也不要混淆15章的static_assert:它发生在编译期,合同与这里的运行时宏不同。

跑过的数量与要求的数量分开保存

即使比较语句没有被关掉,也可能少运行一个用例。test-registration.cpp把每个用例写成一个Case对象,里面的items与expected属于同一项;struct、聚合初始化、vector和范围for都沿用已学写法。

expected_count独立写为4,表示这组约定必须运行4项。executed从0开始,每完成一个实际用例才增加1。每项结果不匹配会增加failures;循环结束后,若executed不等于expected_count,也增加一次失败。不能用当前容器的size给expected_count赋值,否则删去一项时,要求数量也跟着缩小了。

#include <iostream>
#include <vector>

int batch_count(int items, int capacity) {
    int batches{items / capacity + (items % capacity != 0 ? 1 : 0)};
    return batches;
}

struct Case {
    int items;
    int expected;
};

int main() {
    const std::vector<Case> cases{{0, 0}, {1, 1}, {5, 1}, {6, 2}};
    const int expected_count{4};
    int executed{0};
    int failures{0};
    for (const auto& test : cases) {
        const int actual{batch_count(test.items, 5)};
        ++executed;
        if (actual != test.expected) {
            ++failures;
        }
    }
    if (executed != expected_count) {
        ++failures;
    }
    std::cout << "executed=" << executed << " expected=" << expected_count
              << " failures=" << failures << '\n';
    return failures == 0 ? 0 : 1;
}

正常输出executed=4 expected=4 failures=0。只删除最后一个输入6、期望2的Case注册项,其余三项结果仍正确;但输出变成executed=3 expected=4 failures=1,程序退出1。这里新增的1是“数量不足”的失败,不是假称某一项数值比较失败。

数量检查有边界:它能发现本实验的漏注册,不能证明这4项选得好。把某项重复写两遍,也可能仍然凑够4项。用例内容和数量分别审查,数量不能代替独立期望。

停下来,看一次变化

值检查、断言计数与漏注册检查

初始计数0实际求值后1本模式期望11 / 5 · 对应assert-evaluation与test-registration;两种构建模式分开解释初始计数0实际求值后1本模式期望11 / 5
1 · 没有定义NDEBUG

本章Debug与ASan/UBSan配置不定义NDEBUG。assert内的++实际执行,evaluations从0变1,断言条件成立。

1 / 5
查看所有步骤的文字与数值
  1. 1 · 没有定义NDEBUG

    初始计数:0;实际求值后:1;本模式期望:1

    本章Debug与ASan/UBSan配置不定义NDEBUG。assert内的++实际执行,evaluations从0变1,断言条件成立。

  2. 2 · 定义了NDEBUG

    初始计数:0;assert语句之后:仍为0;本模式期望:0

    本章Release配置定义NDEBUG;assert内的++不求值,evaluations保持0。不是把原有1清回0,而是没有执行这次递增。

  3. 3 · 用普通分支核对

    Debug / sanitize:实际1 = 期望1;Release:实际0 = 期望0;普通检查:1次 / 0失败

    两种配置都用普通if把实际计数和各自期望比较,因此统一输出assert-count-matches-build=1、checks=1 failures=0。这个1表示匹配,不表示断言求值次数。

  4. 4 · 四项确实全部执行

    实际执行:4;独立要求:4;失败 / 退出:0 / 0

    test-registration运行四个约定案例,每进入一项便增加executed。结果检查全部通过,实际4项也等于独立要求4项。

  5. 5 · 少一项也必须失败

    实际执行:3;独立要求:仍为4;失败 / 退出:1 / 1

    删除最后{6,2}后,三个剩余结果仍正确,但实际执行3项不等于独立要求4项。数量检查产生1个失败并返回1。

同一个错误,两种构建都应失败

保留18.1中只改了计算式的single-check-bug.cpp。下面仍执行它自己的持续检查。-O2启用这次编译的优化,-DNDEBUG关闭相应assert;它们都不会把这里的普通结果比较变成不需要做的验收。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -O2 -DNDEBUG single-check-bug.cpp -o single-check-bug-release && ./single-check-bug-release
echo $?

它仍应得到actual=2 expected=3、checks=1 failures=1及退出状态1。再用正确的single-check.cpp保存为另一个输出文件,Debug和Release都应得到3、通过检查。不要只看构建命令是否返回成功,还要记录程序本身的结果。

18.3 LLDB:停在第一个偏离期望的位置

测试指出“应该是3,实际是2”。现在用调试器debugger暂停这次运行,读取形成结果时的局部状态。我们调试的仍是自己保存的single-check-bug.cpp,不另换一个大型项目。

先准备与文件匹配的程序

用18.1的-O0 -g命令重新生成single-check-bug。-g提供源文件与变量等调试信息;-O0让这个小例子的逐行观察更直接。优化后有些变量可能不可见,源行与机器执行也不一定一一对应,不能据此说那个变量从未存在。修改源码之后必须重新编译,LLDB不会自动把旧可执行程序变成新实现。

终端的lldb --version显示实际工具版本;lldb ./single-check-bug加载指定可执行文件,出现(lldb)提示符。终端命令与调试器命令输入在不同提示符后面,不要把(lldb)文字也复制进去。加载程序还没有运行main。

lldb --version
lldb ./single-check-bug

下面先解释这一次需要的命令,再按顺序使用:

LLDB命令 在本例中的作用
breakpoint set –name batch_count 按函数名设置断点breakpoint;运行到该函数时暂停
breakpoint list 查看断点及解析位置;0 locations/pending不表示已经能在此停下
run 启动已加载的程序,运行到断点或退出
next 执行当前源语句并停在下一处;遇到函数调用通常越过调用,不逐行进入它
frame variable items capacity batches 读取当前栈帧中这几个变量的状态
thread backtrace 显示当前调用栈;本例可以辨认batch_count由main调用
expression – 表达式 在当前上下文求表达式;–结束命令选项,本章只计算无副作用的算式
continue 恢复运行,直到下一个暂停点或退出
quit 离开LLDB

一次函数调用的局部观察位置称为栈帧 stack frame;backtrace列出当前调用链。frame #0是当前帧,向外能找到main的调用位置,后面可能还有系统启动帧。本章只认读自己程序里的两个函数,不要求解释系统启动代码。

先设置并查看断点,确认batch_count有已解析的位置。run停在该函数后,看到的是将要执行的位置;不要在初始化还没执行时,把batches当成有效计算结果。对这个-O0 -g小程序,执行一次next越过batches的初始化,停到return附近,然后再查看变量;以实际源行位置为准,避免机械地数步。

breakpoint set --name batch_count
breakpoint list
run
next
frame variable items capacity batches
thread backtrace
expression -- items / capacity + (items % capacity != 0 ? 1 : 0)
continue
quit

在错误副本中,初始化执行之后应观察到items=14、capacity=5、batches=2。前两个输入都与用例相符,偏差在形成batches的算式上;它不是main把3打印成了2。单独求正确表达式会得到3,但这次求值没有修改源文件,也没有改变batches;继续运行仍会得到原来的测试失败。

终端最后的程序退出状态与LLDB自身的退出状态要分开。调试器可以成功完成一次观察,而被调试程序按它的检查返回1;实际记录应保留LLDB报告的被调试程序退出码,不能只把外层命令的0当作测试通过。

修复必须落到源码

退出LLDB,在single-check-bug.cpp中把同一计算式恢复为带余数判断的正确版本,保存,再编译到新的single-check-fixed程序并运行。应重新得到actual=3 expected=3、checks=1 failures=0、程序退出0。随后运行原先的四个边界,而不是只重复14一次。

一个最小调试记录保存六项就够:输入与独立期望;实际失败;暂停的源位置;观察到的变量;修改的那一个表达式;重新编译后的结果与回归。开发时注入了错误,就写“注入”,不把它包装成真实事故。遇到工具启动或权限错误时先记录原始提示;没有实际停下来观察,就不能把手算写成LLDB实录。

停下来,看一次变化

从独立期望到首个偏离,再修复复测

输入items=14 / capacity=5两批容量10,不够独立期望3批1 / 5 · single-check的正确源与错误派生;调试观察须以本章实际记录为准输入items=14 / capacity=5两批容量10,不够独立期望3批1 / 5
1 · 先确定需求答案

14件物品,每批最多5件。两批容量只有10,三批容量15,因此独立期望是3。

1 / 5
查看所有步骤的文字与数值
  1. 1 · 先确定需求答案

    输入:items=14 / capacity=5;两批容量:10,不够;独立期望:3批

    14件物品,每批最多5件。两批容量只有10,三批容量15,因此独立期望是3。

  2. 2 · 错误派生在计算处偏离

    输入参数:14 / 5;错误batches:2;应得批数:3

    只保留items/capacity时,C++整数除法得到2。参数没有变化,偏离发生在batches的初始化结果。

  3. 3 · 保留失败证据

    比较:2 != 3;检查统计:1次 / 1失败;退出码:1

    原独立检查把actual=2与expected=3比较。checks=1、failures=1,程序返回1;这不是编译错误,也不是故意让断言终止。

  4. 4 · 查看后改源重编译

    观察值:14 / 5 / 2;源文件修复:有余数时显式加1;重新构建:再执行正确源

    在helper完成初始化后查看items、capacity、batches并核对调用栈;随后把源文件改回带余数判断的表达式并重编译。调试器里算出3不会自动修改源码。

  5. 5 · 原例与边界都复测

    原例:3,退出0;边界结果:0 / 1 / 1 / 2;边界统计:4次 / 0失败

    正确原例得到3并返回0;容量5的四个边界分别得到0、1、1、2,四次检查无失败。

18.4 动态检查的边界:没有报告,说明了多少?

ASan(AddressSanitizer)与UBSan(UndefinedBehaviorSanitizer)在编译及运行时加入相应检查。ASan用于发现一类内存访问错误,例如部分越界与释放后使用;UBSan用于检查所启用类别中的一部分未定义行为,例如某些算术或对齐错误。二者都不会自动知道“14件需要3批”这条业务规则。

本章错误副本的直接整数除法是合法C++,因此不能期待sanitizer替你判断它少算了一批。我们自己的比较仍应让它退出1。没有sanitizer诊断与“所有用例通过”必须分别记录。

用已解释的选项生成一个检查版本

-fsanitize=address,undefined选择这两组检查,单文件命令同时进行编译和链接;多文件时的编译/链接选项传递沿用17章。-g保留调试信息,-O1给这次检查选择较低优化,-fno-omit-frame-pointer帮助保留用于回溯的帧指针信息,但不保证任意报告都有完美调用栈。

下一条运行命令前面的名字=值是终端为紧随程序的这一次启动提供环境变量,不是C++赋值语句。ASAN_OPTIONS与UBSAN_OPTIONS是这两个运行时读取的配置;值内的冒号分隔多个选项。halt_on_error=1要求遇到相应报告时停下;print_stacktrace=1请求UBSan输出可用的栈信息。这里detect_leaks=0明确不进行LeakSanitizer验证,与本章macOS执行条件一致,不能据此声称任意程序无泄漏。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer single-check-bug.cpp -o single-check-bug-sanitize
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 ./single-check-bug-sanitize
echo $?

前一条编译失败时停止,不执行旧的同名程序。如果已在18.3修好这个副本,先明确恢复那一个错误算式再做本实验,不能对着已经修好的文件期待2。错误版本应由自己的检查报告actual=2 expected=3并退出1;没有ASan/UBSan报告并不替它开绿灯。正确版本则应由同一组检查得到3并退出0。

三种证据,回答三个问题

证据 能支持的结论 仍不能推出什么
实际值与独立期望匹配 本次给定输入满足已写出的检查 没列出的要求和输入也都正确
Debug与Release运行相同用例 这两次构建确实执行了所记录的检查 所有编译器、优化选项都必然相同
ASan/UBSan在本次运行没有报告 启用且支持的检查没有在这条实际路径发现对应问题 没执行的路径也安全,或程序逻辑正确

没有到达的分支并不会仅因编译成功而获得动态运行证据。这里不执行悬空解引用、越界移位、容量0或其他危险反例来收集某个“固定输出”。线程竞争与TSan实操留到已有并发章节的适用位置,不能从本章单线程结果推断并发正确性。

独立迁移:容量换成3

合上前面的实现,从新文件写容量为3的同一个合同。先手算:items=0、2、6、7时,分别需要0、1、2、3批。特别比较6与7:恰好装满与多出一件应走不同结果。自己的答案写好后,再阅读以下对照源码。

#include <iostream>
#include <vector>

int batch_count(int items, int capacity) {
    int batches{items / capacity + (items % capacity != 0 ? 1 : 0)};
    return batches;
}

int main() {
    const int capacity{3};
    const std::vector<int> inputs{0, 2, 6, 7};
    const std::vector<int> expected{0, 1, 2, 3};
    if (inputs.size() != expected.size()) {
        std::cout << "case-count-mismatch\n";
        return 1;
    }
    int checks{0};
    int failures{0};
    for (std::size_t i{0}; i < inputs.size(); ++i) {
        const int actual{batch_count(inputs[i], capacity)};
        ++checks;
        if (actual != expected[i]) {
            ++failures;
        }
        if (i != 0) {
            std::cout << ' ';
        }
        std::cout << actual;
    }
    std::cout << '\n';
    std::cout << "checks=" << checks << " failures=" << failures << '\n';
    return failures == 0 ? 0 : 1;
}

正确结果为0、1、2、3,checks=4 failures=0。仍先检查输入与期望数量,再访问对应元素。若只把计算式改回直接除法,输出0、0、2、2,输入2和7失败,程序返回1;增加优化或sanitizer并不能补上缺掉的那一批。

四步练习阶梯

  1. 预测:用14件、容量5手写分批过程,预测错误算式、检查数量与退出状态,再实际运行。
  2. 补全:保留容量5的0/1/5/6四个独立期望,在普通控制流里检查数值与必需数量。删掉一个注册项后,必须观察到非零退出。
  3. 找错:用LLDB停到错误计算之后,记录14/5/2及调用位置;只改源文件中的算式,保存、重编译,并重测四个边界。
  4. 迁移:不看答案,用容量3独立完成0/2/6/7。记录Debug和Release实际执行的检查数量、结果与退出码,再说明动态工具没有证明什么。

这一章的5.5小时包括测试与调试教学。完整G0仍另计10小时,需要陌生题、自己的代码、边界和口述;跑通以上教材、完成阅读记录或看过答案都不会自动通过。进入完整G0说明时,保留本章练习作复习资料,并让评审换一份未见过的题。

先留下自己的答案

两道动手练习

先完成正文的练习阶梯,再用下面两题检查自己的解释。先写预测或代码;需要时展开提示,完成后再对照答案。

练习 1

boundary-suite只把inputs的{0,1,5,6}改为{0,1,5,6,10},保留四个expected。预测输出和退出码,并指出必须在第几类操作之前停止。

查看提示
  1. 先比较两个容器的size,再考虑下标循环。
  2. 新增输入并不自动产生独立答案。
对照答案与推理

输出case-count-mismatch并返回1。inputs有5项、expected只有4项,必须在进入任何expected[i]下标读取之前停止;不能靠越界运行来发现缺少答案。

练习 2

test-registration只删除最后的{6,2},保持expected_count为4。前三个数值比较全部成立时,完整输出和退出码是什么?为什么不能把expected_count改成cases.size()作为修复?

查看提示
  1. executed随实际运行项数增长,不随原定计划增长。
  2. 独立期望必须还能指出“约定四项但只运行三项”。
对照答案与推理

输出executed=3 expected=4 failures=1并返回1。唯一失败来自数量检查。若从删减后的cases.size()生成期望,就把实际少测同时复制成答案,掩盖了遗漏;本题的修复是补回原定注册项并复跑。

关掉参考,再做一次

把理解说出来

先完成正文的独立迁移,再回答下面六题。它们只检验第18.1–18.4节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。

独立期望与逻辑错误

single-check为什么把期望直接写成3?把expected也写成batch_count(14,5)有什么问题?

对照推理与英文回答

期望3来自每批容量5时两批不足、三批够用的问题推理。若实际值和期望都调用同一错误实现,它们可能同时得到2而相等,检查无法发现这个缺陷。错误除法派生必须保留一次失败与退出1。

The expected value comes from the requirement: two batches hold ten items, while three hold fifteen. Reusing the implementation to generate the expectation can reproduce the same bug on both sides. The faulty derivative must report one failure and exit with status one.

边界与表长保护

容量5为何选0、1、5、6?若只增加输入10但没有相应答案,能否直接继续循环?

对照推理与英文回答

这四项分别覆盖空输入、首个非空输入、恰好一批、刚超过一批。输入与答案分开保存时先检查数量一致;不一致就输出case-count-mismatch并退出1,不能读不存在的expected元素。四个例子是有限回归,不是任意输入正确性的证明。

These cases cover empty input, the first nonempty input, an exact batch, and one item beyond it. Separate input and expectation tables must have matching lengths before indexing. A mismatch exits with a failure instead of reading beyond the expectation table. Four cases are finite regression evidence, not a proof for every input.

assert与真实计数

assert-evaluation三个模式都打印assert-count-matches-build=1,是否表示三次构建都执行了++evaluations?

对照推理与英文回答

不是。未定义NDEBUG的两个模式实际计数1;定义NDEBUG的Release实际计数0。程序用普通if分别核对当前模式的独立期望,输出的1只表示比较成立。会影响程序正确性的必要操作与测试比较不能只放在可能被移除的assert表达式里。

No. The modes without NDEBUG evaluate the increment and count one; the Release mode with NDEBUG counts zero. An ordinary conditional compares that actual count with the independent expectation for the current build. The printed one means the comparison passed, not that every build evaluated the assertion.

漏测与数量门

三个已运行案例都通过,为何test-registration的漏注册派生仍必须退出1?只检查执行数量又是否足够?

对照推理与英文回答

本题独立约定运行四项,删除一项后executed只有3,因此数量门产生失败。只数到四也不足以证明选对了测试:重复注册相同案例可能满足数量但遗漏需求,所以仍需逐项检查具体输入、独立期望与结果。

The plan independently requires four cases, so executing only three is a failure even when all three results match. Counting four is not sufficient either: duplicate cases could satisfy the count while missing a requirement. Review the actual inputs, independent expectations, and observed results as well.

首个偏差与修复证据

LLDB已看到items=14、capacity=5、batches=2,并在expression里算出了3,这是否已经修好程序?还需要哪些动作?

对照推理与英文回答

没有。变量与调用栈用于确认当前停点和首次错误结果;expression只在调试会话求值,不自动修改源文件。必须修改独立错误文件中的计算、重新编译该文件并执行,再检查原14件案例与四个边界。记录必须对应实际调试的源码和二进制。

No. The observed variables and backtrace identify the stopped call and its first wrong result. Evaluating an expression does not edit the source. Fix the calculation in that source file, rebuild it, rerun the fourteen-item case, and rerun the four boundary cases. The transcript must identify the actual source and binary used.

动态检查的实际边界

错误items/capacity在ASan/UBSan模式下没有内存或未定义行为报告,但普通测试退出1,这矛盾吗?本章通过后是否自动通过G0?

对照推理与英文回答

不矛盾。在本章正容量与小整数范围内,向下截断是合法C++运算,只是不满足批次需求;独立结果检查才发现错误。动态工具只观察本次实际路径和启用的检查,不能保证未运行路径安全。教材验证也不能代替个人独立编码、测试、调试与口述的G0验收。

There is no contradiction. For these bounded inputs and positive capacities, truncating integer division is legal but fails the batching requirement. The independent result check detects that logic error. Dynamic tools cover executed paths and enabled checks, not every possible path. Textbook validation is separate from the learner’s independent G0 assessment.

和正文是同一份源码

示例文件

先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。

下载清单来自本章元数据,正文代码与下载同源。五份文件分别是独立的C++程序,请按正文逐个编译,并核对对应模式的输出、退出码和检查数量。错误公式或遗漏检查的派生实验按正文单独修改,不把打印成功或编译成功当作测试已完整执行。

可选的学习反馈

记下你真正花的时间

每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。

记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。

尚无真实试学用时。

    按开始学习本章前的情况选择;已经会 C++ 时选择“会 C++”。起点随每条时段保存,之后改选不会重标旧记录。旧记录缺少起点时单独保留;已有基础者的用时不用于校准零基础预算。

      换设备:导入记录,或取回损坏的旧记录

      导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。

      阅读记录与课程验收分别保存。

      本章资料与查证

      本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。