K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 16 讲:RAII、智能指针与项目构建

从资源生命周期出发,掌握 RAII、智能指针以及 Make 与 CMake 项目构建。

CS106L 第 16 讲:RAII、智能指针与项目构建 的封面
C++ · CLASS-C

本课程依据 CS106L Lecture 16: RAII, Smart Pointers, Building Projects 重构。

本节课真正要解决的问题

前几节课中,我们已经学习了对象、特殊成员函数、移动语义,以及如何用 std::optional 避免无效状态。

现在,我们要面对另一个更加隐蔽的问题:

一个程序不仅要正确地“使用资源”,还必须确保资源最终会被释放。

这里的资源不只是动态内存。文件、线程锁、网络连接和数据库连接,都有类似的生命周期:

获取资源
   ↓
使用资源
   ↓
释放资源

如果程序永远按照从上到下的顺序执行,我们或许可以在最后手动写一个释放操作。

但真实程序可能:

  • 提前 return
  • 在中途抛出异常;
  • 在多个分支中结束;
  • 调用另一个可能失败的函数;
  • 在维护时加入新的退出路径。

于是,“记得在最后释放资源”会逐渐变成一件非常不可靠的事。

本节课将依次解决三个问题:

  1. 异常为什么会让手动资源管理变得脆弱;
  2. RAII 如何把资源生命周期绑定到对象生命周期;
  3. 智能指针如何把 RAII 应用于动态内存;
  4. 当项目由多个文件组成时,如何使用 Make 和 CMake 构建程序。

学习本节所需的前置知识

你需要大致知道:

  • 局部变量有作用域;
  • 构造函数在对象创建时执行;
  • 析构函数在对象销毁时执行;
  • new 可以在动态存储区中创建对象;
  • delete 可以释放由 new 创建的对象;
  • 指针可以通过 *-> 访问目标对象;
  • 对象可以复制,也可以移动;
  • 类的成员对象会随着外层对象一起销毁。

本节会重新解释这些知识如何共同构成 RAII,因此不要求你已经熟练掌握资源管理。


从上一节的类型安全自然走向资源安全

上一节课中的 std::optional<T> 解决了这样一个问题:

一个值有时存在,有时不存在,怎样在类型层面明确表示这种状态?

RAII 解决的是一个相似但范围更大的问题:

一个对象是否拥有某项资源?谁负责释放它?什么时候释放?

如果这些责任只存在于程序员脑子里,代码就很容易出错。

如果我们能够把责任写进类型:

对象存在
  ↓
资源一定已被正确获取

对象销毁
  ↓
资源一定被正确释放

那么资源管理就不再依赖“记得调用某个函数”,而是由语言的对象生命周期帮助我们完成。


一、一个看似只有三个分支的函数,究竟有多少条执行路径?

考虑课件中的函数:

std::string returnNameCheckPawsome(Pet p) {
    if (p.type() == "Dog" || p.firstName() == "Fluffy") {
        std::cout << p.firstName() << ' '
                  << p.lastName()
                  << " is paw-some!\n";
    }

    return p.firstName() + " " + p.lastName();
}

这里只展示了 Pet 的使用接口,暂时省略类的完整定义。后面的综合项目会给出可直接编译的完整版本。

从普通的分支结构来看,我们可能认为这里只有几种情况:

进入函数
   ↓
判断条件
  ↙  ↘
真    假
  ↘  ↙
返回姓名

似乎只有两三条执行路径。

但是,这段代码中几乎每一项操作都可能包含函数调用:

  • 按值传入 Pet p,可能需要调用复制构造函数;
  • p.type() 是函数调用;
  • p.firstName() 是函数调用;
  • p.lastName() 是函数调用;
  • == 可能是重载运算符;
  • << 是重载运算符;
  • 字符串 + 是重载运算符;
  • 创建字符串临时对象可能需要分配内存;
  • 返回字符串可能涉及构造、移动或复制。

这些操作中的某一项如果失败,就可能抛出异常。

于是,函数除了正常的 if 分支和 return 路径,还隐藏着大量“执行到一半突然离开”的异常路径。

课件将这些潜在异常出口统计为“至少 23 条路径”,其目的不是给出一个严格适用于所有编译器的控制流定理,而是强调:

一段看起来很短的 C++ 代码,也可能存在大量隐藏的退出位置。

更严格地说,这个数字依赖很多假设:

  • Pet 的函数是否真的可能抛出异常;
  • 运算符如何实现;
  • std::cout 是否启用了异常;
  • 编译器是否进行复制消除;
  • 字符串操作是否需要分配内存;
  • 短路求值让哪些表达式根本不会执行。

因此,不必死记数字 23。真正需要记住的是:

不能只在肉眼可见的 return 前考虑资源清理,异常也能让函数在中途退出。


二、异常是怎样改变程序执行方向的?

2.1 什么是异常?

异常(exception)是 C++ 表示和传播错误的一种机制。

普通函数调用通常按照下面的方式返回:

调用函数
   ↓
函数正常执行
   ↓
通过 return 返回调用者

异常发生时,流程会变成:

调用函数
   ↓
执行到 throw
   ↓
立即停止当前正常流程
   ↓
寻找能够处理该异常的 catch

抛出异常使用 throw

throw std::runtime_error("something went wrong");

处理异常使用 trycatch

try {
    // 可能抛出异常的代码
} catch (const std::runtime_error& error) {
    // 处理 runtime_error
}

2.2 一个完整的异常示例

下面的程序检查年龄是否合法:

#include <iostream>
#include <stdexcept>

void checkAge(int age) {
    if (age < 18) {
        throw std::invalid_argument(
            "you must be at least 18 years old"
        );
    }

    std::cout << "Access granted.\n";
}

int main() {
    try {
        checkAge(15);
        std::cout << "This line is inside try.\n";
    } catch (const std::invalid_argument& error) {
        std::cout << "Access denied.\n";
        std::cout << "Reason: " << error.what() << '\n';
    }

    std::cout << "Program continues after catch.\n";
}

编译:

g++ -std=c++20 main.cpp -o main

输出:

Access denied.
Reason: you must be at least 18 years old
Program continues after catch.

执行过程如下。

首先,main() 进入 try

age = 15

然后调用:

checkAge(15);

checkAge() 中:

age < 18

结果为 true,于是执行:

throw std::invalid_argument(...);

此时发生三件事:

  1. checkAge() 后面的代码不再执行;
  2. trycheckAge(15) 后面的输出语句也不再执行;
  3. 程序开始寻找匹配的 catch

异常类型是 std::invalid_argument,因此下面的处理器可以接住它:

catch (const std::invalid_argument& error)

处理完成后,程序继续执行整个 try-catch 结构之后的代码。


2.3 catch 不是普通的 else

课件为了建立直觉,将多个 catch 类比成:

第一个 catch     类似 if
第二个 catch     类似 else if
最后的全捕获     类似 else

这个类比可以帮助理解匹配顺序,但不能完全等同。

正式语法是:

try {
    // 可能抛出异常
} catch (const std::invalid_argument& error) {
    // 处理 invalid_argument
} catch (const std::runtime_error& error) {
    // 处理 runtime_error
} catch (const std::exception& error) {
    // 处理其他标准异常
} catch (...) {
    // 捕获其他所有类型
}

课件中的简化写法:

catch {
}

并不是合法的 C++ 全捕获语法。

正确写法必须包含三个点:

catch (...) {
}

另外,通常应当通过常量引用捕获异常:

catch (const std::exception& error)

而不是:

catch (std::exception error)

通过值捕获可能产生额外复制,还可能发生对象切片。


2.4 处理器的顺序很重要

假设 std::invalid_argument 属于更一般的 std::exception 类型体系。

下面的顺序合理:

try {
    // ...
} catch (const std::invalid_argument& error) {
    // 更具体的类型
} catch (const std::exception& error) {
    // 更一般的类型
}

如果把一般类型放在前面:

try {
    // ...
} catch (const std::exception& error) {
    // 会先匹配这里
} catch (const std::invalid_argument& error) {
    // 这里无法得到机会
}

更具体的处理器就可能永远不会被执行。


三、异常传播时,局部对象会发生什么?

异常并不一定在抛出它的函数中被处理。

考虑调用关系:

main()
  └── outer()
        └── inner()
              └── throw

如果 inner() 没有对应的 catch,异常会离开 inner(),继续寻找调用者中的处理器。

这个过程称为栈展开(stack unwinding)。

在展开过程中,已经成功构造的局部对象会被销毁。


