← C++ 教材目录(共 13 章)

第 12 章 调试、构建与测试

red wenzi · 2026-09-15 · 编程语言 · C++ · 📖 预计阅读 33 分钟 · 共 4 道练习
🎯 本章你会学到:警告/sanitizer/调试器/CMake/自动化测试。建议边读边敲代码,每节的练习先自己做,再展开答案对照。

前 11 章都在教你“怎么写对”。这一章反过来:**写错的时候,怎么最快找到错**。真实开发里,找 bug 花掉的时间远多于敲代码——把工具用起来,等于给自己省一半时间。

本章四件事:让编译器替你找问题、用 sanitizer 抓内存错误、用调试器定位崩溃、用 CMake 管多文件工程,最后写一个最小的自动化测试。

12.1 三件套:警告、断言、sanitizer

新手最常见的损失是:程序“看起来能跑”,结果悄悄错了。下面三件工具专门抓这类问题,而且都是免费的。

第一件:把警告全开

g++ -std=c++17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -o app app.cpp

-Wall -Wextra 打开绝大多数常用警告;-Wpedantic 拒绝非标准扩展;-Wshadow 提示变量遮蔽外层同名变量;-Wconversion 提示会丢精度的隐式转换(会和 size_t/迭代器混用冲突,可按需开)。

⚠️ 易错点 “改了警告还能跑”是最贵的心态。C++ 里未初始化变量、类型截断、漏 return,很多时候只是警告。

第二件:sanitizer 抓内存错误

g++ -std=c++17 -g -fsanitize=address,undefined -o app app.cpp

MSVC 上的对应写法是 cl /fsanitize=address /Zi app.cpp。开了之后,越界读写、释放后使用、双重释放、整数溢出等会直接在出错的那一行报错并中止。

int a[3]{};
int i{3};
a[i] = 1;      // 越界:不开 sanitizer 可能“静默通过”

同样的代码,关掉 sanitizer 时可能照常输出、什么都不提示;开了之后会立刻打印 stack-buffer-overflow 并指出行号。这就是它存在的意义——把“运气”变成“确定性”。

⚠️ 易错点 sanitizer 只在调试期用:程序会变慢、内存占用变高,不要带到发布版本。

第三件:断言把不变量写下来

assert(size_ <= capacity_);          // 调试期不满足就直接崩,并打印文件行号
assert(!v.empty());                  // 调用 back() 之前先确认非空

断言既是文档也是测试:它把你心里“这里一定成立”的假设写进代码。发布时用 -DNDEBUG 关掉(NDEBUG 定义了以后 assert 变成空语句)。

✍️ 本节练习

12.1.1必做用 sanitizer 抓出越界与泄漏

下面这段程序有两处问题:一处数组越界,一处忘记释放。先直接编译运行看看会发生什么,再用 -fsanitize=address,undefined 重新编译,按报错把那两处改掉(越界改成合法下标、用 unique_ptr 管理内存),最后让它输出 sum=12。

输入 本题无输入。

输出 输出 sum=12

样例输入
(本题无输入)
样例输出
sum=12

💡 提示 先跑一次“能用但不对”的版本,再开 sanitizer 看真实错误;数组用 std::array 或 vector 更安全。

✅ 查看参考答案与解析
#include <iostream>
#include <memory>
int main() {
    const int n = 4;
    std::unique_ptr<int[]> a(new int[n]{1, 2, 4, 5});   // 用智能指针管理
    int sum = 0;
    for (int i = 0; i < n; ++i) {                       // i <= n 就是越界
        sum += a[i];
    }
    std::cout << "sum=" << sum << '\n';
    return 0;
}

解析 原始代码里 a[4] 是越界(合法下标是 0~3),而 new 之后没有 delete 会造成泄漏。开 sanitizer 后运行会直接指出 stack/ heap-buffer-overflowdetected memory leaks,按提示改完即通过。

12.2 调试器:断点、单步、看变量

打印调试(到处插 cout)在程序小的时候够用,但一旦是几百行的多文件工程,调试器能省掉大量来回。

g++ -std=c++17 -g -O0 -o app app.cpp     # -g 保留调试信息,-O0 关优化

然后用 gdb ./app 进入调试器。最常用的命令只有这几个:

命令作用
break 行号 / 函数名下断点(b main、b app.cpp:42)
run开始运行,命中断点停下
next / n单步执行,不进入函数内部
step / s单步执行,进入函数内部
print 变量 / p查看变量的值
bt(backtrace)打印调用栈,看是谁调用了当前函数
watch 变量变量变化时自动停下
continue / c继续运行到下一个断点
quit退出
# 一个典型会话
$ gdb ./app
(gdb) break app.cpp:12
(gdb) run
Breakpoint 1, main () at app.cpp:12
(gdb) print head
$1 = (Node *) 0x0            <- 这里发现 head 是空的
(gdb) bt
#0  main () at app.cpp:12
(gdb) continue
(gdb) quit

在 VS Code 里同样是这套东西:装 C/C++ 扩展,写一个 launch.json,把 program 指向编译出的可执行文件、MIMode 设为 gdb / lldb,就能用鼠标打断点、悬停看变量。

