Lecture 16 (Week 8 - Thursday): RAII、智能指针与构建项目 (RAII, Smart Pointers & Building C++ Projects)

目录 · ← l15 · l17 →

Lecture 16 (Week 8 - Thursday): RAII、智能指针与构建项目 (RAII, Smart Pointers & Building C++ Projects)

概述

本讲分三部分。首先从异常(try/catch/throw)入手:异常会带来大量”代码路径”,一旦在 newdelete 之间抛出异常,内存就会泄漏。答案是 RAII(Resource Acquisition Is Initialization,资源获取即初始化)——资源在构造函数中获取、在析构函数中释放,无论是否抛异常,析构函数都保证执行。其次介绍三大智能指针 std::unique_ptr(唯一所有权、不可拷贝)、std::shared_ptr(引用计数、可共享)、std::weak_ptr(不增加引用计数、打破循环引用),以及工厂函数 std::make_unique/std::make_shared。最后是构建工具链:编译命令、Makefile 与 CMake/CMakeLists.txt,让多文件项目不再靠手敲命令。

核心特性与语法详解

1. 异常:try / catch / throw

  • 定义与目的:异常是”错误发生时”的处理机制——错误被 throw 抛出,代码用 try/catch 捕获并继续运行,而不是直接终止。结构类似 if/else if/else 链。
  • 核心语法
    try {
        // 可能抛异常的代码
    } catch (const std::runtime_error& e1) {   // 类似 "if"
        // 处理第一种错误
    } catch (const std::exception& e2) {       // 类似 "else if"
        // 处理第二种错误
    } catch (...) {                            // 类似 "else"
        // catch-all:兜底捕获一切
    }
    
  • 设计意图与最佳实践:异常类型应尽量具体(catch (const std::exception&)catch (...) 信息更多);捕获后要么处理、要么重新抛出。异常的代价是控制流不再线性——幻灯片统计一行 return Pet(...) 的代码有至少 23 条可能路径(拷贝构造、临时 string 构造、重载运算符、返回字符串拷贝都可能抛)。这意味着”new 之后、delete 之前”任何一步抛异常,delete 就可能被跳过 → 内存泄漏。

2. RAII:Resource Acquisition Is Initialization

  • 定义与目的:Bjarne Stroustrup 提出的思想:所有资源应在构造函数中获取,在析构函数中释放。这样资源生命周期与对象作用域绑定:对象创建即可用,对象销毁必然释放——无论中途是否抛异常(栈展开时析构函数必被调用)。
  • 核心语法(反例 vs 正例):
    // 反例:ifstream 在代码中打开/关闭,异常时可能漏关
    void bad() {
        std::ifstream file;
        file.open("data.txt");   // 资源在"代码中"获取
        // ... 若这里抛异常,file 永远不会 close
        file.close();            // 手工释放
    }
    // 正例:RAII 类在构造时打开、析构时关闭
    void good() {
        std::ifstream file("data.txt");  // 构造 = 获取资源
        // ... 无论发生什么,file 离开作用域时析构自动 close
    }
    
  • 设计意图与最佳实践:RAII 消除”半有效状态”:对象要么不存在,要么完全可用;资源要么没获取,要么保证释放。std::lock_guard 是 RAII 的典范——构造时获取锁,析构(出作用域)时自动释放,锁永远不会因为异常而”忘记解锁”。智能指针就是把 RAII 应用到内存管理的产物。

3. std::unique_ptr<T>:唯一所有权

  • 定义与目的:独占管理一块堆内存的”包装指针”:构造(或 make_unique)时拥有资源,析构时自动 delete不可拷贝(拷贝构造/拷贝赋值被 = delete),只能移动(所有权转移)。这正是 Lecture 13/14 的 SMF 与移动语义的实际应用。
  • 核心语法
    #include <memory>
    std::unique_ptr<T> p = std::make_unique<T>(args...);  // 首选!
    auto q = std::move(p);        // 移动转移所有权,p 变空
    T* raw = p.get();             // 取裸指针(不转移所有权)
    p->member;  (*p).member;      // 像裸指针一样用
    
  • 设计意图与最佳实践:为什么不可拷贝?如果两个 unique_ptr 指向同一内存,先析构者 delete 后,另一个就成了悬垂指针(幻灯片场景)。移动则始终保证”同一时刻只有唯一 owner”。std::make_unique<T> 而不是 new(后面详解)。