3.1 用程序观察栈展开

#include <iostream>
#include <stdexcept>
#include <string>

class Marker {
public:
    explicit Marker(const std::string& name)
        : name_(name) {
        std::cout << "Construct " << name_ << '\n';
    }

    ~Marker() {
        std::cout << "Destroy " << name_ << '\n';
    }

private:
    std::string name_;
};

void inner() {
    Marker inner_marker{"inner object"};
    throw std::runtime_error("error from inner");
}

void outer() {
    Marker outer_marker{"outer object"};
    inner();
}

int main() {
    try {
        outer();
    } catch (const std::exception& error) {
        std::cout << "Caught: " << error.what() << '\n';
    }
}

输出:

Construct outer object
Construct inner object
Destroy inner object
Destroy outer object
Caught: error from inner

执行顺序为:

进入 outer()
    创建 outer_marker

进入 inner()
    创建 inner_marker

inner() 抛出异常
    销毁 inner_marker

异常离开 inner()
    销毁 outer_marker

异常到达 main() 的 catch

这里出现了一个极其重要的性质:

即使函数没有执行到结尾,局部对象的析构函数仍会在栈展开时执行。

这正是 RAII 能够工作的基础。


3.2 哪些东西不会自动清理?

局部对象会自动销毁,但普通指针指向的动态对象不会因为指针离开作用域而自动执行 delete

考虑:

Resource* pointer = new Resource;

这里实际上存在两个不同的东西:

局部指针变量 pointer
        │
        ▼
动态创建的 Resource 对象

当作用域结束时:

  • pointer 这个指针变量会消失;
  • 它指向的动态对象不会自动销毁。

如果没有执行:

delete pointer;

动态对象就会泄漏。


练习:预测栈展开时的输出

#include <iostream>
#include <stdexcept>

class Item {
public:
    explicit Item(int id) : id_(id) {
        std::cout << "create " << id_ << '\n';
    }

    ~Item() {
        std::cout << "destroy " << id_ << '\n';
    }

private:
    int id_;
};

void work() {
    Item first{1};

    {
        Item second{2};
        throw std::runtime_error("failed");
    }
}

int main() {
    try {
        work();
    } catch (const std::exception&) {
        std::cout << "caught\n";
    }
}

答案与解释

输出为:

create 1
create 2
destroy 2
destroy 1
caught

first 先创建,second 后创建。

异常发生时,局部对象按照与构造相反的顺序销毁:

first 创建
second 创建

异常发生

second 销毁
first 销毁

catch 要等到 work() 中需要清理的对象全部销毁后才开始执行。


四、为什么手动 newdelete 经不起异常?

考虑下面的资源类:

#include <iostream>
#include <stdexcept>

class Resource {
public:
    Resource() {
        ++live_count_;
        std::cout << "Resource acquired.\n";
    }

    ~Resource() {
        --live_count_;
        std::cout << "Resource released.\n";
    }

    static int liveCount() {
        return live_count_;
    }

private:
    static inline int live_count_ = 0;
};

void riskyOperation() {
    throw std::runtime_error("operation failed");
}

void unsafeFunction() {
    Resource* resource = new Resource;

    riskyOperation();

    delete resource;
}

int main() {
    try {
        unsafeFunction();
    } catch (const std::exception& error) {
        std::cout << "Caught: " << error.what() << '\n';
    }

    std::cout << "Live resources: "
              << Resource::liveCount() << '\n';
}

输出:

Resource acquired.
Caught: operation failed
Live resources: 1

没有出现:

Resource released.

执行过程为:

new Resource
    ↓
动态对象创建成功
    ↓
riskyOperation() 抛出异常
    ↓
unsafeFunction() 立即退出
    ↓
delete resource 被跳过

虽然局部指针变量 resource 会消失,但它指向的对象仍然存在。

这就是内存泄漏(memory leak)。


4.1 课件中的原始指针示例需要修正的地方

课件使用了类似下面的结构:

Pet* p = new Pet(petId);

// 使用 p

delete p;

其教学目的非常明确:

如果 newdelete 之间抛出异常,delete 就不会执行。

不过,课件截图中对指针成员的访问写成了类似:

p.type()

如果 p 的类型是 Pet*,正确语法应当是:

p->type()

或者:

(*p).type()

此外,必须确保删除的是同一个指针变量:

delete p;

不能出现对象名称前后不一致的情况。

这些语法修正不会改变原示例的教学重点:手动资源释放很容易被异常绕过。


4.2 这不只是内存问题

很多资源都具有成对操作:

资源 获取操作 释放操作
动态内存 new delete
文件 open close
互斥锁 lock unlock
网络套接字 socket close
数据库连接 connect disconnect
系统句柄 创建或获取句柄 关闭句柄

共同问题是:

获取资源
   ↓
执行许多可能失败的操作
   ↓
释放资源

只要中间存在一条异常路径,最后的释放操作就可能被跳过。


4.3 可以在每个地方都写 try-catch 吗?

我们当然可以这样写:

Resource* resource = new Resource;

try {
    riskyOperation();
} catch (...) {
    delete resource;
    throw;
}

delete resource;

这里的:

throw;

表示重新抛出当前异常。

这段代码能够处理当前这个简单例子,但会产生新的问题:

  • 每获取一种资源都要重复写清理代码;
  • 多个资源的清理顺序容易出错;
  • 新增一个 return 时可能忘记清理;
  • 新增一个异常点时可能忘记更新处理逻辑;
  • 清理代码会淹没真正的业务逻辑;
  • 多层嵌套后非常难读。

我们真正需要的不是“在每一条路径上手动清理”,而是:

无论从哪一条路径离开作用域,都自动执行清理。

这自然引出了 RAII。


五、RAII:把资源生命周期绑定到对象生命周期

RAII 的全称是:

资源获取即初始化(Resource Acquisition Is Initialization)

这个名称第一次看到可能有些拗口。它想表达的是:

让一个对象在构造成功时就拥有资源,并在对象析构时自动释放资源。

可以把 RAII 对象理解成资源的管理者:

RAII 对象
┌─────────────────────────┐
│ 持有资源的句柄           │
│ 构造时获取资源           │
│ 析构时释放资源           │
└─────────────────────────┘

只要管理对象是局部对象:

进入作用域
   ↓
构造管理对象
   ↓
资源被获取
   ↓
使用资源
   ↓
离开作用域
   ↓
析构管理对象
   ↓
资源被释放

离开作用域可以由很多原因造成:

  • 正常执行到右花括号;
  • 执行 return
  • 执行 breakcontinue 后离开局部作用域;
  • 抛出异常并进行栈展开。

这些情况下,已成功构造的局部对象都会析构。


5.1 对课件定义的更准确理解

课件将 RAII 概括为:

  • 类使用的资源应当在构造函数中获取;
  • 类使用的资源应当在析构函数中释放。

作为初学阶段的直觉,这样理解是合适的。

更准确地说:

一个类所拥有的资源,应当由对象生命周期管理。

“使用资源”和“拥有资源”不是同一个概念。

例如:

void print(const Pet& pet);

print() 使用了 pet,但并不拥有 pet,因此不负责销毁它。

而智能指针可能拥有动态对象,因此必须负责最终释放它。


5.2 为什么 RAII 能避免“半有效状态”?

考虑一个需要手动初始化的类:

class FileReader {
public:
    FileReader() = default;

    void open(const std::string& filename);

    void read();
};

对象刚创建时:

FileReader reader;

它还没有文件可读。

此时调用:

reader.read();

对象可能处于无效状态。

其生命周期变成:

对象已存在
   ↓
但资源尚未获取
   ↓
必须记得调用 open()
   ↓
对象才真正可用

这就是一种半有效状态(half-valid state)。

更好的设计是:

class FileReader {
public:
    explicit FileReader(const std::string& filename);

    void read();
};

创建时就提供文件名:

FileReader reader{"data.txt"};

如果构造成功,说明对象已经可用。

如果文件无法打开,构造函数可以抛出异常,使这个无效对象根本不会成功存在。

于是我们得到一个更清楚的不变量:

FileReader 对象存在
        ⇔
它持有一个可用的文件资源

5.3 RAII 的三个主要收益

对象构造成功后立即可用

不需要额外记住:

object.initialize();

资源释放不依赖手动调用

不需要在所有路径中重复:

object.close();

异常发生时仍然能够清理资源

异常展开会调用局部对象的析构函数。


5.4 “无论如何都会析构”需要一点边界

课件使用了“No matter what”来强调析构函数的可靠性。