⚠️ 易错点 不要在 -O2 下调试:变量可能被优化掉,断点顺序也会“跳”。调试用 -O0 -g,发布再开优化。

✍️ 本节练习

12.2.1必做用 gdb 找出崩溃位置

下面这段程序在运行时会崩溃(段错误)。不要靠猜:用 -g 编译后交给 gdb,用 run 复现崩溃、bt 看调用栈、print 看变量,找出崩在哪一行、哪个变量有问题,最后修好它,让它输出 3 -> 1 -> -1 -> 2 -> null

输入 本题无输入。

输出 输出一行链表内容,程序不崩溃。

样例输入
(本题无输入)
样例输出
3 -> 1 -> -1 -> 2 -> null

💡 提示 崩溃往往发生在解引用空指针那一行;bt 会直接告诉你第几行。

✅ 查看参考答案与解析
#include <iostream>
#include <memory>

struct Node {
    int value{};
    Node* next = nullptr;
};

int main() {
    Node a{3, nullptr};
    Node b{1, nullptr};
    Node c{-1, nullptr};
    Node d{2, nullptr};
    a.next = &b;
    b.next = &c;
    c.next = &d;

    for (const Node* p = &a; p != nullptr; p = p->next) {
        std::cout << p->value << " -> ";
    }
    std::cout << "null\n";
    return 0;
}

解析 崩溃的经典原因是链路断在某个节点上(指针为 nullptr)却继续解引用。用 gdb 的 bt 看到崩溃行、用 p 变量名 看到空指针后,把断链补上即可;修好后的循环必须严格用 p != nullptr 作终止条件。

12.3 CMake:把多文件工程管起来

文件一多,“编译命令”就开始变成负担:加了新 .cpp 要改命令、想开 sanitizer 要改命令、换台电脑路径又不一样。CMake 的作用是把这些写进一个配置文件。

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(cpp_learn CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app util.cpp main.cpp)     # 源文件列在这里

target_compile_options(app PRIVATE -Wall -Wextra -Wpedantic)

option(ENABLE_ASAN "打开 AddressSanitizer" OFF)
if(ENABLE_ASAN)
  target_compile_options(app PRIVATE -fsanitize=address,undefined -g)
  target_link_options(app PRIVATE -fsanitize=address,undefined)
endif()
# 配置 + 构建 + 运行
cmake -S . -B build                  # 生成构建文件
cmake --build build                  # 编译
./build/app                          # 运行(Windows 上是 .\build\app.exe)

# 想开 sanitizer 就加一个开关
cmake -S . -B build -DENABLE_ASAN=ON
cmake --build build

这样做的三个好处:加文件只改一行;编译选项集中在一处,全项目一致;换到别的机器上照样能构建。

⚠️ 易错点 build/ 是生成物,必须写进 .gitignore,不要提交进仓库。

✍️ 本节练习

12.3.1挑战把三文件工程改造成 CMake 工程

第 3 章的 3.3.2 题把代码拆成了 util.h / util.cpp / main.cpp。请为它写一个 CMakeLists.txt,要求:C++17、开 -Wall -Wextra -Wpedantic、支持 -DENABLE_ASAN=ON 打开 sanitizer;然后写出完整的构建与运行命令。

输入 本题无输入。

输出 构建成功并输出 9。

样例输入
(本题无输入)
样例输出
9

💡 提示 把三个文件都列进 add_executable;头文件不用列(会被自动跟踪)。

✅ 查看参考答案与解析
cmake_minimum_required(VERSION 3.16)
project(square_demo CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app util.cpp main.cpp)
target_compile_options(app PRIVATE -Wall -Wextra -Wpedantic)

option(ENABLE_ASAN "打开 AddressSanitizer" OFF)
if(ENABLE_ASAN)
  target_compile_options(app PRIVATE -fsanitize=address,undefined -g)
  target_link_options(app PRIVATE -fsanitize=address,undefined)
endif()

解析 构建命令:cmake -S . -B build 然后 cmake --build build;运行 ./build/app(Windows 用 .\build\app.exe)。目录结构保持 util.h / util.cpp / main.cpp / CMakeLists.txt 平铺即可。

12.4 最小自动化测试

“我手动试了几个输入,都对”不等于测试。测试要能在改代码之后**一条命令重跑一遍**,这样你才敢改。

最小的测试框架只要三件事:注册用例、运行用例、统计失败。下面这个 40 行的版本足够应付学习阶段的所有需求。

#include <cassert>
#include <functional>
#include <iostream>
#include <string>
#include <vector>

struct Case {
    std::string name;
    std::function<void()> fn;
};

std::vector<Case>& all() {           // 全局用例表
    static std::vector<Case> cases;
    return cases;
}

struct AutoReg {                     // 构造时自动注册
    AutoReg(const std::string& n, std::function<void()> f) { all().push_back({n, f}); }
};

#define TEST(name) \
    void name(); \
    static AutoReg reg_##name(#name, name); \
    void name()