4. std::shared_ptr<T>:引用计数共享所有权

  • 定义与目的:多个 shared_ptr 可共享同一块内存,内部维护引用计数(reference count):每拷贝一次计数 +1,每析构一个 -1;计数归零时才真正 delete 底层资源。解决”unique_ptr 不能拷贝”的共享需求。
  • 核心语法
    std::shared_ptr<T> a = std::make_shared<T>(args...);
    std::shared_ptr<T> b = a;    // 拷贝:计数 +1(现在 2)
    b.reset();                   // b 释放:计数 -1(现在 1)
    a.reset();                   // 计数归零 → 真正 delete
    long n = a.use_count();      // 当前引用计数
    
  • 设计意图与最佳实践:计数增减是原子的(线程安全);但循环引用是致命陷阱:A 持有指向 B 的 shared_ptr、B 持有指向 A 的 shared_ptr 时,两者计数永远到不了 0,内存永不释放——这就是 weak_ptr 要解决的。

5. std::weak_ptr<T>:打破循环引用

  • 定义与目的:观察 shared_ptr 管理的对象但不增加引用计数(不拥有所有权)。典型用途:打破 A↔B 循环引用。因为不计数,它指向的对象可能随时被销毁,使用时需 lock() 升级为 shared_ptr 再访问。
  • 核心语法
    std::weak_ptr<T> w = a;          // 从 shared_ptr 构造,计数不变
    if (auto s = w.lock()) {         // lock() 返回 shared_ptr;对象已死则返回空
        s->use();                    // 安全访问
    } else {
        // 对象已被销毁
    }
    
  • 设计意图与最佳实践weak_ptr 永远不能直接解引用(没有 operator*/operator->)——必须先 lock(),因为对象可能已不存在。它不是”第二所有权”,而是”不拥有所有权的观察者”。

6. std::make_unique / std::make_shared

  • 定义与目的:智能指针的推荐工厂函数,替代裸 new
  • 核心语法
    auto p = std::make_unique<T>(ctorArgs...);   // C++14 起
    auto s = std::make_shared<T>(ctorArgs...);
    // 反面教材(避免):
    std::shared_ptr<T> s2(new T(...));           // 两步:先 new 再包装
    
  • 设计意图与最佳实践:两个理由——(1)异常安全f(std::shared_ptr<A>(new A), g()) 中若 g() 先于包装抛异常,new A 出来的裸指针就泄漏了;make_shared 一步到位无此窗口。(2)效率make_shared 把对象与计数块放进同一次分配;make_unique 让代码统一(”用了 make_unique 就也用 make_shared”),避免 new 与智能指针混用。

7. 构建项目:编译命令、Makefile、CMake

  • 定义与目的:C++ 源码要先翻译成机器码(编译器)才能运行;多文件项目需要把多个 .cpp 一起编译。手工敲命令不现实,于是有了 make(构建系统,读 Makefile)CMake(构建系统生成器,读 CMakeLists.txt,生成 Makefile) 两层工具。
  • 核心语法
    # 编译命令:g++ -std=<标准> <源文件...> -o <可执行文件名>
    g++ -std=c++20 main.cpp user.cpp -o main
    ./main
    
    # Makefile
    CXX = g++                  # 编译器
    CXXFLAGS = -std=c++20      # 编译选项
    SRCS = $(wildcard *.cpp)   # 自动收集所有 .cpp
    TARGET = main
    all:
    	$(CXX) $(CXXFLAGS) $(SRCS) -o $(TARGET)
    clean:
    	rm -f $(TARGET)
    
    # CMakeLists.txt
    cmake_minimum_required(VERSION 3.10)
    project(cs106l_classes)
    set(CMAKE_CXX_STANDARD 20)        # 指定 C++20
    file(GLOB SRC_FILES "*.cpp")      # 通配收集源文件
    add_executable(main ${SRC_FILES}) # 生成可执行文件 main
    
  • 设计意图与最佳实践:make 的优点是增量编译(只重编改动过的文件)和集中管理编译参数;CMake 在 Makefile 之上再加一层抽象(跨平台、可生成不同构建系统)。CMake 标准流程:
    mkdir build && cd build
    cmake ..      # 用根目录的 CMakeLists.txt 生成 Makefile
    make          # 编译
    ./main        # 运行
    

