C++基础:Stanford CS106L学习笔记 15 RAII&智能指针&构建C++工程

目录

15.1 RAII

15.1.1 exceptions

exceptions(异常)是处理代码中出现的错误的一种方式。

exceptions会被 "抛出"。

不过,我们可以编写代码来处理异常,这样我们就能继续执行代码,而不一定会出错。


这段内容是对一段C++代码中可能抛出异常的代码路径(code paths)的分析,总共至少有23条可能抛出异常的路径,具体解析如下:

  1. **Pet的拷贝构造函数(1处)**:

函数参数Pet p采用值传递方式,会调用Pet的拷贝构造函数,此处可能抛出异常。

  1. **临时字符串构造(5处)**:

函数返回类型为std::string,过程中涉及临时字符串的构造,这些操作可能抛出异常(如内存分配失败)。

p.type()p.firstName()p.lastName()的返回值是std::string,这些函数返回时会构造临时字符串(可能 3 处)。代码中"Dog""Fluffy"等字符串字面值,在与std::string进行比较(==)或拼接(+)时,会隐式转换为临时std::string对象(可能 2 处)。

  1. **成员函数调用(1+3+2处)**:
    • 调用p.type()(1处)可能抛出异常
    • 调用p.firstName()(3处)可能抛出异常
    • 调用p.lastName()(2处)可能抛出异常
  2. **运算符重载(10处)**:

代码中p.firstName() + " " + p.lastName()使用了字符串拼接运算符,用户重载的这些运算符可能抛出异常。因为有10个符号,所以10处。

  1. **返回字符串的拷贝构造(1处)**:

返回std::string对象时,其拷贝构造函数可能抛出异常。

这些数字总和(1+5+6+10+1)提示了代码中存在多处可能引发异常的点,需要注意异常安全处理。

代码用 new Pet(petId) 动态分配了 Pet 对象(指针 p 指向它),但 delete p 的执行存在 "漏洞",导致内存无法回收:

  • Pet 构造函数抛出异常(比如 petId 无效、内部初始化失败):此时 new Pet(petId) 会直接触发异常,后续的 delete p 根本不会执行,分配的内存永久 "丢失"。
  • 若后续操作(如 p.type()p.firstName())抛出异常:比如这些成员函数内部访问无效数据、动态内存分配失败(如返回 std::string 时内存不足),程序会直接跳转到异常处理流程,同样跳过 delete p,导致内存泄漏。

只有当所有操作都正常执行到 delete p 时,内存才会回收 ------ 但只要中间任何一步抛异常,就会泄漏,风险极高。

不仅是指针很多资源都会在获取后释放。

我们如何保证当我们有异常时,资源会正确的释放呢?

15.1.2 资源获取即初始化

RAII(Resource Acquisition is Initialization) 是由 Bjarne Stroustrup(C++ 语言的创始人)提出的编程范式,其核心是将资源管理对象的生命周期强绑定,是 C++ 中管理资源(如内存、文件句柄、网络连接、锁等)的基石思想。

包括:

  • 一个类所使用的所有资源都应该在构造函数中获取!
  • 一个类所使用的所有资源都应该在析构函数中释放!
1、本质

RAII 的本质是 "​资源获取即初始化,资源释放即销毁​",具体通过两个关键步骤实现:

  • 资源获取(Acquisition = Initialization)​: 类在构造函数 中完成资源的申请 / 获取(比如用 new 分配内存、open() 打开文件、lock() 加锁)。 这确保了 "创建对象" 和 "获取资源" 是原子操作 ------ 只要对象创建成功,资源就一定已就绪,不会出现 "对象存在但资源未获取" 的中间状态。
  • 资源释放(Release = Destruction)​: 类在析构函数 中完成资源的释放 / 清理(比如用 delete 释放内存、close() 关闭文件、unlock() 解锁)。 由于 C++ 会自动调用对象的析构函数 (栈对象离开作用域时、堆对象被 delete 时),这意味着:资源会随着对象生命周期的结束自动释放 ,完全无需手动调用 "释放函数"(如 free()close())。
2、解决的核心问题:避免资源泄漏

手动管理资源时,很容易因代码逻辑漏洞导致资源泄漏(比如忘记释放内存、提前 return 跳过释放步骤、异常抛出打断释放流程)。而 RAII 通过 "自动析构" 从根本上规避了这些问题。

​**示例对比(以动态内存为例)**​:

  • 手动管理(风险高):