在普通 C++ 控制流中,可以理解为:

  • 正常离开作用域会析构;
  • return 会析构;
  • 异常栈展开会析构。

但它并不是说任何宇宙级情况都能执行析构。例如:

  • 调用 std::abort()
  • 操作系统强制终止进程;
  • 程序断电;
  • 某些致命崩溃;
  • 在异常展开期间另一个析构函数又抛出异常并导致终止。

因此,RAII 保证的是正常语言控制流和异常展开中的确定性清理。

析构函数通常不应抛出异常。


六、文件流本身就是 RAII 对象

课件展示了类似下面的代码:

void printFile() {
    std::ifstream input;
    input.open("hamlet.txt");

    std::string line;
    while (std::getline(input, line)) {
        std::cout << line << std::endl;
    }

    input.close();
}

课件想指出的问题是:

  • 获取和释放操作散落在普通代码中;
  • 如果循环中抛出异常,input.close() 可能被跳过;
  • 对象先创建、后打开,中间存在未打开状态。

不过这里需要做一个准确性修正:

std::ifstream 本身已经是 RAII 类型。

即使没有手动执行:

input.close();

input 析构时,它也会关闭文件。

所以这段代码不会因为异常而永久忘记关闭文件。

但是,我们仍然可以写得更符合 RAII 的设计:

#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>

void printFile() {
    std::ifstream input{"hamlet.txt"};

    if (!input) {
        throw std::runtime_error("failed to open hamlet.txt");
    }

    std::string line;

    while (std::getline(input, line)) {
        std::cout << line << '\n';
    }
}

int main() {
    try {
        printFile();
    } catch (const std::exception& error) {
        std::cerr << "Error: " << error.what() << '\n';
    }
}

这里没有显式调用:

input.close();

执行过程为:

构造 input
   ↓
构造函数尝试打开文件
   ↓
循环读取
   ↓
函数正常结束或异常退出
   ↓
input 析构
   ↓
文件自动关闭

使用:

std::ifstream input{"hamlet.txt"};

比先默认构造再调用 open() 更容易保证对象创建后立即处于预期状态。


练习:是否必须调用 close()

判断下面的程序是否会在异常发生时遗忘关闭文件:

void processFile() {
    std::ifstream input{"data.txt"};

    std::string line;
    while (std::getline(input, line)) {
        processLine(line);
    }
}

假设 processLine() 可能抛出异常。


答案与解释

不会因为缺少显式 close() 而遗忘关闭文件。

input 是局部的 std::ifstream 对象。

processLine() 抛出异常时,程序离开 processFile(),栈展开会调用 input 的析构函数。析构函数负责关闭它持有的文件。

显式 close() 仍然有一种用途:希望在对象析构前提前释放文件资源。但对于普通的函数局部文件流,通常没有必要手动调用。


七、线程锁为什么尤其需要 RAII?

多个线程共同访问数据时,通常需要互斥锁(mutex)。

一种手动写法是:

database_lock.lock();

// 修改共享数据

database_lock.unlock();

lock()unlock() 之间的区域称为临界区(critical section)。

lock()
  ↓
┌───────────────────────┐
│ 只有持有锁的线程能进入 │
│ 修改共享数据           │
└───────────────────────┘
  ↓
unlock()

如果临界区中抛出异常:

database_lock.lock();

modifyDatabase();  // 抛出异常

database_lock.unlock();

最后一行会被跳过。

锁将一直保持锁定状态,其他线程可能永远无法继续,程序出现死锁。

课件的原始代码中还存在变量名不一致的问题:获取的是 databaseLock,释放时却写成了另一个名称。正确的手动形式必须对同一个互斥锁调用 lock()unlock()

但即使名称正确,异常问题仍然存在。


7.1 使用 std::lock_guard

标准库提供了 RAII 锁管理器:

std::lock_guard<std::mutex>

它的行为是:

构造 lock_guard
   ↓
锁定 mutex

析构 lock_guard
   ↓
解锁 mutex

完整示例:

#include <iostream>
#include <map>
#include <mutex>
#include <stdexcept>

void cleanDatabase(
    std::mutex& database_lock,
    std::map<int, int>& database
) {
    std::lock_guard<std::mutex> guard{database_lock};

    database.clear();

    throw std::runtime_error("database validation failed");
}

int main() {
    std::mutex database_lock;
    std::map<int, int> database{
        {1, 10},
        {2, 20}
    };

    try {
        cleanDatabase(database_lock, database);
    } catch (const std::exception& error) {
        std::cout << "Caught: " << error.what() << '\n';
    }

    {
        std::lock_guard<std::mutex> guard{database_lock};
        std::cout << "The mutex can be locked again.\n";
    }
}

输出:

Caught: database validation failed
The mutex can be locked again.

cleanDatabase() 中:

std::lock_guard<std::mutex> guard{database_lock};

创建 guard 时,database_lock 被锁定。

异常发生时,guard 离开作用域,它的析构函数解锁互斥锁。

不需要显式写:

database_lock.unlock();

7.2 RAII 负责清理,但不负责逻辑回滚

上面的程序中,异常发生前已经执行:

database.clear();

所以锁虽然被正确释放,数据库内容却已经被清空。

RAII 能保证:

锁会释放
文件会关闭
内存会回收

但不能自动保证:

业务数据恢复到操作前的状态

事务回滚是另一个设计问题。

这一区别很重要:

RAII 保证资源生命周期正确,不保证所有业务操作都具有原子性。


练习:下面哪段代码具有异常安全的解锁行为?

代码 A:

mutex.lock();
modify();
mutex.unlock();

代码 B:

std::lock_guard<std::mutex> guard{mutex};
modify();

假设 modify() 可能抛出异常。


答案与解释

代码 B 具有异常安全的解锁行为。

代码 A 中,如果 modify() 抛出异常:

mutex.unlock();

不会执行。

代码 B 中,异常会导致 guard 析构,析构函数负责解锁。

不过,代码 B 只保证锁被释放,不保证 modify() 已经完成的部分修改会被撤销。


八、把 RAII 应用于动态内存:智能指针

对于线程锁,我们使用:

RAII for locks
      ↓
std::lock_guard

对于动态内存,我们也可以创建一个管理对象:

RAII for heap memory
      ↓
智能指针

智能指针(smart pointer)不是“会思考的地址”。

它是一个普通 C++ 类,只不过这个类内部保存了指针,并在析构时按照规则释放动态对象。

智能指针对象
┌────────────────────────┐
│ 保存目标对象的地址      │
│ 记录所有权信息          │
│ 析构时执行相应清理      │
└────────────┬───────────┘
             │
             ▼
      动态创建的对象

因此,智能指针本质上是:

动态内存的 RAII 包装器。

使用智能指针需要:

#include <memory>

现代 C++ 主要提供三种相关类型:

  • std::unique_ptr<T>
  • std::shared_ptr<T>
  • std::weak_ptr<T>

它们最大的区别不是语法,而是所有权规则。


九、std::unique_ptr:只能有一个所有者

9.1 唯一所有权是什么意思?

std::unique_ptr<T> 表示:

当前动态对象只有一个负责销毁它的所有者。

关系可以画成:

unique_ptr
    │
    │ 唯一拥有
    ▼
  T 对象

当这个 unique_ptr 被销毁时,它拥有的对象也会被销毁。


9.2 用 std::make_unique 创建对象

完整示例:

#include <iostream>
#include <memory>

class Node {
public:
    explicit Node(int value)
        : value_(value) {
        std::cout << "Node constructed: "
                  << value_ << '\n';
    }

    ~Node() {
        std::cout << "Node destroyed: "
                  << value_ << '\n';
    }

    int value() const noexcept {
        return value_;
    }

private:
    int value_;
};

int main() {
    std::unique_ptr<Node> node =
        std::make_unique<Node>(42);

    std::cout << node->value() << '\n';
}

输出:

Node constructed: 42
42
Node destroyed: 42

执行过程:

std::make_unique<Node>(42)
   ↓
动态创建 Node
   ↓
构造 unique_ptr,令其拥有 Node
   ↓
通过 node->value() 使用对象
   ↓
main 结束
   ↓
node 析构
   ↓
自动 delete Node

不需要手动写:

delete node;

实际上,node 不是普通的 Node*,不能这样删除。


9.3 课件中直接使用 new 的写法

课件先展示了:

std::unique_ptr<Node> node{new Node};

这在语法上可以工作。

但是现代 C++ 更推荐:

std::unique_ptr<Node> node =
    std::make_unique<Node>();