代码示例与逐步解说(核心)

示例 1:异常基础——throw 与 catch(C++11)

// C++11
#include <iostream>
#include <stdexcept>

double divide(double a, double b) {
    if (b == 0) throw std::runtime_error("division by zero");  // 抛出异常
    return a / b;
}

int main() {
    try {
        std::cout << divide(10, 2) << '\n';    // 5(正常)
        std::cout << divide(10, 0) << '\n';    // 抛出异常,跳到 catch
        std::cout << "never printed\n";
    } catch (const std::runtime_error& e) {    // 按类型捕获
        std::cout << "Caught: " << e.what() << '\n';
    }
    std::cout << "Program continues...\n";     // 捕获后继续执行
}
  • 代码做什么divide(10, 0) 抛出 std::runtime_errortry 块立即中断,控制流跳到匹配的 catch,打印错误信息后程序继续运行。
  • 特性机制解说throw展开栈(stack unwinding)——从抛出点向上,逐层销毁已构造的局部对象并调用其析构函数,直到找到匹配的 catch。这正是 RAII 能保证异常安全的机制基础:即使抛出异常,局部对象的析构函数也一定会执行。异常匹配是按类型(含继承)进行的,catch (const std::exception&) 能捕获所有派生异常,catch (...) 兜底一切。若没有 catch,程序调用 std::terminate 终止。

示例 2:异常导致内存泄漏(C++11)

// C++11:演示为什么"裸 new + 手工 delete"在异常面前如此脆弱
#include <iostream>
#include <stdexcept>

void process(bool fail) {
    int* data = new int[1000];          // 手工分配堆内存
    if (fail) {
        throw std::runtime_error("boom");   // 异常在这里抛出!
        // delete[] data;                 // ❌ 永远不会执行 → 泄漏 1000 个 int
    }
    delete[] data;                        // 只有不抛异常才执行
}

int main() {
    try {
        process(true);
    } catch (const std::exception& e) {
        std::cout << "Caught: " << e.what() << '\n';   // 捕获了,但内存已泄漏
    }
}
  • 代码做什么process(true)new 之后抛异常,delete[] 被跳过,程序”捕获成功”却泄漏了 4KB 内存。
  • 特性机制解说:裸指针不是 RAII 对象——它没有析构函数,栈展开时没有任何机制帮它释放内存。幻灯片强调”这不只是指针的问题”:文件句柄、锁、数据库连接……一切”需要获取后释放”的资源在异常下都可能泄漏。解决思路就是给资源套一层 RAII 包装:资源在构造时获取、在析构时释放,析构由栈展开保证执行。std::lock_guard(构造加锁、析构解锁)和智能指针(构造持有、析构释放)都是这个思路的实例。

示例 3:自定义 RAII 类(C++11)

// C++11:手写一个 RAII 文件类,展示"析构必执行"
#include <iostream>
#include <stdexcept>

class File {
public:
    explicit File(const char* name) {
        std::cout << "Opening " << name << '\n';
        // 真实代码:fopen / std::ifstream 打开资源
    }
    ~File() {
        std::cout << "Closing file (always runs!)\n";
        // 真实代码:fclose / 关闭资源
    }
};

void useFile(bool fail) {
    File f("data.txt");        // 构造 = 获取资源
    if (fail) {
        throw std::runtime_error("error while using file");
    }
    // 资源使用完毕
}   // ← 无论是否抛异常,f 的析构函数都会在这里执行

int main() {
    try {
        useFile(true);         // 抛异常 → 栈展开 → f 析构 → catch
    } catch (const std::exception& e) {
        std::cout << "Caught: " << e.what() << '\n';
    }
    useFile(false);            // 正常路径同样析构
}
  • 代码做什么File 在构造时”打开”、析构时”关闭”。useFile(true) 抛异常,但 f 的析构仍被执行(栈展开),输出 “Closing file (always runs!)”;正常路径也一样。
  • 特性机制解说:这就是 RAII 的全部奥义——资源生命周期 = 对象生命周期。构造函数里获取资源,对象诞生即”完全可用”(无半有效状态);析构函数里释放资源,对象死亡即”资源已还”。异常安全来自语言保证:栈展开时所有已构造的局部对象的析构函数必然被调用。对比示例 2:裸 new 的指针在栈展开时”没人管”,而 RAII 对象永远有人管。std::lock_guard 正是这个模式在锁上的应用——critical section 里抛异常,锁也会被自动释放,绝不死锁。

