Standard C++ 基础回炉|第 2 课(2-1):RAII 与资源管理
从构造异常、资源所有权、显式 close、锁、线程与事务出发,系统理解 RAII 如何把资源收尾责任绑定到对象生命周期,并通过树莓派 + AddressSanitizer 实验验证异常展开中的资源安全。
Standard C++ 基础回炉|第 2 课(2-1):RAII 与资源管理
1. 学习目标
这一课的目标不是记住"构造申请、析构释放"八个字,而是建立一个可以用于实际 C++ 工程设计的 RAII 模型。
完成本课后,应能够回答:
- RAII 到底管理什么,为什么不等于智能指针。
- 为什么文件、Socket、锁、线程、事务都可以使用 RAII。
- 为什么"类里写了析构释放资源"仍然可能发生泄漏。
- 构造函数抛异常时,外层对象和已经构造完成的成员分别会发生什么。
- RAII Wrapper 为什么需要维护所有权状态和有效状态。
- 为什么显式
close()与析构函数承担的职责不同。 - 独占资源为什么通常不能直接拷贝。
- RAII 能保证什么,又不能保证什么。
- 如何使用 AddressSanitizer / LeakSanitizer 验证资源泄漏。
- 如何自己封装一个最基本的
std::FILE*RAII Wrapper。
2. 什么是 RAII
RAII 是 Resource Acquisition Is Initialization,通常翻译为"资源获取即初始化"。
最简化的说法是:
构造 → 获取资源
析构 → 释放资源
但这还不够完整。
更准确的理解是:
RAII 将资源或必须收尾的责任绑定到对象生命周期,由对象维护资源的所有权和有效状态。当对象生命周期结束时,根据当前状态执行正确的收尾,从而使资源管理不再依赖具体控制流。
因此 RAII 的重点并不是 new/delete,而是:
对象建立
↓
承担某种资源或收尾责任
↓
维护所有权 / 有效状态
↓
正常离开作用域、return、throw 等
↓
对象生命周期结束
↓
自动执行正确收尾
RAII 把原本散落在控制流中的清理代码,转换成对象生命周期问题。
3. "资源"不只是内存
常见资源包括:
new → delete
new[] → delete[]
malloc → free
fopen → fclose
open → close
socket → close
mmap → munmap
mutex.lock → mutex.unlock
transaction → commit / rollback
thread start → stop / join
例如:
std::lock_guard<std::mutex> guard(mutex);
这里没有动态内存,也没有裸指针,但 guard
在自己的生命周期内承担了"持有这把锁并最终解锁"的责任。
所以 std::lock_guard 是非常典型的 RAII。
可以把资源进一步理解为:
一段具有明确开始、结束,并且必须可靠完成收尾的所有权或责任。
4. 手动资源管理为什么脆弱
传统写法:
mutex.lock();
doSomething();
mutex.unlock();
如果 doSomething() 中发生:
return;
或者:
throw;
那么 unlock() 很容易被绕过。
文件也一样:
FILE* file = std::fopen("test.txt", "rb");
doSomething();
std::fclose(file);
随着控制流变复杂,就会出现:
正常路径要释放
错误路径要释放
提前 return 要释放
异常路径要释放
以后新增分支还要记得释放
资源管理开始依赖"程序员是否在每条路径都记得写清理代码"。
RAII 的思路则是:
FileHandle file("test.txt");
doSomething();
只要 file 生命周期结束,清理责任就由 FileHandle 完成。
5. RAII 不等于智能指针
智能指针只是 RAII 的一种实现。
例如:
RAII
├── std::unique_ptr
├── std::shared_ptr
├── std::vector
├── std::fstream
├── std::lock_guard
├── std::unique_lock
├── 自定义 Socket
├── 自定义 FileHandle
├── 自定义 Transaction
└── 自定义 ThreadGuard
下面的类即使完全没有智能指针,也可以是 RAII Wrapper:
class Socket
{
public:
Socket()
{
fd_ = ::socket(...);
}
~Socket()
{
::close(fd_);
}
private:
int fd_;
};
fd_ 是裸句柄,但 Socket 本身就是它的最终所有者和生命周期管理者。
因此:
RAII Wrapper 可以直接持有裸资源;业务类则优先组合 RAII Wrapper,而不是重复管理裸资源。
6. 类写了析构,为什么仍然可能泄漏
考虑:
class RawBuffer
{
public:
RawBuffer()
{
data_ = new int[100];
initialize(); // 可能抛异常
}
~RawBuffer()
{
delete[] data_;
}
private:
int* data_ = nullptr;
};
表面上它符合:
构造 → new[]
析构 → delete[]
但如果:
initialize();
抛出异常,则 RawBuffer 没有完成构造。
C++ 的规则是:
对象只有成功完成构造之后,才进入完整生命周期。构造函数抛异常时,该对象自己的析构函数不会执行。
因此:
new int[100] 成功
↓
initialize() throw
↓
RawBuffer 构造失败
↓
~RawBuffer() 不执行
↓
delete[] 不执行
↓
内存泄漏
这说明:
"构造获取、析构释放"只是 RAII 的表面形式。还必须考虑 Wrapper 自己的构造过程是否异常安全。
7. 已完成构造的成员会参与异常展开
更安全的结构:
class Buffer
{
public:
Buffer()
{
data_ = new int[100];
}
~Buffer() noexcept
{
delete[] data_;
}
private:
int* data_ = nullptr;
};
class Device
{
public:
Device()
{
throw std::runtime_error("Device initialization failed");
}
private:
Buffer buffer_;
};
构造过程:
Buffer 构造完成
↓
Device 构造函数体开始
↓
throw
↓
Device 未完成构造
↓
~Device() 不执行
↓
buffer_ 已经完成构造
↓
~Buffer() 执行
因此有一个非常重要的判断模型:
谁完成了构造,谁就拥有自己的生命周期,谁就有资格在异常展开时析构。
如果结构是:
Device ❌ 未完成
└── Buffer ❌ 未完成
└── p_ ✅ 已完成
那么即使 Device 和 Buffer 自己的析构函数都不会执行,只要 p_
是已经成功构造的 RAII 成员,例如:
std::unique_ptr<int[]> p_;
p_ 仍然会在异常展开时析构并释放资源。
8. 资源应尽快进入独立 RAII 对象
危险窗口是:
裸资源已经获取
↓
负责它的 RAII 对象还没构造完成
↓
发生异常
因此一个重要原则是:
资源一旦成功获取,应尽快交给一个已经能够独立完成清理的 RAII 对象管理。
例如:
class Buffer
{
public:
Buffer()
: data_(100)
{
initialize();
}
private:
std::vector<int> data_;
};
data_ 在进入 Buffer 构造函数体之前已经构造完成。
即使 initialize() 抛异常:
Buffer 构造失败
↓
data_ 已经完成构造
↓
std::vector 析构
↓
vector 自己释放内部内存
注意这里不是"vector 的资源由 Buffer 生命周期控制",而是:
Buffer 拥有 vector 子对象
vector 自己拥有自己的动态内存
每一层只负责自己真正拥有的资源。
9. RAII Wrapper 的状态
资源管理对象通常需要知道:
析构时,我现在还有没有未完成的资源管理责任?
例如:
std::FILE* file_;
可以表达:
file_ != nullptr → Owning
file_ == nullptr → Empty
std::unique_lock 内部也需要知道:
owns_lock == true
owns_lock == false
事务可以使用:
bool active_;
线程则可能需要:
thread_.joinable()
这些状态不是普通业务状态,而是:
所有权状态和资源有效状态。
它们决定析构时到底应该做什么。
10. 所有权状态必须与真实资源状态一致
例如:
void close()
{
if (file_ != nullptr)
{
std::fclose(file_);
file_ = nullptr;
}
}
调用 close() 后:
Owning
↓
关闭资源
↓
Empty
如果已经是 Empty:
file_ == nullptr
再次调用 close() 应安全地什么都不做。
这可以避免:
第一次 fclose
第二次 fclose
↓
二次释放 / Undefined Behavior
因此:
所有会改变资源所有权或有效状态的操作,都应经过 Wrapper,使 Wrapper 内部状态与真实资源状态保持一致。
反例:
FileHandle file("test.txt");
std::fclose(file.raw());
如果外部绕过 Wrapper 直接关闭底层资源:
真实 FILE* 已关闭
FileHandle 却仍认为自己拥有它
析构时再次 fclose() 就可能产生 Undefined Behavior。
11. 显式 close 与析构兜底
某些资源关闭操作可能失败。
因此工程中常见:
void close();
~FileHandle() noexcept;
二者职责不同。
显式调用:
file.close();
可以:
主动结束资源
检查关闭结果
向调用者报告错误
析构则负责:
生命周期结束时最后兜底
尽力完成清理
不能让异常逃出析构函数
例如概念上:
FileHandle::~FileHandle() noexcept
{
if (file_ != nullptr)
{
std::fclose(file_);
}
}
析构不是处理复杂错误流程的万能位置。
12. 为什么析构函数通常不能传播异常
考虑:
try
{
FileHandle file("test.txt");
throw ErrorA{};
}
程序正在因为 ErrorA 进行栈展开。
此时:
~FileHandle()
被调用。
如果析构函数又抛出:
ErrorB
则会出现两个异常同时传播,程序将调用:
std::terminate()
因此 RAII 析构通常应满足:
~Resource() noexcept;
原则是:
析构函数应完成兜底清理,但不要让异常逃出去。
需要向调用者报告的关闭错误,应通过显式接口处理。
13. 独占资源为什么通常禁止拷贝
假设:
class FileHandle
{
private:
FILE* file_;
};
如果允许默认拷贝:
FileHandle a("a.txt");
FileHandle b = a;
默认成员拷贝可能变成:
a.file_ ─┐
├──→ 同一个 FILE*
b.file_ ─┘
然后两个对象析构:
~b() → fclose()
~a() → fclose()
同一个资源被关闭两次。
因此独占资源 Wrapper 通常:
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
至于移动:
FileHandle b = std::move(a);
其正确语义应该是:
移动前:
a ───→ FILE*
移动后:
a ───→ nullptr
b ───→ FILE*
移动是转移所有权,不是复制资源。
这一部分将在第 03 课"拷贝、移动与 Rule of 0/5"继续展开。
14. std::move 本身并不会移动资源
例如:
FileHandle b = std::move(a);
std::move(a) 本身并没有:
关闭文件
修改指针
复制文件
转移资源
它主要把表达式转换成可以匹配移动操作的形式。
真正完成:
b 接管资源
a 进入 Empty
的是 FileHandle 的移动构造函数或移动赋值运算符。
移动后的对象仍然存在。
对于自定义 FileHandle,可以设计:
Moved-from → file_ == nullptr
这样它仍然合法、可析构。
对于标准库类型,通常只能依赖:
moved-from 对象处于 valid but unspecified state。
15. 移动构造与移动赋值的资源区别
移动构造:
FileHandle b(std::move(a));
b 是新对象,没有旧资源需要处理。
移动赋值:
FileHandle b("b.txt");
b = std::move(a);
此时 b 已经拥有资源。
因此移动赋值必须考虑:
先处理 b 原来的资源
↓
再接管 a 的资源
↓
a 进入 Empty
这也是为什么资源所有权会直接影响特殊成员函数设计。
本课只建立概念,具体实现留到第 03 课。
16. 锁也是 RAII 资源
错误写法:
mutex.lock();
doSomething();
mutex.unlock();
如果:
doSomething();
抛异常,则 unlock() 可能无法执行。
使用:
std::lock_guard<std::mutex> guard(mutex);
后:
guard 构造
↓
获得锁
↓
正常结束 / return / throw
↓
guard 生命周期结束
↓
析构
↓
unlock
这里资源是:
对 mutex 的锁所有权。
17. 为什么不能绕过 lock_guard 手动 unlock
例如:
std::lock_guard<std::mutex> guard(mutex);
mutex.unlock();
此时:
真实 mutex:已经解锁
guard:仍然认为自己持锁
等 guard 析构:
再次 unlock
可能产生 Undefined Behavior。
如果确实需要提前解锁,应使用支持状态变化的:
std::unique_lock<std::mutex> lock(mutex);
lock.unlock();
unique_lock 会同步更新内部状态:
owns_lock = false
因此析构时不会再次解锁。
这与:
FileHandle::close()
之后:
file_ = nullptr;
本质相同。
18. 线程的 RAII 不只是 join
一个简单线程 Wrapper:
class ThreadGuard
{
public:
ThreadGuard()
: thread_([] {
doWork();
})
{
}
~ThreadGuard() noexcept
{
if (thread_.joinable())
thread_.join();
}
private:
std::thread thread_;
};
这里:
join()
的作用是:
等待线程真正结束,并完成线程资源的收尾。
而:
detach()
表示:
放弃对线程生命周期的管理,让线程独立继续执行。
因此 RAII Wrapper 通常不是"构造 join,析构 detach"。
但下面仍然有问题:
while (true)
{
doWork();
}
析构调用:
join();
会一直等待。
真正需要的协议是:
request stop
↓
工作线程观察停止条件
↓
线程退出
↓
join
这说明:
RAII 决定谁负责收尾、什么时候触发收尾,但不能自动设计正确的线程停止协议。
19. 事务也是 RAII
手动事务:
db.begin();
updateA();
updateB();
writeLog();
db.commit();
如果中途发生异常:
begin
↓
修改部分数据
↓
throw
↓
commit 没执行
就需要 rollback。
可以设计:
class Transaction
{
public:
explicit Transaction(Database& db)
: db_(db)
{
db_.begin();
active_ = true;
}
void commit()
{
db_.commit();
active_ = false;
}
~Transaction() noexcept
{
if (active_)
db_.rollback();
}
private:
Database& db_;
bool active_ = false;
};
正常:
begin
↓
Active
↓
业务操作
↓
commit 成功
↓
active_ = false
↓
析构无需 rollback
异常:
begin
↓
Active
↓
throw
↓
栈展开
↓
~Transaction()
↓
rollback
这里没有动态内存,没有文件句柄。
Transaction 管理的是:
一个尚未结束、必须 commit 或 rollback 的事务责任。
20. 状态更新必须匹配真实操作的成功边界
错误:
void Transaction::commit()
{
active_ = false;
db_.commit();
}
如果:
db_.commit();
抛异常:
Wrapper:active_ == false
数据库:commit 没有成功
Wrapper 状态和真实资源状态失去一致性。
析构看到:
active_ == false
也不会执行 rollback。
更合理的简单模型:
void Transaction::commit()
{
db_.commit();
active_ = false;
}
只有真实操作成功后,才更新 Wrapper 状态。
因此:
Wrapper 的状态转换必须依据底层资源 API 的真实语义,而不是机械套用固定顺序。
真实数据库 API 的 commit 失败语义可能更加复杂,工程实现可能需要:
enum class State
{
Active,
Committed,
RolledBack,
Failed,
Unknown
};
本课只建立状态一致性的基本原则。
21. RAII 能保证什么
RAII 能解决的核心问题:
谁负责收尾?
什么时候触发收尾?
正常离开作用域怎么办?
提前 return 怎么办?
异常 throw 怎么办?
外层构造失败时已构造成员怎么办?
它将这些问题绑定到:
对象生命周期
而不是散落在每一条控制流路径中。
22. RAII 不能自动保证什么
用了 RAII 并不意味着程序自动安全。
RAII 不能自动替你决定:
线程应该如何停止
Socket 应该怎样正确关闭
事务失败后应该怎样恢复
关闭失败是否可以重试
资源状态如何定义
底层 API 的失败语义是什么
Wrapper 自己构造到一半失败怎么办
例如:
~Worker()
{
thread_.join();
}
形式上是 RAII。
但线程如果永远不退出,析构就可能永远阻塞。
因此:
RAII 提供生命周期管理机制,但不能代替资源本身正确的终止协议。
23. 业务对象与 RAII Wrapper
例如:
class Device
{
private:
Socket socket_;
std::vector<char> buffer_;
std::string name_;
};
Device 自己未必是专门的资源 Wrapper。
更准确地说:
Device通过组合 RAII 成员获得资源安全特性。
而:
Socket
FileHandle
MappedMemory
Transaction
通常更适合称为:
RAII Wrapper
RAII 类型
因为它们直接封装一种资源或收尾责任。
推荐结构:
Device
├── Socket
│ └── fd
├── FileHandle
│ └── FILE*
├── vector
│ └── dynamic storage
└── string
└── character storage
即:
外层对象负责"拥有谁",底层 RAII Wrapper 负责"怎么释放"。
24. Rule of Zero 的影子
如果业务类:
class Device
{
private:
Socket socket_;
std::vector<char> buffer_;
std::string name_;
};
所有成员都能够正确管理自己的资源,那么 Device 通常不需要自己写:
~Device()
编译器生成的析构函数会自动按照规则析构成员。
所以:
"没有手写析构函数"不等于"没有析构"。
这也是后续 Rule of Zero 的重要基础:
尽量让资源管理发生在专门的 RAII 类型中,让业务类型通过组合获得正确的特殊成员函数行为。
25. 实验 02-1:裸资源 + 构造异常
25.1 实验目的
验证:
对象构造函数抛异常
↓
对象自己的析构函数不执行
↓
构造过程中获得的裸资源可能泄漏
25.2 测试代码
#include <iostream>
#include <stdexcept>
class RawBuffer
{
public:
RawBuffer()
{
std::cout << "RawBuffer: constructor begin\n";
data_ = new int[100];
std::cout << "RawBuffer: resource acquired\n";
throw std::runtime_error("RawBuffer initialization failed");
}
~RawBuffer()
{
std::cout << "RawBuffer: destructor\n";
delete[] data_;
}
private:
int* data_ = nullptr;
};
void test_raw_resource()
{
try
{
RawBuffer buffer;
}
catch (const std::exception& e)
{
std::cout << "caught: " << e.what() << '\n';
}
}
int main()
{
test_raw_resource();
}
25.3 编译
树莓派 GCC 8:
g++ -std=c++17 -g -O0 -fsanitize=address -fno-omit-frame-pointer main.cpp -o raii_test
运行时本机出现:
ASan runtime does not come first in initial library list
因此本次实验使用:
LD_PRELOAD=$(gcc -print-file-name=libasan.so) ./raii_test
只对当前命令预加载 ASan,不建议永久全局设置 LD_PRELOAD。
25.4 实际输出
RawBuffer: constructor begin
RawBuffer: resource acquired
caught: RawBuffer initialization failed
=================================================================
ERROR: LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s) allocated from:
#0 operator new[]
#1 RawBuffer::RawBuffer() .../main.cpp:11
#2 test_raw_resource() .../main.cpp:32
#3 main .../main.cpp:41
SUMMARY: AddressSanitizer: 400 byte(s) leaked in 1 allocation(s).
25.5 分析
没有出现:
RawBuffer: destructor
证明 RawBuffer 没有完成构造,因此自己的析构函数没有执行。
泄漏大小:
100 × sizeof(int)
= 100 × 4
= 400 bytes
与 LeakSanitizer 报告完全对应。
调用栈:
main
↓
test_raw_resource
↓
RawBuffer::RawBuffer
↓
operator new[]
阅读 ASan / LSan 报告时,应优先向下寻找:
第一条属于自己源码的位置。
本实验中就是:
RawBuffer::RawBuffer() main.cpp:11
26. 实验 02-2:RAII 成员与外层构造失败
26.1 实验目的
验证:
外层对象构造失败时,已经完成构造的成员仍会在异常展开中析构。
26.2 测试代码
#include <iostream>
#include <stdexcept>
class Buffer
{
public:
Buffer()
{
std::cout << "Buffer: constructor\n";
data_ = new int[100];
std::cout << "Buffer: resource acquired\n";
}
~Buffer() noexcept
{
std::cout << "Buffer: destructor, release resource\n";
delete[] data_;
}
private:
int* data_ = nullptr;
};
class Device
{
public:
Device()
{
std::cout << "Device: constructor body\n";
throw std::runtime_error("Device initialization failed");
}
~Device()
{
std::cout << "Device: destructor\n";
}
private:
Buffer buffer_;
};
int main()
{
try
{
Device device;
}
catch (const std::exception& e)
{
std::cout << "caught: " << e.what() << '\n';
}
}
26.3 预测
Buffer 析构:执行
Device 析构:不执行
LeakSanitizer:不应报告泄漏
26.4 实际输出
Buffer: constructor
Buffer: resource acquired
Device: constructor body
Buffer: destructor, release resource
caught: Device initialization failed
没有 LeakSanitizer 报告。
26.5 分析
Buffer 完成构造
↓
Device 构造函数体
↓
throw
↓
Device 未完成构造
↓
~Device() 不执行
↓
buffer_ 已完成构造
↓
~Buffer()
↓
delete[] data_
实验结果与理论一致。
27. 实验 02-3:多成员构造失败与析构顺序
27.1 第一轮
成员声明:
Resource first_;
Resource second_;
Resource third_;
初始化:
Device()
: first_("A"),
second_("B"),
third_(createThird())
{
}
createThird():
static Resource createThird()
{
std::cout << "try to construct C\n";
throw std::runtime_error("C failed");
}
预测:
A 成功
B 成功
C 失败
B 析构
A 析构
Device 不析构
原因:
成员按声明顺序构造
构造失败时只析构已经完成构造的成员
已构造成员按逆序析构
27.2 第二轮:故意打乱初始化列表与声明顺序
成员改为:
Resource third_;
Resource first_;
Resource second_;
初始化列表仍然:
Device()
: first_("A"),
second_("B"),
third_(createThird())
{
}
实际输出:
try to construct C
caught: C failed
没有:
construct: A
construct: B
destroy: A
destroy: B
说明真正决定成员构造顺序的是:
类中的成员声明顺序,而不是成员初始化列表的书写顺序。
因此实际工程中,初始化列表最好也按照成员声明顺序书写,避免阅读误导和
-Wreorder 警告。
28. 实验 02-4:自定义 FileHandle
28.1 目标
自己封装:
std::FILE*
要求:
fopen 失败
→ 构造失败
构造成功
→ file_ 有效
close()
→ 显式关闭
→ 进入 Empty
重复 close()
→ 安全
不主动 close()
→ 析构兜底
close 后析构
→ 不重复 fclose
28.2 头文件
#pragma once
#include <cstdio>
class FileHandle
{
public:
explicit FileHandle(const char* path);
~FileHandle() noexcept;
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
bool isOpen() const;
void close();
private:
std::FILE* file_ = nullptr;
};
28.3 实现
本次实验实现:
#include "FileHandle.h"
#include <iostream>
#include <stdexcept>
FileHandle::FileHandle(const char* path)
: file_(std::fopen(path, "rb"))
{
if (file_ == nullptr)
{
throw std::runtime_error("打开文件失败");
}
std::cout << "FileHandle acquired\n";
}
FileHandle::~FileHandle() noexcept
{
close();
std::cout << "FileHandle released\n";
}
bool FileHandle::isOpen() const
{
return file_ != nullptr;
}
void FileHandle::close()
{
if (file_ != nullptr)
{
std::fclose(file_);
file_ = nullptr;
}
}
这版用于本课验证 RAII 生命周期机制。
工程级实现还需要进一步考虑 std::fclose()
失败时的错误报告,以及析构函数不能传播异常的问题。
28.4 外层对象构造失败测试
class ConfigLoader
{
public:
ConfigLoader()
: file_("test.txt")
{
std::cout << "ConfigLoader constructor\n";
throw std::runtime_error("ConfigLoader failed");
}
~ConfigLoader()
{
std::cout << "ConfigLoader destructor\n";
}
private:
FileHandle file_;
};
28.5 实际输出
FileHandle acquired
ConfigLoader constructor
FileHandle released
caught: ConfigLoader failed
没有:
ConfigLoader destructor
也没有资源泄漏报告。
28.6 分析
FileHandle 成功构造
↓
成功获得 FILE*
↓
ConfigLoader 构造函数体
↓
throw
↓
ConfigLoader 未完成构造
↓
~ConfigLoader() 不执行
↓
file_ 成员已经完成构造
↓
~FileHandle()
↓
fclose()
这正是 RAII 在实际代码中的核心价值。
29. 声明与实现分离时遇到的链接问题
实验中使用:
FileHandle.h
FileHandle.cpp
main.cpp
最初编译:
g++ -std=c++17 -g -O0 -fsanitize=address -fno-omit-frame-pointer main.cpp -o raii_test
出现:
undefined reference to `FileHandle::FileHandle(char const*)'
undefined reference to `FileHandle::isOpen() const'
undefined reference to `FileHandle::close()'
undefined reference to `FileHandle::~FileHandle()'
原因不是头文件找不到。
头文件已经让编译器知道:
这些函数存在,并且具有这些声明
但链接器没有看到:
FileHandle.cpp 中的函数定义
正确编译:
g++ -std=c++17 -g -O0 -fsanitize=address -fno-omit-frame-pointer \
main.cpp FileHandle.cpp \
-o raii_test
因此可以快速区分:
fatal error: FileHandle.h: No such file or directory
→ 头文件搜索 / 编译阶段问题
undefined reference to FileHandle::...
→ 函数实现没有进入最终链接
这是本次 RAII 实验顺带验证的 C++ 编译链接基础。
30. 本课常见误区总结
30.1 RAII 就是智能指针
错误。
智能指针只是 RAII 的一种实现。
30.2 写了析构函数就是安全 RAII
错误。
Wrapper 自己构造到一半失败时,它自己的析构函数不会执行。
30.3 RAII 对象不能持有裸指针
错误。
最终资源 Wrapper 可以直接持有裸资源。
30.4 RAII 能自动解决异常安全
错误。
Wrapper 的状态转换和底层资源终止协议仍需要正确设计。
30.5 析构函数适合处理所有关闭错误
错误。
析构适合做可靠兜底,不适合传播复杂错误。
30.6 手动释放后 Wrapper 不需要更新状态
错误。
真实资源状态和 Wrapper 内部状态必须保持一致。
30.7 资源只指内存、文件、Socket
错误。
锁所有权、事务责任、线程收尾责任也可以成为 RAII 管理对象。
31. 一个通用 RAII 设计检查表
以后设计任何资源 Wrapper,可以依次问:
1. 我获得了什么资源或责任?
2. 谁拥有它?
3. 对象成功构造后允许哪些状态?
4. 哪些状态表示当前仍承担收尾责任?
5. 哪些操作会改变所有权或有效状态?
6. 状态变化是否全部经过 Wrapper?
7. 正常离开作用域时怎么收尾?
8. 提前 return 时怎么收尾?
9. throw 时怎么收尾?
10. Wrapper 自己构造到一半失败怎么办?
11. 显式 close / commit / unlock 后状态如何变化?
12. 析构函数能否可靠完成兜底?
13. 析构是否可能传播异常?
14. 这个资源能不能 copy?
15. 这个资源能不能 move?
其中最后两个问题正好连接下一课。
32. 本课最终结论
结论一
RAII 的核心不是智能指针,也不是简单的:
构造申请
析构释放
而是:
将资源或必须收尾的责任绑定到对象生命周期。
结论二
RAII Wrapper 通常还必须维护:
所有权状态
有效状态
是否仍承担收尾责任
结论三
资源管理责任应尽可能下沉:
业务对象
↓
组合 RAII Wrapper
↓
Wrapper 管理裸资源
即:
外层对象负责"拥有谁",RAII Wrapper 负责"怎么释放"。
结论四
构造异常时:
未完成构造的对象
→ 自己的析构不执行
已经完成构造的成员
→ 参与异常展开并析构
结论五
RAII 能保证:
在对象生命周期结束时自动触发预先设计好的收尾机制。
但它不能保证:
收尾协议本身一定正确。
线程停止、事务恢复、资源状态、错误处理等仍然需要根据底层 API 语义正确设计。
结论六
业务类型如果全部由正确的 RAII 成员组成,往往不需要自己管理裸资源,也不需要手写析构函数。
这正是 Rule of Zero 的基础。
33. 第 02 课验收
本课理论与实验均已完成:
☑ 理解 RAII 的完整含义
☑ 区分 RAII 与智能指针
☑ 理解资源 / 收尾责任
☑ 理解所有权与有效状态
☑ 理解构造失败时的异常展开
☑ 验证裸资源构造异常泄漏
☑ 使用 LeakSanitizer 定位 400 字节泄漏
☑ 验证已构造成员自动析构
☑ 验证多成员逆序析构
☑ 验证成员构造顺序由声明顺序决定
☑ 理解显式 close 与析构兜底
☑ 理解析构函数的 noexcept 原则
☑ 理解锁的 RAII
☑ 理解线程 stop / join 的职责区别
☑ 理解事务 commit / rollback 的 RAII 建模
☑ 自己实现 std::FILE* 的 FileHandle
☑ 使用头文件 / cpp 分离实现
☑ 理解 undefined reference 链接错误
至此:
☑ 第 02 课:RAII 与资源管理
下一课:
第 03 课:拷贝、移动与 Rule of 0/5
而本课的 FileHandle 将继续作为下一课的实验对象:
FileHandle a("a.txt");
FileHandle b = a; // 为什么危险?
FileHandle c = std::move(a); // 所有权应该如何转移?
RAII 解决了"资源应该跟谁走"。
下一课继续解决:
当 C++ 对象发生复制和移动时,资源所有权应该怎么走?