cpp 复制代码
void badExample() {
    int* ptr = new int[10]; // 手动获取资源(内存)
    if (someCondition) {
        return; // 提前返回,跳过 delete,内存泄漏!
    }
    delete[] ptr; // 手动释放(若上面返回,此处不会执行)
}
  • RAII 管理(安全): 用 std::vector(C++ 标准库中典型的 RAII 容器)封装内存:
cpp 复制代码
void goodExample() {
    std::vector<int> vec(10); // 构造函数中自动分配内存(获取资源)
    if (someCondition) {
        return; // 离开作用域,vec 析构函数自动释放内存,无泄漏
    }// 无需手动释放,vec 生命周期结束时析构函数自动执行
}
3、典型应用场景

RAII 几乎覆盖所有 "需手动申请 / 释放" 的资源类型,常见场景包括:

  • 动态内存(std::unique_ptr/std::shared_ptr,替代裸指针 new/delete);
  • 文件操作(自定义类在构造函数 open(),析构函数 close());
  • 线程锁(std::lock_guard/std::unique_lock,自动加锁 / 解锁,避免死锁);
  • 网络连接、数据库连接等稀缺资源。
4、一些例子

文件打开与关闭,不符合RAII

这段代码在 RAII合规性方面存在问题:

资源管理方式不符合 RAII​:

  • 代码手动调用了input.close()关闭文件流,这违背了 RAII 的核心思想
  • RAII 要求资源(如文件句柄)应在对象构造时获取,在析构时自动释放

潜在的资源泄漏风险​:

  • 如果while(getline(...))循环内部抛出异常,input.close()可能不会被执行
  • 文件流资源可能无法正常释放

正确的 RAII 做法​:

  • ifstream本身是符合 RAII 的类,其析构函数会自动关闭文件
  • 不需要手动调用close(),应移除显式的input.close()语句
  • input对象超出作用域时,会自动调用析构函数释放文件资源

因此,这段代码​不符合 RAII 规范​,手动管理资源的方式增加了出错风险。

锁的打开与关闭,不符合RAII

如何修复?

核心优势在于lock_guard的 RAII 特性:无论函数正常返回还是因异常退出,只要lock_guard对象超出作用域,就会自动释放锁,避免死锁风险。

15.2 智能指针

避免显式调用 new 和 delete:

原因

new 返回的指针应当属于一个资源句柄(该句柄能够调用 delete)。如果 new 返回的指针被赋值给一个普通 / 裸指针,那么该对象可能会发生泄漏。

注意

在大型程序中,裸 delete(即应用程序代码中的 delete,而非专门用于资源管理的代码部分中的 delete)很可能是一个错误:如果你有 N 个 delete,你如何能确定不需要 N+1 个或 N-1 个呢?这个错误可能是潜在的:它可能只在维护期间才会显现。如果你有一个裸 new,你很可能在某个地方需要一个裸 delete,所以你很可能存在一个错误。

实施对任何显式使用 new 和 delete 的情况发出警告。建议改用 make_unique。

15.2.1 三种智能指针

有三种符合 RAII 规范的指针:

std::unique_ptr

唯一拥有其资源,不能被复制

std::shared_ptr

可以进行复制,当底层内存超出作用域时会被销毁

std::weak_ptr

一类旨在缓解循环依赖的指针

std::unique_ptr
cpp 复制代码
// 普通指针不好
void rawptrFn(){
    Node* n = new Node;
    //do smth with n
    delete n;
}
//////////////////////////////////////
// 用智能指针
void rawptrEn(){
    std:unique_ptr<Node> n(new Node);
    //do something with n
    //n automatically freed
    std:unique_ptr<Node> copy = n;                // unique智能指针不能被复制!
}

为什么unique_ptr不能被复制?

这行代码尝试复制 一个unique_ptr,但unique_ptr故意禁用了拷贝构造函数(删除了拷贝操作),所以这会直接导致​编译错误​。

如果假设这段代码能编译通过(实际上不可能),会引发严重问题:

  • n的生命周期结束时,其析构函数会自动释放所指向的Node对象内存
  • 此时copy仍然指向已被释放的内存,成为悬空指针
  • copy的析构函数被调用时,会尝试再次释放已释放的内存,导致双重释放(double free)错误,这是 C++ 中常见的内存安全问题
std::shared_ptr