TEST(add_works) {
    assert(1 + 1 == 2);
}

TEST(vector_grows) {
    std::vector<int> v;
    for (int i = 0; i < 100; ++i) { v.push_back(i); }
    assert(v.size() == 100);
}

int main() {
    int failed = 0;
    for (const auto& c : all()) {
        try {
            c.fn();
            std::cout << "[PASS] " << c.name << '\n';
        } catch (...) {
            ++failed;
            std::cout << "[FAIL] " << c.name << '\n';
        }
    }
    std::cout << "failed=" << failed << '\n';
    return failed == 0 ? 0 : 1;      // 有失败就返回非 0,CI 能感知
}

真实项目里会用 GoogleTest、Catch2、doctest 这些库,提供的断言更细(EXPECT_EQ / ASSERT_THROW),但骨架和上面一样。工程上还会配合 sanitizer 一起跑,属于“一条命令验证一遍”的标准动作。

💡 和随机对拍配合:对拍负责“大范围随机输入下不崩、结果一致”,单元测试负责“边界和异常路径覆盖”,两个一起用,手写数据结构就基本不会翻车。

✍️ 本节练习

12.4.1挑战给 IntArray 写四个用例

用上面这个最小测试框架,为第 5 章综合练习里的 IntArray 写四个测试:① 构造后 size 正确且元素为 0;② 拷贝构造后两边独立(改一个不影响另一个);③ at() 越界抛 std::out_of_range;④ 自赋值 a = a 之后内容不变。程序要能打印四个 [PASS] 并输出 failed=0

输入 本题无输入。

输出 四行 [PASS] 和一行 failed=0

样例输入
(本题无输入)
样例输出
[PASS] ctor_zero
[PASS] copy_independent
[PASS] at_throws
[PASS] self_assign
failed=0

💡 提示 断言异常要用 try/catch 包起来再 assert;自赋值用 a = a 直接调用。

✅ 查看参考答案与解析
#include <cassert>
#include <cstddef>
#include <functional>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

class IntArray {
public:
    explicit IntArray(std::size_t n) : size_(n), data_(n > 0 ? new int[n]{} : nullptr) { }
    IntArray(const IntArray& o)
        : size_(o.size_), data_(o.size_ > 0 ? new int[o.size_]{} : nullptr) {
        for (std::size_t i = 0; i < size_; ++i) { data_[i] = o.data_[i]; }
    }
    IntArray& operator=(const IntArray& o) {
        if (this == &o) { return *this; }
        int* fresh = o.size_ > 0 ? new int[o.size_]{} : nullptr;
        for (std::size_t i = 0; i < o.size_; ++i) { fresh[i] = o.data_[i]; }
        delete[] data_;
        data_ = fresh;
        size_ = o.size_;
        return *this;
    }
    ~IntArray() { delete[] data_; }
    std::size_t size() const { return size_; }
    int& at(std::size_t i) {
        if (i >= size_) { throw std::out_of_range("IntArray::at"); }
        return data_[i];
    }
    int at(std::size_t i) const {
        if (i >= size_) { throw std::out_of_range("IntArray::at"); }
        return data_[i];
    }
private:
    std::size_t size_ = 0;
    int* data_ = nullptr;
};

struct Case { std::string name; std::function<void()> fn; };
std::vector<Case>& all() { static std::vector<Case> c; return c; }
struct AutoReg { AutoReg(const std::string& n, std::function<void()> f) { all().push_back({n, f}); } };
#define TEST(name) void name(); static AutoReg reg_##name(#name, name); void name()

TEST(ctor_zero) {
    IntArray a(3);
    assert(a.size() == 3);
    for (std::size_t i = 0; i < a.size(); ++i) { assert(a.at(i) == 0); }
}

TEST(copy_independent) {
    IntArray a(3);
    a.at(0) = 7;
    IntArray b = a;
    b.at(0) = 99;
    assert(a.at(0) == 7);            // 深拷贝:互不影响
    assert(b.at(0) == 99);
}

TEST(at_throws) {
    IntArray a(2);
    bool thrown = false;
    try { a.at(5); } catch (const std::out_of_range&) { thrown = true; }
    assert(thrown);
}

TEST(self_assign) {
    IntArray a(2);
    a.at(1) = 42;
    a = a;                            // 自赋值不能崩也不能变
    assert(a.size() == 2);
    assert(a.at(1) == 42);
}

int main() {
    int failed = 0;
    for (const auto& c : all()) {
        try { c.fn(); std::cout << "[PASS] " << c.name << '\n'; }
        catch (...) { ++failed; std::cout << "[FAIL] " << c.name << '\n'; }
    }
    std::cout << "failed=" << failed << '\n';
    return failed == 0 ? 0 : 1;
}

解析 注意用例的执行顺序和注册顺序一致(同一个 vector 里),所以输出顺序稳定;如果哪天输出顺序变了,说明有静态初始化顺序问题——这也是这套极简框架的一个已知局限。

本章小结

📚 本文概念都在知识大全:

调试器 GDB · Sanitizer · CMake · 单元测试 · 编译选项