或者使用 auto

auto node = std::make_unique<Node>();

原因包括:

  • 不需要显式出现 new
  • 所有权从创建时就清晰;
  • 代码更短;
  • 更容易保持异常安全;
  • 不容易在复杂表达式中遗失裸指针;
  • std::make_shared 的写法一致。

因此,除非有特殊需求,优先使用:

std::make_unique<T>(构造参数...)

9.4 unique_ptr 为什么不能复制?

下面的代码不能编译:

auto first = std::make_unique<Node>(7);

std::unique_ptr<Node> second = first;

原因不是编译器故意限制我们,而是复制会破坏“唯一所有权”的含义。

假设复制被允许:

first ────→ Node
second ───→ Node

现在两个指针都认为自己负责销毁同一个对象。

如果 first 先析构:

first 析构
   ↓
Node 被 delete

此时:

second ───→ 已经被释放的内存

second 变成悬空指针。

如果 second 最后再次删除对象,还会发生重复释放。

因此,unique_ptr 禁止复制。


9.5 唯一所有权可以移动

虽然不能复制,但可以移动所有权:

#include <iostream>
#include <memory>
#include <utility>

class Node {
public:
    explicit Node(int value)
        : value_(value) {
        std::cout << "Node(" << value_
                  << ") constructed\n";
    }

    ~Node() {
        std::cout << "Node(" << value_
                  << ") destroyed\n";
    }

    int value() const noexcept {
        return value_;
    }

private:
    int value_;
};

int main() {
    std::unique_ptr<Node> first =
        std::make_unique<Node>(7);

    std::unique_ptr<Node> second =
        std::move(first);

    std::cout << std::boolalpha;
    std::cout << "first is empty: "
              << (first == nullptr) << '\n';

    std::cout << "second value: "
              << second->value() << '\n';
}

输出:

Node(7) constructed
first is empty: true
second value: 7
Node(7) destroyed

移动前:

first ───→ Node(7)
second     不存在

移动后:

first     为空
second ──→ Node(7)

Node 没有被复制,只是所有权从 first 转移到了 second


9.6 unique_ptr 如何解决异常泄漏?

把之前不安全的函数改写为:

#include <iostream>
#include <memory>
#include <stdexcept>

class Resource {
public:
    Resource() {
        std::cout << "Resource acquired.\n";
    }

    ~Resource() {
        std::cout << "Resource released.\n";
    }
};

void riskyOperation() {
    throw std::runtime_error("operation failed");
}

void safeFunction() {
    auto resource = std::make_unique<Resource>();

    riskyOperation();
}

int main() {
    try {
        safeFunction();
    } catch (const std::exception& error) {
        std::cout << "Caught: "
                  << error.what() << '\n';
    }
}

输出:

Resource acquired.
Resource released.
Caught: operation failed

异常发生时:

riskyOperation() 抛出
   ↓
safeFunction() 开始栈展开
   ↓
resource 析构
   ↓
Resource 自动删除
   ↓
异常到达 catch

这就是动态内存上的 RAII。


9.7 unique_ptr 的常见接口

访问对象:

pointer->member();

解引用:

T& object = *pointer;

判断是否为空:

if (pointer) {
}

主动释放当前对象并变为空:

pointer.reset();

获取内部的裸指针,但不转移所有权:

T* raw = pointer.get();

通过 get() 获得的地址通常只是借用。

不能执行:

delete raw;

否则智能指针稍后还会再次删除它。

release() 会放弃所有权并返回裸指针:

T* raw = pointer.release();

使用后,调用者必须自己负责 delete。这会重新带回手动管理,因此通常不应随意使用。


练习:哪些 unique_ptr 操作能够编译?

auto a = std::make_unique<int>(10);

判断下面每一行是否合法:

auto b = a;
auto c = std::move(a);
std::cout << *c << '\n';
std::cout << *a << '\n';

答案与解释

下面的复制不合法:

auto b = a;

因为 unique_ptr 的复制构造函数被禁用。

下面的移动合法:

auto c = std::move(a);

移动后,c 获得对象所有权,a 变为空。

因此:

std::cout << *c << '\n';

合法并输出:

10

但是:

std::cout << *a << '\n';

虽然从纯语法角度看表达式可以通过编译,但运行时对空智能指针解引用属于错误行为,会导致未定义行为。

应当先判断:

if (a) {
    std::cout << *a << '\n';
}

十、std::shared_ptr:多个对象共同拥有资源

10.1 为什么需要共享所有权?

有时一个动态对象确实需要被多个部分共同拥有。

例如:

窗口系统 ───┐
缓存系统 ───┼──→ 同一个大型资源对象
任务系统 ───┘

只要其中任意一个所有者仍在使用资源,资源就不能被销毁。

这时不能简单使用可复制的裸指针,因为我们不知道最后由谁执行 delete

std::shared_ptr<T> 表示共享所有权。


10.2 shared_ptr 的引用计数

每个共享对象通常会关联一个控制块(control block)。

概念结构如下:

shared_ptr A ─────┐
                  │
shared_ptr B ─────┼────→ T 对象
                  │
shared_ptr C ─────┘

控制块
┌─────────────────────┐
│ 强引用计数:3        │
│ 弱引用相关信息       │
│ 删除器               │
│ 可能的分配器信息     │
└─────────────────────┘

复制 shared_ptr 时,强引用计数增加。

销毁一个 shared_ptr 时,强引用计数减少。

当强引用计数变成零时,目标对象被销毁。


10.3 一个完整的引用计数示例

#include <iostream>
#include <memory>

class Node {
public:
    explicit Node(int value)
        : value_(value) {
        std::cout << "Node constructed.\n";
    }

    ~Node() {
        std::cout << "Node destroyed.\n";
    }

private:
    int value_;
};

int main() {
    auto first = std::make_shared<Node>(42);

    std::cout << "count: "
              << first.use_count() << '\n';

    {
        std::shared_ptr<Node> second = first;

        std::cout << "count: "
                  << first.use_count() << '\n';
    }

    std::cout << "count: "
              << first.use_count() << '\n';
}

输出:

Node constructed.
count: 1
count: 2
count: 1
Node destroyed.

状态变化:

创建 first

first ──→ Node
强引用计数 = 1

进入内部作用域并复制:

first  ──┐
         ├──→ Node
second ──┘

强引用计数 = 2

内部作用域结束:

second 析构

first ──→ Node
强引用计数 = 1

main() 结束:

first 析构
强引用计数 = 0
Node 被销毁

10.4 对课件表述的准确修正

课件中有类似说法:

shared_ptr 可以复制,并在底层内存离开作用域时析构。

更准确的说法是:

当最后一个拥有该对象的 shared_ptr 被销毁或放弃所有权时,目标对象被销毁。

动态对象本身没有普通局部变量意义上的“离开作用域”。

真正离开作用域的是一个个 shared_ptr 管理对象。


10.5 使用 std::make_shared

课件展示了直接使用 new

std::shared_ptr<T> pointer{new T};

现代 C++ 通常应写成:

auto pointer = std::make_shared<T>();

带构造参数时:

auto pointer =
    std::make_shared<Node>(42);

make_shared 通常可以更高效地安排目标对象和控制块的内存,并且避免把刚刚创建的裸指针暴露在普通代码中。


10.6 不要从同一个裸指针创建两个独立的 shared_ptr

下面的代码非常危险:

Node* raw = new Node(42);

std::shared_ptr<Node> first{raw};
std::shared_ptr<Node> second{raw};

firstsecond 不知道彼此的存在。

它们会创建两个不同的控制块:

first  → 控制块 A → raw
second → 控制块 B → raw

两个控制块都认为自己最终应该删除 raw

结果会发生重复释放。

正确方式是先创建一个 shared_ptr,再复制它:

auto first = std::make_shared<Node>(42);
auto second = first;

现在二者共享同一个控制块。


10.7 什么时候不该使用 shared_ptr

shared_ptr 不是“比 unique_ptr 更强的高级版本”。

共享所有权会带来:

  • 控制块;
  • 引用计数更新;
  • 更复杂的生命周期;
  • 循环引用风险;
  • 更难回答“谁真正应该拥有对象”。

默认选择应当是:

只有一个所有者
    ↓
unique_ptr

只有当程序语义确实需要多个独立所有者时,才使用:

多个所有者共同延长生命周期
    ↓
shared_ptr

use_count() 适合学习和调试,但通常不应成为复杂业务逻辑的核心判断依据。


练习:预测引用计数