std::shared_ptr(共享指针)的核心工作原理:

  1. 解决unique_ptr的拷贝限制std::unique_ptr是独占所有权的智能指针,无法被拷贝(只能移动),而shared_ptr允许拷贝,因为它采用了共享所有权模式。
  2. 内存释放机制shared_ptr不会像unique_ptr那样在自身超出作用域时就释放底层内存,而是要等到所有指向该内存的 shared_ptr 都超出作用域时才会释放,避免了过早释放或重复释放的问题。
  1. 内部结构包含
  • 指向 T 的指针:实际指向管理的对象
  • **控制块(Control Block)**:存储关键元数据
    • **引用计数(Reference Count)**:记录当前有多少个shared_ptr指向该对象
    • **弱引用计数(Weak Count)**:记录关联的weak_ptr数量
    • 其他信息:如自定义删除器(Custom Deleter)、分配器(Allocator)等

工作流程简单来说:每次拷贝shared_ptr时,控制块中的引用计数加 1;当某个shared_ptr销毁时,引用计数减 1;当引用计数变为 0 时,才真正释放所管理的对象内存。

cpp 复制代码
// 显式调用new,不对
std::unique_ptr<T> uniquePtr{new T};
std::shared_ptr<T> sharedPtr{new T};
std::weak_ptr<T> wp = sharedPtr;

////////////////////////////////////////
// 用make_unique
std::unique_ptr<T> uniquePtr = std::make_unique<T>();
std::shared_ptr<T> sharedPtr = std::make_shared<T>();
std::weak_ptr<T> wp = sharedPtr;

始终使用 std::make_unique<T>和 std::make_shared<T>

原因是:如果不这样做,我们将会进行两次内存分配,一次是为指针本身分配,另一次是为 new T 分配。

我们还应该保持一致性 ------ 如果你使用 make_unique,那么也请使用 make_shared!

std::weak_ptr

弱指针是一种避免代码中出现循环依赖的方法,这样我们就不会发生内存泄漏。

cpp 复制代码
#include <iostream>
#include <memory>
class B;
class A {
    public:
        std::shared_ptr<B> ptr_to_b;
    ~A (){
        std::cout << "A 的所有资源已释放" << std::endl;
    }
};
class B {
    public:
        std::shared_ptr<A> ptr_to_a;
    ~B (){
        std::cout << "B 的所有资源已释放" << std::endl;
    }
};
int main () {
    std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
    std::shared_ptr<A>_shared_ptr_to_b = std::make_shared<B>();
    a->ptr_to_b = shared_ptr_to_b;
    b->ptr_to_a = shared_ptr_to_a;
    return 0;
}

类 A 的实例 a 和类 B 的实例 b 都持有指向对方的共享指针。因此,它们永远不会被正确释放。

cpp 复制代码
#include <iostream>
#include <memory>
class B;
class A {
    public:
        std::shared_ptr<B> ptr_to_b;
    ~A (){
        std::cout << "A 的所有资源已释放" << std::endl;
    }
};
class B {
    public:
        std::weak_ptr<A> ptr_to_a;
    ~B (){
        std::cout << "B 的所有资源已释放" << std::endl;
    }
};
int main () {
    std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
    std::shared_ptr<A>_shared_ptr_to_b = std::make_shared<B>();
    a->ptr_to_b = shared_ptr_to_b;
    b->ptr_to_a = shared_ptr_to_a;
    return 0;
}

在这里,在 B 类中,我们不再将 a 存储为 shared_ptr,因此它不会增加 a 的引用计数。

因此,a 可以正常被释放,b 也因此可以被释放。

分步骤的详细解释:
一、前提:共享指针(std::shared_ptr)的核心逻辑

std::shared_ptr 是 C++ 用于共享对象所有权的智能指针,其核心是通过「引用计数」管理内存:

  1. 每个被 shared_ptr 管理的对象,都关联一个「引用计数器」(记录当前有多少个 shared_ptr 指向它)。
  2. 当新增一个 shared_ptr 指向对象时,引用计数 +1;当 shared_ptr 销毁,如出作用域时,引用计数-1。
  3. 只有当引用计数 减至 0 时,对象才会被自动销毁(调用析构函数),避免内存泄漏。

这种机制在「单向引用」场景下完美工作,但在「双向循环引用」时会彻底失效。

二、错误例子:循环依赖导致内存泄漏的根源