示例 4:std::unique_ptr 链表——RAII 自动释放(改写自 A7 的 ListNode 示例)(C++14,make_unique 需 C++14)

// C++14(std::make_unique 自 C++14 起可用)
#include <iostream>
#include <memory>

struct Node {
    int value;
    std::unique_ptr<Node> next;              // 递归持有下一个节点
    explicit Node(int v) : value(v) {}
    ~Node() { std::cout << "Destroying node " << value << '\n'; }
};

int main() {
    auto head = std::make_unique<Node>(1);
    head->next = std::make_unique<Node>(2);
    head->next->next = std::make_unique<Node>(3);

    // std::unique_ptr<Node> copy = head;   // ❌ 编译错误:unique_ptr 不可拷贝
    std::unique_ptr<Node> moved = std::move(head);   // ✅ 移动转移所有权
    std::cout << "head is " << (head ? "non-null" : "null") << '\n';  // null

    if (moved) std::cout << "moved->value = " << moved->value << '\n'; // 1
}   // moved 析构 → 递归销毁 1 → 2 → 3,全程无手工 delete!
  • 代码做什么:用 make_unique 建一条三节点链表,演示移动转移所有权;main 结束时 moved 析构,整条链表递归释放,打印三次 “Destroying node”。
  • 特性机制解说unique_ptr 的析构调用 delete,而 Node 的析构会销毁成员 next(另一个 unique_ptr),后者又触发下一个 Node 的析构——递归释放整条链。不可拷贝是设计核心:若允许拷贝,两个指针指向同一节点,先析构者 delete 后另一个悬垂(幻灯片”original destructor is called after the copy happens”场景)。移动保持唯一性:只是把所有权从 head 转给 movedhead 变空指针。A7 提醒:这种递归析构对很长的链表会消耗调用栈(每次析构嵌套一层),可能栈溢出。

示例 5:shared_ptr 引用计数与 weak_ptr 打破循环(C++11)

// C++11:循环引用 = 泄漏;weak_ptr 打破循环
#include <iostream>
#include <memory>

struct A;
struct B;
struct A { std::shared_ptr<B> b; ~A() { std::cout << "~A\n"; } };
struct B { std::shared_ptr<A> a; ~B() { std::cout << "~B\n"; } };  // 循环!

struct C;
struct D;
struct C { std::shared_ptr<D> d; ~C() { std::cout << "~C\n"; } };
struct D { std::weak_ptr<C> c; ~D() { std::cout << "~D\n"; } };   // weak 打破循环

void badCycle() {
    auto pa = std::make_shared<A>();
    auto pb = std::make_shared<B>();
    pa->b = pb;
    pb->a = pa;   // A 与 B 互相持有 shared_ptr → 计数都 ≥1,永不释放
}                 // 函数结束:什么析构都不打印 → 内存泄漏!

void goodCycle() {
    auto pc = std::make_shared<C>();
    auto pd = std::make_shared<D>();
    pc->d = pd;
    pd->c = pc;   // weak_ptr 不增加引用计数
}                 // 正常释放:打印 ~C 与 ~D

int main() {
    std::cout << "bad:\n";
    badCycle();
    std::cout << "good:\n";
    goodCycle();
}
  • 代码做什么badCycle 中 A↔B 互相持有 shared_ptr,函数结束时两者引用计数都降不到 0,析构函数永远不被调用(泄漏);goodCycle 中 D 用 weak_ptr 持有 C,不计数,全部正常释放。
  • 特性机制解说:shared_ptr 内部有一个引用计数(常与对象一起分配在”控制块”里):每份拷贝 +1,每个析构 -1,归零才 delete 底层对象。循环引用时:papb->a 各持一份指向 A 的计数(2),pbpa->b 各持一份指向 B 的计数(2)。函数结束时 papb 各 -1,计数仍为 1——谁也到不了 0,谁也不释放weak_ptr 旁观不计数:pd->c 是 weak_ptr,指向 C 的计数只有 pc 一份;pc 析构 → C 计数归零 → ~C → 销毁成员 d(shared_ptr)→ D 计数归零 → ~D。使用 weak_ptr 时要注意:它没有 operator->,必须 lock() 得到 shared_ptr 才能访问(对象可能已被销毁,lock 返回空)。