auto a = std::make_shared<int>(5);
std::shared_ptr<int> b = a;

{
    std::shared_ptr<int> c = b;
    std::cout << a.use_count() << '\n';
}

std::cout << a.use_count() << '\n';
b.reset();
std::cout << a.use_count() << '\n';

答案与解释

输出:

3
2
1

创建 a 后:

强引用计数 = 1

复制给 b

强引用计数 = 2

内部作用域创建 c

强引用计数 = 3

所以第一次输出 3

内部作用域结束,c 析构:

强引用计数 = 2

所以第二次输出 2

执行:

b.reset();

b 放弃所有权:

强引用计数 = 1

所以第三次输出 1


十一、std::weak_ptr:观察对象,但不拥有对象

11.1 为什么还需要第三种指针?

假设某个组件希望查看一个共享对象,但不希望因为自己的存在而延长对象生命周期。

例如:

资源管理器真正拥有对象
监控界面只想观察对象

如果监控界面也保存 shared_ptr,它就成为了所有者。

即使资源管理器已经不再需要对象,只要监控界面的 shared_ptr 还存在,对象就不会销毁。

这时可以使用:

std::weak_ptr<T>

weak_ptr 表示:

我知道这个共享对象,但我不参与拥有它。


11.2 weak_ptr 不增加强引用计数

#include <iostream>
#include <memory>

int main() {
    auto owner = std::make_shared<int>(42);

    std::weak_ptr<int> observer = owner;

    std::cout << owner.use_count() << '\n';
}

输出:

1

虽然 observer 指向同一共享对象,但它不增加强引用计数。


11.3 为什么不能直接解引用 weak_ptr

观察对象可能已经被销毁。

因此,不能直接写:

*observer

也不能直接写:

observer->member()

必须先调用:

observer.lock()

lock() 会尝试创建一个临时的 shared_ptr

如果对象仍然存在,得到有效的 shared_ptr

如果对象已经销毁,得到空的 shared_ptr

完整示例:

#include <iostream>
#include <memory>

int main() {
    std::weak_ptr<int> observer;

    {
        auto owner = std::make_shared<int>(42);
        observer = owner;

        if (auto locked = observer.lock()) {
            std::cout << "Value: "
                      << *locked << '\n';
        }
    }

    if (auto locked = observer.lock()) {
        std::cout << *locked << '\n';
    } else {
        std::cout << "Object no longer exists.\n";
    }
}

输出:

Value: 42
Object no longer exists.

在内部作用域中:

owner 存在
强引用计数 = 1
observer 只观察

内部作用域结束后:

owner 析构
强引用计数 = 0
int 对象销毁
observer 仍存在,但已过期

也可以检查:

observer.expired()

但通常直接使用 lock() 更安全,因为检查和获取在同一个操作中完成。


十二、循环引用:shared_ptr 无法独自解决的问题

12.1 两个对象互相拥有

考虑类 A 和类 B

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> ptr_to_b;

    ~A() {
        std::cout << "A destroyed\n";
    }
};

class B {
public:
    std::shared_ptr<A> ptr_to_a;

    ~B() {
        std::cout << "B destroyed\n";
    }
};

int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();

    a->ptr_to_b = b;
    b->ptr_to_a = a;
}

程序结束时,没有析构输出。

为什么?

建立关系后:

局部变量 a ───────────→ A
                         │
                         │ shared_ptr
                         ▼
局部变量 b ───────────→ B
                         │
                         │ shared_ptr
                         └──────────→ A

更清楚地画:

a ──→ A ──shared──→ B
      ↑              │
      └──shared──────┘

此时:

A 的强引用计数 = 2
    局部变量 a
    B::ptr_to_a

B 的强引用计数 = 2
    局部变量 b
    A::ptr_to_b

main() 结束时,局部变量按相反顺序销毁。

销毁局部变量 b 后:

B 的强引用计数从 2 变成 1

因为 A 仍然拥有 B

销毁局部变量 a 后:

A 的强引用计数从 2 变成 1

因为 B 仍然拥有 A

最终:

A 等待 B 释放自己
B 等待 A 释放自己

引用计数永远无法变成零。

这就是循环引用(circular reference)。


12.2 用 weak_ptr 打破所有权环

如果设计上:

  • A 真正拥有 B
  • B 只是需要找到 A
  • B 不应该负责延长 A 的生命周期;

那么可以把 B 中的指针改为 weak_ptr

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> ptr_to_b;

    ~A() {
        std::cout << "A destroyed\n";
    }
};

class B {
public:
    std::weak_ptr<A> ptr_to_a;

    ~B() {
        std::cout << "B destroyed\n";
    }

    void visitA() {
        if (auto a = ptr_to_a.lock()) {
            std::cout << "A is still alive.\n";
        } else {
            std::cout << "A no longer exists.\n";
        }
    }
};

int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();

    a->ptr_to_b = b;
    b->ptr_to_a = a;

    b->visitA();
}

输出:

A is still alive.
A destroyed
B destroyed

现在关系为:

A ──shared──→ B
↑             │
└────weak─────┘

BA 的边不增加强引用计数。

局部变量销毁时:

  1. 局部 b 释放一份对 B 的所有权;
  2. 局部 a 释放最后一份对 A 的所有权;
  3. A 析构;
  4. A::ptr_to_b 析构,释放最后一份对 B 的所有权;
  5. B 析构。

循环被打破。


12.3 所有权图的直觉

可以把对象关系画成一张图。

shared_ptr 是强所有权边:

A ──strong──→ B

只要强边仍然存在,目标对象就必须继续存活。

weak_ptr 是观察边:

B ──weak──→ A

观察边允许目标对象在没有强所有者时销毁。

理想情况下,强所有权关系不应该形成环。

如果业务关系天然存在环,就需要选择至少一条不代表所有权的边,将其改为弱引用。


练习:应该把哪条边改成 weak_ptr

有两个对象:

Company
  └── 拥有 Employee

Employee
  └── 需要知道自己属于哪家 Company

目前两个方向都使用 shared_ptr

class Company {
public:
    std::shared_ptr<Employee> employee;
};

class Employee {
public:
    std::shared_ptr<Company> company;
};

应当怎样修改?


答案与解释

通常可以让 Company 拥有 Employee

class Company {
public:
    std::shared_ptr<Employee> employee;
};

Employee 只观察所属公司:

class Employee {
public:
    std::weak_ptr<Company> company;
};

关系变为:

Company ──shared──→ Employee
Company ←──weak──── Employee

公司决定员工对象的主要生命周期,员工不应仅因为保存了“所属公司”信息就阻止公司销毁。

实际项目中也可能采用其他所有权设计。关键不是机械规定哪个方向必须弱,而是先回答:

谁真正负责让谁继续存活?


十三、四种指针角色的完整对比

裸指针、unique_ptrshared_ptrweak_ptr 并不是互相替代的四个等级。

它们表达的是不同关系。

13.1 裸指针和引用:通常表示借用

void printPet(const Pet& pet);

这里的函数只借用 pet

它不拥有对象,不负责删除对象。

裸指针也可以表示可空借用:

void inspectPet(const Pet* pet);

使用者必须保证目标对象在借用期间仍然存在。


13.2 unique_ptr:独占所有权

只有一个对象负责目标生命周期

适用于:

  • 树节点拥有子节点;
  • 一个管理器独占某项资源;
  • 工厂函数返回新对象;
  • 对象不能被复制,但可以转移所有权。

13.3 shared_ptr:共享所有权

多个独立对象都需要延长目标生命周期

适用于:

  • 无法确定最后由哪个组件停止使用;
  • 异步任务共享同一对象;
  • 某些图结构中的共同所有节点。

不能只因为“以后可能需要复制”就使用 shared_ptr


13.4 weak_ptr:观察共享对象

需要找到对象
但不负责让对象继续存活

适用于:

  • 缓存观察者;
  • 父子对象的反向引用;
  • 打破 shared_ptr 循环;
  • 事件订阅关系中的非拥有观察。

类型 是否拥有对象 能否复制 能否移动 目标何时销毁
裸指针 T* 通常不明确 可以 可以 由其他所有者决定
unique_ptr<T> 独占拥有 不可以 可以 唯一所有者销毁或重置时
shared_ptr<T> 共享拥有 可以 可以 最后一个强所有者消失时
weak_ptr<T> 不拥有 可以 可以 不影响目标销毁时间

选择时可以使用下面的顺序:

这个变量需要拥有对象吗?
  │
  ├── 不需要
  │     └── 引用、裸指针,或 weak_ptr
  │
  └── 需要
        │
        ├── 是否只有一个所有者?
        │       └── unique_ptr
        │
        └── 是否确实有多个共同所有者?
                └── shared_ptr

十四、智能指针常见错误

14.1 默认使用 shared_ptr

错误直觉:

shared_ptr 可以复制,所以最省事。

问题是它模糊了所有权,还可能产生循环引用。

更好的默认选择是:

std::unique_ptr<T>

只有确定需要共享所有权时再换成 shared_ptr


14.2 对 unique_ptr 使用 delete

错误:

auto pointer = std::make_unique<int>(10);
delete pointer.get();

这会提前删除对象。

稍后 pointer 析构时,还会再次尝试删除同一地址。


14.3 使用移动后的 unique_ptr

auto first = std::make_unique<int>(10);
auto second = std::move(first);

std::cout << *first;

移动后 first 通常为空,不能解引用。

应当使用:

std::cout << *second;

14.4 把 weak_ptr 当作普通指针

错误:

std::weak_ptr<Node> observer = owner;
observer->work();

weak_ptr 没有这种直接访问方式。

正确:

if (auto node = observer.lock()) {
    node->work();
}

14.5 让 shared_ptr 形成环

每个对象的引用计数都大于零,不代表程序中仍然有真正可访问它们的根所有者。

需要分析所有权图,并用 weak_ptr 表示非拥有关系。


十五、从一个源文件走向真正的 C++ 项目

到目前为止,示例通常只有一个文件:

main.cpp

编译命令是:

g++ -std=c++20 main.cpp -o main

其中:

g++                编译器驱动命令
-std=c++20         使用 C++20 标准
main.cpp           源文件
-o main            将最终可执行文件命名为 main

运行:

./main

这里的:

./

表示当前目录。


15.1 源代码是怎样变成可执行文件的?

课件使用了下面的简化流程:

C++ 源代码
    ↓
编译器
    ↓
机器能够运行的程序

对于入门理解,这个流程足够。

更完整一些,可以写成:

源文件和头文件
      ↓
预处理
      ↓
翻译单元
      ↓
编译与汇编
      ↓
目标文件 .o
      ↓
链接
      ↓
可执行文件

假设项目结构为:

main.cpp
Pet.cpp
Pet.h

可以分开执行:

g++ -std=c++20 -c main.cpp -o main.o
g++ -std=c++20 -c Pet.cpp -o Pet.o
g++ main.o Pet.o -o pet_app

前两条命令只编译,不链接:

main.cpp → main.o
Pet.cpp  → Pet.o

第三条命令链接:

main.o + Pet.o → pet_app

也可以让 g++ 一次完成:

g++ -std=c++20 main.cpp Pet.cpp -o pet_app

15.2 什么是翻译单元?

预处理器处理完 #include 后,一个 .cpp 文件及其展开的头文件内容形成一个翻译单元(translation unit)。

例如:

#include "Pet.h"

不是把 Pet.h 独立编译成程序。

它大致表示:

把 Pet.h 中的声明提供给当前 .cpp

每个 .cpp 通常独立编译成一个 .o 文件,最后由链接器组合。


15.3 编译错误和链接错误不同

编译错误

编译器无法理解某个翻译单元:

int value = ;

或者:

pet.unknownFunction();

链接错误

声明存在,所以每个 .cpp 都能编译,但最终找不到定义:

int calculate();

调用了:

calculate();

却没有任何 .cpp 提供:

int calculate() {
    return 42;
}

常见错误信息包含:

undefined reference

这通常意味着:

  • 忘记实现函数;
  • 忘记把某个 .cpp 加入编译命令;
  • 声明与定义的参数类型不一致;
  • 命名空间不一致。

十六、为什么不能永远手写编译命令?

对于三个文件,可以写:

g++ -std=c++20 main.cpp Pet.cpp -o pet_app

但大型项目可能有成百上千个源文件。

课件用 TensorFlow Core 中大量 C++ 源文件作为规模示例。此时手写一条包含所有文件的命令会非常痛苦。

更重要的是,如果只修改了一个文件,我们通常只希望:

重新编译发生变化的部分
    +
重新链接

而不是重新编译整个项目。

构建系统(build system)用于描述:

  • 项目有哪些源文件;
  • 哪些文件依赖哪些头文件;
  • 使用哪个编译器;
  • 使用哪些编译选项;
  • 如何生成可执行文件;
  • 哪些目标需要重新构建。

十七、Make 和 Makefile

make 是一个读取构建规则并执行命令的工具。

规则通常保存在:

Makefile

基本结构是:

目标: 依赖
	生成目标所需的命令

注意:

命令行开头通常必须是真正的 Tab 字符,而不是若干空格。


17.1 课件中的简化 Makefile

课件展示了类似下面的文件:

CXX = g++
CXXFLAGS = -std=c++20

SRCS = $(wildcard *.cpp)
TARGET = main

all:
	$(CXX) $(CXXFLAGS) $(SRCS) -o $(TARGET)

clean:
	rm -f $(TARGET)

含义为:

CXX = g++

定义编译器变量。

CXXFLAGS = -std=c++20

定义编译选项。

SRCS = $(wildcard *.cpp)

找到当前目录下所有 .cpp 文件。

TARGET = main

最终可执行文件名为 main

运行:

make

会执行:

g++ -std=c++20 所有cpp文件 -o main

运行:

make clean

会删除:

main

17.2 这个简化版本不能充分利用增量构建

课件同时强调 Make 能追踪发生变化的文件。

这个能力是 Make 的重要特性,但上面的简化 Makefile 并没有真正把每个 .cpp 分别编译成 .o 文件。

而且:

all:

没有对应的真实输出文件,也没有列出依赖,因此通常每次运行都会再次执行整条编译命令。

它适合作为最初的 Makefile 演示,但没有完整体现增量编译。


17.3 一个能够增量编译的 Makefile

假设项目包含:

main.cpp
Pet.cpp
Pet.h

可以写:

CXX := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Wpedantic

TARGET := pet_app
OBJS := main.o Pet.o

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CXX) $(OBJS) -o $(TARGET)

main.o: main.cpp Pet.h
	$(CXX) $(CXXFLAGS) -c main.cpp -o main.o

Pet.o: Pet.cpp Pet.h
	$(CXX) $(CXXFLAGS) -c Pet.cpp -o Pet.o

clean:
	rm -f $(TARGET) $(OBJS)

构建关系为:

main.cpp + Pet.h
        ↓
      main.o

Pet.cpp + Pet.h
        ↓
      Pet.o

main.o + Pet.o
        ↓
      pet_app

第一次运行:

make

会执行:

编译 main.cpp
编译 Pet.cpp
链接 pet_app

如果只修改 Pet.cpp

Pet.cpp 比 Pet.o 新

Make 会:

重新生成 Pet.o
重新链接 pet_app

但不需要重新编译 main.cpp

如果修改 Pet.h,由于两个 .o 都依赖它:

重新生成 main.o
重新生成 Pet.o
重新链接 pet_app

17.4 Makefile 中的目标与依赖

这条规则:

Pet.o: Pet.cpp Pet.h
	$(CXX) $(CXXFLAGS) -c Pet.cpp -o Pet.o

表示:

目标:Pet.o
依赖:Pet.cpp 和 Pet.h

只要任意依赖比目标新,或者目标不存在,命令就需要执行。

这就是 Make 增量构建的核心直觉。


练习:修改文件后,Make 会重建什么?

使用上面的 Makefile。

情况一:只修改 main.cpp

情况二:只修改 Pet.h

情况三:只修改已经生成的可执行文件 pet_app,源文件都没变。


答案与解释

情况一:

重新生成 main.o
重新链接 pet_app

Pet.o 不需要重新生成。

情况二:

重新生成 main.o
重新生成 Pet.o
重新链接 pet_app

因为两个目标文件都声明依赖 Pet.h

情况三:

源文件和目标文件都没有变旧,通常不需要重新编译或链接。

不过,实际判断取决于文件时间戳。如果手动修改可执行文件使其时间更新,Make 可能认为它比依赖更新,从而不重建它。


十八、CMake:生成构建系统

Makefile 能够精确控制构建,但大型跨平台项目直接维护 Makefile 会逐渐复杂。

CMake 是构建系统生成器(build system generator)。

它的关系可以理解为:

CMakeLists.txt
       ↓
     CMake
       ↓
Makefile、Ninja 文件或其他构建系统文件
       ↓
真正执行编译