你的错误代码中,AB 类的实例互相持有对方的 shared_ptr,形成了「循环引用」,具体过程如下:

  1. 执行流程
cpp 复制代码
#include <iostream>
#include <memory>

// 前向声明:告诉编译器 B 是一个类(后续会定义)
class B;

class A {
public:
    // A 持有指向 B 的 shared_ptr
    std::shared_ptr<B> ptr_to_b;
    ~A() {
        std::cout << "A 的所有资源已释放" << std::endl;
    }
};

class B {
public:
    // B 持有指向 A 的 shared_ptr
    std::shared_ptr<A> ptr_to_a;
    ~B() {
        std::cout << "B 的所有资源已释放" << std::endl;
    }
};

int main() {
    // 创建 A 和 B 的实例,用 shared_ptr 管理
    std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
    std::shared_ptr<B> shared_ptr_to_b = std::make_shared<B>();

    // 互相赋值:形成循环引用
    shared_ptr_to_a->ptr_to_b = shared_ptr_to_b;  // A 的实例持有 B 的 shared_ptr
    shared_ptr_to_b->ptr_to_a = shared_ptr_to_a;  // B 的实例持有 A 的 shared_ptr

    return 0;  // main 函数结束,局部的 shared_ptr 开始销毁
}
  1. 循环引用导致的"引用计数僵局"

执行到 main 函数结束时,会发生以下关键步骤,最终导致内存泄漏:

步骤1:局部 ​​shared_ptr销毁

main 中的 shared_ptr_to_ashared_ptr_to_b 是局部变量,函数结束时会被销毁:

  • shared_ptr_to_a 销毁:A 对象的引用计数从 2(shared_ptr_to_a + Bptr_to_a)减为 1。
  • shared_ptr_to_b 销毁:B 对象的引用计数从 2(shared_ptr_to_b + Aptr_to_b)减为 1。

步骤2:引用计数无法减至 0,对象永远不销毁

此时,A 对象的引用计数还剩 1(由 Bptr_to_a 持有),B 对象的引用计数也剩 1(由 Aptr_to_b 持有)。由于 AB 互相"牵制",它们的 shared_ptr 永远不会被完全销毁,引用计数永远无法到 0------最终 AB 的析构函数都不会执行(控制台不会输出释放信息),内存泄漏。

三、正确例子:用弱指针(std::weak_ptr)打破循环

弱指针(std::weak_ptr)是专门为解决 shared_ptr 循环依赖设计的智能指针,它有两个核心特性:

不参与引用计数 ​:weak_ptr 指向对象时,不会增加对象的引用计数(仅作为"观察者",不持有所有权)。

需要先转为 ​shared_ptr 才能访问对象:weak_ptr 本身不能直接解引用(避免访问已销毁的对象),必须通过 lock() 方法转为 shared_ptr(若对象已销毁,lock() 返回空的 shared_ptr)。

  1. 正确代码实现

将其中一方的 shared_ptr 改为 weak_ptr(如 B 类的 ptr_to_a),即可打破循环:

cpp 复制代码
#include <iostream>
#include <memory>

class B;  // 前向声明

class A {
public:
    std::shared_ptr<B> ptr_to_b;  // A 仍用 shared_ptr 持有 B
    ~A() {
        std::cout << "A 的所有资源已释放" << std::endl;
    }
};

class B {
public:
    // 关键:B 用 weak_ptr 持有 A(不增加 A 的引用计数)
    std::weak_ptr<A> ptr_to_a;
    ~B() {
        std::cout << "B 的所有资源已释放" << std::endl;
    }
};

int main() {
    std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
    std::shared_ptr<B> shared_ptr_to_b = std::make_shared<B>();

    // 互相赋值:此时仅 A 持有 B 的 shared_ptr,B 持有 A 的 weak_ptr
    shared_ptr_to_a->ptr_to_b = shared_ptr_to_b;  // A 的引用计数:1(仅 shared_ptr_to_a)
    shared_ptr_to_b->ptr_to_a = shared_ptr_to_a;  // B 的引用计数:2(shared_ptr_to_b + A 的 ptr_to_b)

    return 0;  // main 结束,局部 shared_ptr 销毁
}
  1. 弱指针如何打破循环?执行流程解析

步骤1:局部 ​shared_ptr 销毁

  • shared_ptr_to_a 销毁:A 对象的引用计数从 1 减为 0A 的析构函数执行(输出"A 的所有资源已释放")。
  • A 析构时,其成员 ptr_to_b(指向 Bshared_ptr)也会销毁 → B 对象的引用计数从 2 减为 1