示例 6:make_shared 的异常安全(C++11)

// C++11
#include <iostream>
#include <memory>
#include <stdexcept>

struct Widget { Widget(int) {} };

void helper() { throw std::runtime_error("boom"); }

int main() {
    // 危险写法:先 new 再包装,若 helper() 先抛异常,new 的内存没人接管 → 泄漏
    // std::shared_ptr<Widget> w(new Widget(1), helper());   // 求值顺序不定,可能泄漏

    // 安全写法:make_shared 一步完成,要么都成功要么都失败
    try {
        auto w = std::make_shared<Widget>(1);
        helper();                 // 若这里抛异常,w 的析构会正常释放 Widget
        std::cout << "OK\n";
    } catch (const std::exception& e) {
        std::cout << "Caught: " << e.what() << '\n';
    }
}
  • 代码做什么:演示 make_shared 与 RAII 组合的异常安全性:即使 helper() 抛异常,w 作为局部对象仍会在栈展开时释放其管理的 Widget。
  • 特性机制解说std::shared_ptr<Widget>(new Widget(1), helper()) 有两个独立步骤(先 new、再构造 shared_ptr 接管),C++ 允许求值顺序导致”helper() 先抛、new 出的裸指针无人接管”的泄漏窗口。make_shared 把分配与包装合成一个原子操作,堵死这个窗口;同时它把对象与控制块放进同一次分配,比”new + shared_ptr 构造”少一次堆分配。这就是”永远用 make_unique / make_shared,不要用裸 new”的完整理由。

示例 7:构建工具链——编译命令、Makefile、CMake

# ① 直接编译(多文件)
g++ -std=c++20 main.cpp user.cpp -o main
./main
# ② CMake 标准工作流(Makefile 与 CMakeLists.txt 的完整写法见上文"核心特性与语法详解"第 7 节)
mkdir build && cd build     # 在项目内建 build 目录(生成物集中存放)
cmake ..                    # 用根目录的 CMakeLists.txt 生成 Makefile
make                        # 编译
./main                      # 运行
  • 代码做什么:两段命令演示”直接编译”与”CMake 标准工作流”(Makefile/CMakeLists.txt 内容见上文,此处不再重复)。
  • 特性机制解说g++ 是编译器,-std=c++20 指定语言标准,-o main 指定输出名。make 是构建系统:读 Makefile,按目标(target)执行命令,并能增量编译——只重新编译自上次构建以来修改过的文件(幻灯片强调这是 make 的核心优势)。CMake 是构建系统生成器:读 CMakeLists.txt(更高层的抽象),生成 Makefile(或其他构建系统的文件)。CMake 的 file(GLOB SRC_FILES "*.cpp") 等价于 Makefile 的 $(wildcard *.cpp)——自动收集源文件,新增 .cpp 无需改构建脚本。真实世界如 TensorFlow Core 有 2000+ 源文件,显然需要这样的工具链而不是手敲命令。

与旧标准(如C++98)的对比

  • 智能指针在 C++98 的处境:C++98 标准库只有 std::auto_ptr<memory>)。它的”拷贝”语义其实是转移所有权(拷贝构造会把源指针置空),行为反直觉、无法放进标准容器(vector<auto_ptr<T>> 会出问题),也没有移动语义支撑。C++11 用 std::unique_ptr 取代它,auto_ptr 在 C++17 被正式移除。
  • C++98 的资源管理:只有裸指针 + 手工 new/delete,异常安全完全靠程序员自律(示例 2 的泄漏在 C++98 同样存在且无解);锁、文件等资源同样手工管理,异常路径极易泄漏。RAII 思想在 C++98 就存在(std::stringstd::vector 就是 RAII 类),但直到 C++11 才把它系统化应用到指针上。
  • std::make_shared 是 C++11 的,std::make_unique 是 C++14 的make_unique 是 C++14 才进入标准库,此前常用自定义版本)。C++98 只能 new + 手工包装。
  • 构建工具:make 和 CMake 都是老工具(Make 自 1976 年,CMake 自 2000 年),不是 C++ 标准的一部分;区别在于 CMake 的 set(CMAKE_CXX_STANDARD 20) 让”指定 C++ 版本”变得显式、可跨平台。C++20 起还有官方的模块(modules)与包管理器(如 CMake 的 FetchContent)在演进,但课程仍以 Makefile/CMake 为教学主线。