因此,CMake 通常不是直接代替编译器。

它帮助生成适合当前平台和构建工具的规则。


18.1 课件中的 CMakeLists.txt

课件展示了:

cmake_minimum_required(VERSION 3.10)

project(cs106l_classes)

set(CMAKE_CXX_STANDARD 20)

file(GLOB SRC_FILES "*.cpp")

add_executable(main ${SRC_FILES})

逐项解释如下。

cmake_minimum_required(VERSION 3.10)

要求 CMake 版本至少为 3.10。

project(cs106l_classes)

定义项目名称。

set(CMAKE_CXX_STANDARD 20)

要求使用 C++20 语言标准。

这里需要修正课件旁注中的一个说法:

这条命令不是把编译器设置成某个编译器,而是设置 C++ 语言标准版本。

编译器可能仍然是:

  • GCC;
  • Clang;
  • MSVC;
  • 其他受支持编译器。
file(GLOB SRC_FILES "*.cpp")

搜索当前目录中的 .cpp 文件并存入 SRC_FILES

add_executable(main ${SRC_FILES})

使用这些源文件创建名为 main 的可执行目标。


18.2 file(GLOB) 的便利与限制

file(GLOB ...) 对教学小项目很方便:

file(GLOB SRC_FILES "*.cpp")

但大型项目通常更推荐明确列出源文件:

add_executable(main
    main.cpp
    Pet.cpp
)

原因是:

  • 项目结构更明确;
  • 新增或删除源文件时更容易审查;
  • 某些情况下新增文件后,CMake 不一定自动重新配置;
  • 不会意外把测试文件或临时文件加入正式目标。

因此,课件写法能够工作,但不是所有项目中的最佳实践。


18.3 一个更明确的 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

project(pet_app LANGUAGES CXX)

add_executable(pet_app
    main.cpp
    Pet.cpp
)

target_compile_features(pet_app PRIVATE cxx_std_20)

target_compile_options(pet_app PRIVATE
    -Wall
    -Wextra
    -Wpedantic
)

这里:

project(pet_app LANGUAGES CXX)

说明项目使用 C++。

add_executable(pet_app ...)

创建可执行目标。

target_compile_features(
    pet_app
    PRIVATE
    cxx_std_20
)

要求 pet_app 使用 C++20。

PRIVATE 表示这个要求用于当前目标自身。


18.4 使用 CMake 的现代命令

假设项目根目录中有:

CMakeLists.txt
main.cpp
Pet.cpp
Pet.h

可以直接执行:

cmake -S . -B build

含义为:

-S .       源代码目录是当前目录
-B build   构建文件放入 build 目录

然后构建:

cmake --build build

运行:

./build/pet_app

项目结构变为:

project/
├── CMakeLists.txt
├── main.cpp
├── Pet.cpp
├── Pet.h
└── build/
    ├── CMakeCache.txt
    ├── 构建系统文件
    ├── 目标文件
    └── pet_app

这种方式称为目录外构建(out-of-source build)。

生成文件集中在 build/ 中,不会污染源代码目录。

清理时可以直接删除:

rm -rf build

18.5 课件中的传统操作流程

课件给出的流程为:

mkdir build
cd build
cmake ..
make
./main

它同样表达了目录外构建。

其中:

cmake ..

表示:

使用上一级目录中的 CMakeLists.txt

如果 CMake 生成的是 Makefile,接着运行:

make

现代写法:

cmake --build build

不依赖底层一定是 Make,也适用于 CMake 生成的其他构建系统。


18.6 Make 与 CMake 的关系

g++
    负责编译和链接

Makefile
    描述目标、依赖和构建命令

make
    读取 Makefile 并执行需要的命令

CMakeLists.txt
    用更高层方式描述项目

CMake
    根据 CMakeLists.txt 生成实际构建系统

可以画成:

CMakeLists.txt
      ↓
    CMake
      ↓
   Makefile
      ↓
     make
      ↓
      g++
      ↓
  可执行文件

不过 CMake 也可以生成 Ninja 等其他构建系统,因此 Makefile 只是其中一种后端。


十九、综合练习:构建一个异常安全的宠物程序

现在把本节内容串起来。

目标是完成一个多文件程序:

pet_project/
├── Pet.h
├── Pet.cpp
├── main.cpp
├── pet.txt
├── Makefile
└── CMakeLists.txt

程序需要:

  1. 从文件中读取宠物类型、名字和姓氏;
  2. 使用 std::ifstream 自动管理文件;
  3. 使用 std::unique_ptr<Pet> 表示唯一所有权;
  4. 文件读取失败时抛出异常;
  5. main() 中捕获并报告异常;
  6. 支持直接使用 g++、Make 和 CMake 构建。

输入文件格式为三行:

Dog
Fluffy
Wang

预期输出:

Fluffy Wang is paw-some!
Display name: Fluffy Wang

参考实现

Pet.h

#pragma once

#include <memory>
#include <string>

class Pet {
public:
    Pet(const std::string& type,
        const std::string& first_name,
        const std::string& last_name);

    const std::string& type() const noexcept;
    const std::string& firstName() const noexcept;
    const std::string& lastName() const noexcept;

private:
    std::string type_;
    std::string first_name_;
    std::string last_name_;
};

std::unique_ptr<Pet> loadPet(
    const std::string& filename
);

std::string displayName(const Pet& pet);

Pet.cpp

#include "Pet.h"

#include <fstream>
#include <iostream>
#include <stdexcept>

Pet::Pet(const std::string& type,
         const std::string& first_name,
         const std::string& last_name)
    : type_(type),
      first_name_(first_name),
      last_name_(last_name) {}

const std::string& Pet::type() const noexcept {
    return type_;
}

const std::string& Pet::firstName() const noexcept {
    return first_name_;
}

const std::string& Pet::lastName() const noexcept {
    return last_name_;
}

std::unique_ptr<Pet> loadPet(
    const std::string& filename
) {
    std::ifstream input{filename};

    if (!input) {
        throw std::runtime_error(
            "cannot open file: " + filename
        );
    }

    std::string type;
    std::string first_name;
    std::string last_name;

    if (!std::getline(input, type) ||
        !std::getline(input, first_name) ||
        !std::getline(input, last_name)) {
        throw std::runtime_error(
            "pet file must contain at least three lines"
        );
    }

    return std::make_unique<Pet>(
        type,
        first_name,
        last_name
    );
}

std::string displayName(const Pet& pet) {
    if (pet.type() == "Dog" ||
        pet.firstName() == "Fluffy") {
        std::cout
            << pet.firstName()
            << ' '
            << pet.lastName()
            << " is paw-some!\n";
    }

    return pet.firstName()
        + " "
        + pet.lastName();
}

main.cpp

#include "Pet.h"

#include <exception>
#include <iostream>
#include <memory>
#include <string>

int main() {
    try {
        std::unique_ptr<Pet> pet =
            loadPet("pet.txt");

        const std::string name =
            displayName(*pet);

        std::cout
            << "Display name: "
            << name
            << '\n';
    } catch (const std::exception& error) {
        std::cerr
            << "Error: "
            << error.what()
            << '\n';

        return 1;
    }

    return 0;
}

pet.txt

Dog
Fluffy
Wang

直接编译

g++ -std=c++20 -Wall -Wextra -Wpedantic \
    main.cpp Pet.cpp -o pet_app

运行:

./pet_app

Makefile

CXX := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Wpedantic

TARGET := pet_app
OBJS := main.o Pet.o

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CXX) $(OBJS) -o $(TARGET)

main.o: main.cpp Pet.h
	$(CXX) $(CXXFLAGS) -c main.cpp -o main.o

Pet.o: Pet.cpp Pet.h
	$(CXX) $(CXXFLAGS) -c Pet.cpp -o Pet.o

clean:
	rm -f $(TARGET) $(OBJS)

构建:

make

运行:

./pet_app

清理:

make clean

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

project(pet_app LANGUAGES CXX)

add_executable(pet_app
    main.cpp
    Pet.cpp
)

target_compile_features(
    pet_app
    PRIVATE
    cxx_std_20
)

target_compile_options(
    pet_app
    PRIVATE
    -Wall
    -Wextra
    -Wpedantic
)

构建:

cmake -S . -B build
cmake --build build

运行:

./build/pet_app

二十、综合程序是怎样一步步执行的?

20.1 编译阶段

main.cpp 包含:

#include "Pet.h"

所以编译器知道:

  • Pet 类有哪些成员函数;
  • loadPet() 的参数和返回类型;
  • displayName() 的参数和返回类型。