​步骤2:​ B​ 对象的引用计数最终减为 0 main 中的 shared_ptr_to_b 继续销毁 → B 对象的引用计数从 1 减为 0B 的析构函数执行(输出"B 的所有资源已释放")。

最终,AB 都能正常销毁,无内存泄漏。

四、弱指针的其他注意事项

不能直接解引用 ​:weak_ptr 没有 operator*operator->,必须通过 lock() 转为 shared_ptr 后才能访问对象,例如:

cpp 复制代码
// 在 B 类中若要访问 A 的成员,需先 lock()
void B::accessA() {
    // lock():若 A 未销毁,返回指向 A 的 shared_ptr;否则返回空
    if (auto a_ptr = ptr_to_a.lock()) {
        // 此时 a_ptr 是 shared_ptr<A>,可安全访问 A 的成员
        // a_ptr->xxx;
    } else {
        std::cout << "A 对象已销毁,无法访问" << std::endl;
    }
}

用途限制​:弱指针仅用于"打破循环依赖"或"观察对象是否存活",不适合作为常规的对象持有方式(因为无法保证对象一定存活)。

总结
  • 循环依赖的本质shared_ptr 互相持有导致引用计数无法减至 0,对象永远不销毁。
  • 弱指针的作用:以"不持有所有权"的方式观察对象,不增加引用计数,从而打破循环。
  • 核心原则 :在双向引用场景中,让一方用 shared_ptr 持有(负责对象存活),另一方用 weak_ptr 观察(不影响存活),即可避免内存泄漏。

15.3 构建C++项目

先看:2.5 编译C++程序

make 是一个 "构建系统" 程序,能帮助你进行编译!

你可以指定想要使用的编译器

要使用 make,你需要有一个 Makefile

CMake 是一个构建系统生成器。

因此,你可以使用 CMake 来生成 Makefile 文件。

它就像是 Makefile 文件的一种更高级别的抽象。

cpp 复制代码
cmake_minimum_required(VERSION 3.10)
project(cs106l_classes)
set(CMAKE_CXX_STANDARD 20)            // 告诉cmake把编译器设置为C++20
file(GLOB SRC_FILES "*.cpp")          // 这个 GLOB 命令是告诉 CMAKE 程序对所有符合 *.cpp 模式的文件进行通配符搜索。
add_executable(main ${SRC_FILES})     // 此命令会将我们程序的所有源文件添加到可执行文件中

1.你的项目根目录中需要有一个 CMakeLists.txt 文件

2.在你的项目中创建一个 build 文件夹(mkdir build)

3.进入 build 文件夹(cd build)

4.运行 cmake...

a. 该命令使用项目根目录中的 CMakeLists.txt 运行 cmake!

b. 这会生成一个 Makefile

5.运行 make

6.像往常一样使用./main 执行你的程序

RAII(资源获取即初始化)指出,动态分配的资源应该在构造函数中获取,在析构函数中释放。智能指针就是这样的例子。对于编译我们的项目,我们可以而且应该使用 Makefile。而为了生成 Makefile,我们可以而且应该使用 CMAKE。

相关推荐
TL滕2 小时前
从0开始学算法——第二十一天(高级链表操作)
笔记·学习·算法
YYDS3142 小时前
次小生成树
c++·算法·深度优先·图论·lca最近公共祖先·次小生成树
黑客思维者2 小时前
机器学习014:监督学习【分类算法】(逻辑回归)-- 一个“是与非”的智慧分类器
人工智能·学习·机器学习·分类·回归·逻辑回归·监督学习
xu_yule2 小时前
算法基础(区间DP)
数据结构·c++·算法·动态规划·区间dp
biter down2 小时前
C++ 交换排序算法:从基础冒泡到高效快排
c++·算法·排序算法
落羽的落羽2 小时前
【C++】深入浅出“图”——图的遍历与最小生成树算法
linux·服务器·c++·人工智能·算法·机器学习·深度优先
程序员大辉2 小时前
比notion更好用的markdown笔记工具Obsidian
笔记
race condition2 小时前
UNIX网络编程笔记 信号处理
笔记·unix·信号处理
Dream it possible!2 小时前
牛客周赛 Round 123_C_小红出对 (哈希表+哈希集合)
c++·哈希算法·散列表