关键要点

  • 异常try/catch/throw 让错误可捕获、程序可继续;但异常带来大量隐式代码路径,任何”获取后未释放”的资源都可能泄漏。
  • RAII:资源在构造函数获取、析构函数释放;栈展开保证析构必执行,因此 RAII 是 C++ 异常安全的基石(lock_guard、智能指针、string/vector 都是 RAII)。
  • unique_ptr:唯一所有权、不可拷贝、可移动;析构自动 delete永远用 std::make_unique
  • shared_ptr:引用计数共享所有权,计数归零才释放;weak_ptr 旁观不计数,用来打破循环引用永远用 std::make_shared(异常安全 + 一次分配)。
  • 构建g++ -std=c++XX files -o exe 直接编译;多文件项目用 Makefile(make,增量编译);再往上用 CMake(CMakeLists.txt → 生成 Makefile → cmake .. && make)。

常见陷阱与注意事项

  • 循环引用导致内存泄漏:A↔B 互相持有 shared_ptr,引用计数永远到不了 0,析构永不执行。打破方法:让其中一方(通常是被依赖/生命周期更短的一方)持有 weak_ptr
  • weak_ptr 直接解引用weak_ptr 没有 operator*/operator->,必须先 lock() 检查对象是否还活着;对象可能已被销毁,lock() 返回空 shared_ptr。
  • 用裸 new 而不是 make_unique/make_shared:既有异常安全窗口(先 new 后包装,中间抛异常就泄漏),又多一次分配;记住”new 与 delete 每出现一次,就是一次手动管理的机会”。
  • 忘记 noexcept(呼应 L14):unique_ptr 的移动操作若可能抛异常,容器会退化为拷贝。
  • 手工 delete 智能指针管理的内存delete p.get(); 是双重释放;p.release() 后不接管则是泄漏——尽量不要触碰 .get()/.release() 返回的裸指针的所有权。
  • Makefile 的 tab 陷阱all: 下的命令必须以制表符(Tab)开头,空格会导致 make 报错 “missing separator”;CMake 记得在 build 目录里执行 cmake ..,不要把生成物散落在源码目录。

关联作业提示

本讲与 A7: Unique Pointerassign7/)直接相关——你要亲手实现一个简化版 cs106l::unique_ptr,正是本讲三个主题的集大成:

  1. 实现 RAII~unique_ptr()delete ptr;(本讲”析构释放资源”);short answer Q1 问的正是”用 RAII 管理内存相比手工 new/delete 的好处”(自动释放、异常安全、无泄漏)。
  2. 实现不可拷贝 + 可移动unique_ptr(const unique_ptr&) = delete;operator=(const unique_ptr&) = delete;(L13 的 = delete),unique_ptr(unique_ptr&& other)operator=(unique_ptr&& other) 用 L14 的”偷指针 + 置空源对象”套路实现(short answer Q2 问不置空会怎样——双重释放)。
  3. Part 2 的链表head->next = std::make_unique<ListNode<int>>(2) 的递归结构正是本讲示例 4;create_list 里用 node->next = std::move(head) 转移所有权(L14 的 std::move)。short answer Q3 问”递归析构对长链表的隐患”——本讲示例 4 的机制解说里提到了栈深度问题。
  4. 构建命令g++ -std=c++20 main.cpp -o main(A7 单文件)与 A5 的 g++ -std=c++20 main.cpp user.cpp -o main(多文件)就是本讲编译命令一节的内容;若项目变大,可以用 Makefile 或 CMake 来管理。

另外,L16 与本讲之外的两讲也有关联:std::optional(L15)在 A6 中使用;A5 的 User 类本质上是”手动管理裸指针数组的 RAII 类”(析构释放、拷贝深拷贝),理解 RAII 能帮你写对它的析构函数。