Pet.cpp 提供这些函数的定义。

编译阶段分别生成:

main.cpp → main.o
Pet.cpp  → Pet.o

链接阶段组合:

main.o + Pet.o → pet_app

如果忘记把 Pet.cpp 加入构建,main.cpp 可能正常编译,但链接时会出现 undefined reference


20.2 进入 main()

执行:

std::unique_ptr<Pet> pet =
    loadPet("pet.txt");

此时先调用 loadPet()


20.3 创建文件流

std::ifstream input{filename};

创建局部对象 input,并尝试打开文件。

如果打开失败:

if (!input) {
    throw std::runtime_error(...);
}

异常离开 loadPet()

input 是已经构造成功的局部对象,因此会析构并清理自身状态。

异常到达 main() 的:

catch (const std::exception& error)

20.4 读取三行数据

如果文件成功打开:

std::string type;
std::string first_name;
std::string last_name;

三个局部字符串被创建。

然后执行:

std::getline(input, type)
std::getline(input, first_name)
std::getline(input, last_name)

读取成功后:

type       = "Dog"
first_name = "Fluffy"
last_name  = "Wang"

如果任何一行缺失,程序抛出异常。

栈展开时:

  • last_name 析构;
  • first_name 析构;
  • type 析构;
  • input 析构并关闭文件。

20.5 创建动态 Pet

return std::make_unique<Pet>(
    type,
    first_name,
    last_name
);

make_unique

  1. Pet 获取动态内存;
  2. 调用 Pet 构造函数;
  3. 创建拥有该对象的 unique_ptr
  4. 将所有权返回给调用者。

如果 Pet 构造过程中抛出异常,make_unique 会负责释放刚获取的内存,不会留下裸指针泄漏。


20.6 loadPet() 返回

返回时,局部文件流 input 析构,文件关闭。

动态 Pet 不会跟着销毁,因为其所有权已经转移给 main() 中的:

pet

状态为:

main 中的 pet
       │
       ▼
动态 Pet 对象
┌──────────────────┐
│ type_ = Dog      │
│ first = Fluffy   │
│ last = Wang      │
└──────────────────┘

20.7 调用 displayName()

const std::string name =
    displayName(*pet);

*pet 得到 Pet&

参数类型为:

const Pet& pet

因此不会复制整个 Pet 对象。

函数判断:

pet.type() == "Dog"

结果为真。

由于 || 使用短路求值:

pet.firstName() == "Fluffy"

不需要继续用于条件判断。

函数输出:

Fluffy Wang is paw-some!

然后构造并返回:

Fluffy Wang

20.8 正常离开作用域

main()try 块结束时:

pet

离开作用域。

unique_ptr 析构,并自动销毁它拥有的 Pet 对象。

不需要写:

delete pet;

20.9 如果 displayName() 抛出异常呢?

字符串拼接可能需要分配内存。

如果分配失败并抛出异常:

displayName() 异常退出
   ↓
main() 的 try 开始栈展开
   ↓
pet 析构
   ↓
动态 Pet 被删除
   ↓
异常到达 catch

这说明 RAII 不只保护“文件读取阶段”,还保护后续所有可能异常退出的路径。


二十一、综合练习的进一步思考

练习一:为什么 loadPet() 不返回裸指针?

可以写成:

Pet* loadPet(const std::string& filename);

并在里面:

return new Pet(...);

但这样调用者必须记住:

delete pet;

如果调用者忘记,或者在 delete 之前抛出异常,就会泄漏。

返回:

std::unique_ptr<Pet>

可以在类型层面表达:

调用者获得这个 Pet 的唯一所有权。


练习二:如果两个系统需要共同拥有同一个 Pet 呢?

假设:

  • 游戏场景系统需要使用宠物;
  • 用户界面系统也需要独立持有宠物;
  • 任意一方都可能比另一方活得更久。

这时可能需要:

std::shared_ptr<Pet>

工厂函数可以返回:

std::shared_ptr<Pet> loadPet(
    const std::string& filename
);

并使用:

return std::make_shared<Pet>(
    type,
    first_name,
    last_name
);

但只有在两个系统确实共同负责生命周期时才这样设计。


练习三:如果界面只观察宠物呢?

如果场景系统真正拥有宠物,而界面只是显示它的信息:

场景系统:shared_ptr<Pet>
界面系统:weak_ptr<Pet>

界面访问时:

if (auto pet = observed_pet.lock()) {
    // 使用 pet
}

这样界面不会阻止宠物正常销毁。


二十二、本节课的完整知识串联

本节课从一个很短的函数开始。

肉眼看到的控制流只有:

if 真
if 假
return

但函数调用、对象构造和运算符都可能抛出异常,于是实际存在大量隐藏出口。

异常可以让函数中途退出
          ↓
末尾的手动清理可能被跳过
          ↓
new/delete、lock/unlock、open/close 都不可靠

为了解决这个问题,我们利用了 C++ 的对象生命周期:

局部对象离开作用域
          ↓
析构函数自动执行

于是产生 RAII:

构造对象
   ↓
获取资源

析构对象
   ↓
释放资源

标准库中的许多类型都采用了这个设计:

文件资源
   ↓
std::ifstream

互斥锁
   ↓
std::lock_guard

动态内存
   ↓
智能指针

动态内存中的所有权又分成三种主要关系:

独占所有权
   ↓
unique_ptr

共享所有权
   ↓
shared_ptr

非拥有观察
   ↓
weak_ptr

当代码逐渐拆成多个文件后:

.cpp 独立编译为 .o
多个 .o 链接为可执行文件

手动输入长编译命令不再现实,于是使用:

Makefile + make

进一步为了以更高层、跨平台的方式描述项目,可以使用:

CMakeLists.txt + CMake

整条逻辑链为:

异常带来隐藏退出路径
        ↓
手动清理不可靠
        ↓
RAII 绑定资源与对象生命周期
        ↓
智能指针管理动态对象所有权
        ↓
多文件项目需要自动化构建
        ↓
Make 和 CMake 管理工程结构

二十三、初学者最容易混淆的结论

RAII 不是垃圾回收

RAII 依赖确定的对象生命周期。

对象离开作用域时,析构函数立即执行。

垃圾回收通常由运行时在某个不确定时间寻找不再使用的对象。


智能指针不是普通指针的语法糖

它们编码了所有权规则。

unique_ptr:唯一所有权
shared_ptr:共享所有权
weak_ptr:不拥有

使用智能指针不等于所有代码都应当出现智能指针

函数临时使用对象时,常常应当接受:

const T&

或者可空借用:

const T*

不应该为了“现代 C++”而到处把参数改成 shared_ptr


shared_ptr 的复制不是对象复制

auto second = first;

复制的是管理句柄。

目标对象仍然只有一个。

first  ─┐
        ├──→ 同一个对象
second ─┘

RAII 不会自动撤销数据修改

它保证资源被释放,不保证业务逻辑回滚。


CMake 不等于编译器

CMake 生成构建系统。

真正编译代码的仍然是 GCC、Clang、MSVC 等编译器。


设置 C++20 不等于选择编译器

set(CMAKE_CXX_STANDARD 20)

表示要求 C++20,而不是指定必须使用 g++


课件中的简单 Makefile不会自动实现最佳增量编译

要让 Make 精确追踪文件变化,需要描述目标文件和依赖关系。


二十四、本节知识如何连接到后续学习

这节课是课程中的最后一节必修讲座,课件接下来安排的是可选的 C++ Iceberg 内容。

“冰山”这个比喻很适合现在的阶段:

水面之上
├── 变量
├── 函数
├── 类
├── 容器
└── 基础模板

水面之下
├── 对象生命周期
├── 异常安全
├── 所有权
├── 并发同步
├── 编译与链接
├── 构建系统
├── ABI
└── 更复杂的语言规则

本节课已经让我们第一次看到这些内容彼此并不独立:

  • 移动语义让 unique_ptr 可以转移所有权;
  • 特殊成员函数决定资源管理类能否安全复制;
  • 类型安全帮助表达“拥有、共享、观察”;
  • RAII 让异常路径仍能正确清理;
  • 构建系统把许多独立翻译单元组织成完整程序。

接下来学习更复杂的 C++ 时,可以始终用三个问题检查设计:

这个对象拥有谁?
谁负责释放资源?
异常发生时,对象仍然处于什么状态?

当这三个问题能够从类型和作用域中直接得到答案时,代码才真正开始具有现代 C++ 的结构。