$ cat ~ / posts /c++ /oop5 3.9k Words ~ 15 Mins
cover.png
面向对象程序设计05

#面向对象程序设计05

exdoubled Lv5

RAII = 析构函数的确定性 + 所有权的清晰归属

析构函数的确定性解决“什么时候释放”的问题,所有权解决“谁负责释放”的问题

二者合在一起,构成现代 C++ 资源管理的基础

为什么手动管理资源总是出错

手动管理资源的结构性缺陷在于:资源获取和资源释放分离在代码的两个位置,中间隔着任意多的逻辑、分支和异常路径,程序员必须保证每条路径都能走到释放语句,一旦遗漏,就会泄漏

1
2
3
4
5
6
7
8
9
10
11
void process(size_t size) {
char* buf = new char[size];

if (!validate(buf)) {
return; // buf 没有 delete[],资源泄漏
}

parse(buf); // 如果这里抛异常,也会跳过释放

delete[] buf;
}

与泄漏相对的是悬空指针

它发生在资源已经释放,但指针或引用还被继续使用时:

1
2
3
4
char* buf = new char[1024];
delete[] buf;

buf[0] = 'X'; // 未定义行为:访问已释放内存

悬空指针危险在于结果不可预测

程序可能立刻崩溃,也可能暂时看起来正常,最后在完全不相关的位置出错

还有一种更隐蔽的悬空引用:

1
2
3
4
5
6
7
8
std::string* p = nullptr;

{
std::string s = "hello";
p = &s;
}

std::cout << p->size(); // 未定义行为:s 已经析构

这类问题的共同根源都是生命周期没有被类型系统表达出来

指针本身只表示“这里有个地址”,不表示“谁拥有这个对象”,也不表示“这个对象还能活多久”

RAII

RAII 是 Resource Acquisition Is Initialization,即“资源获取即初始化”

它的基本规则是:

  • 构造函数中获取资源
  • 析构函数中释放资源
  • 对象生命周期绑定资源生命周期。

这样一来,释放资源不再依赖程序员在每个分支末尾手写清理语句,而是依赖 C++ 对局部对象析构的语言保证

栈展开是 RAII 成立的底层保证

当 C++ 程序抛出异常时,运行时会沿调用栈向外寻找匹配的 catch

在向外传播的过程中,已经构造完成的局部对象会按照构造的逆序依次析构,这个过程叫栈展开

RAII 利用这个机制:只要清理逻辑写在析构函数中,无论函数正常返回、提前返回,还是因为异常退出,只要对象已经构造完成,它的析构函数就会被调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class FileGuard {
public:
explicit FileGuard(const char* path)
: file_(std::fopen(path, "r")) {
if (!file_) {
throw std::runtime_error("open failed");
}
}

~FileGuard() noexcept {
if (file_) {
std::fclose(file_);
}
}

FILE* get() const noexcept {
return file_;
}

FileGuard(const FileGuard&) = delete;
FileGuard& operator=(const FileGuard&) = delete;

private:
FILE* file_{};
};

void withRAII() {
FileGuard file("data.txt");
processFile(file.get());
}

第一,FileGuard 构造成功之后,file_ 就一定由这个对象负责关闭,调用者不需要记住 fclose

第二,如果 processFile 抛出异常,file 仍然会在栈展开时析构,所以文件句柄不会泄漏

第三,拷贝被禁止。因为两个 FileGuard 如果持有同一个 FILE*,离开作用域时就会关闭两次同一个句柄,这是未定义行为

析构函数不能抛异常

析构函数是清理资源的最后防线

它的职责是尽力释放资源,而不是向外报告失败,尤其在栈展开过程中,如果析构函数再次抛出异常,C++ 运行时会调用 std::terminate 终止程序

因此资源类的析构函数应当写成 noexcept,内部处理所有失败情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Connection {
public:
explicit Connection(std::string host)
: handle_(connect(host)) {
if (!handle_) {
throw std::runtime_error("connect failed");
}
}

~Connection() noexcept {
disconnect(handle_); // 不把错误继续抛出
}

void close() {
if (!disconnect(handle_)) {
throw std::runtime_error("close failed");
}
handle_ = nullptr;
}

private:
SocketHandle handle_{};
};

如果释放操作本身可能失败,可以提供显式的 close() 让调用者主动处理错误;析构函数只负责兜底清理,并且不能抛异常

轻量级 RAII:一次性清理

有时资源清理只出现一次,不值得专门写一个完整资源类,可以使用类似 gsl::finally 的作用域守卫,在离开作用域时执行一个 lambda:

1
2
3
4
5
6
7
8
9
10
11
12
void process(const char* path) {
FILE* f = std::fopen(path, "r");
if (!f) {
throw std::runtime_error("open failed");
}

auto cleanup = gsl::finally([f] {
std::fclose(f);
});

parseData(f);
}

这本质上还是 RAII:cleanup 是一个局部对象,它在析构时执行保存的 callable

适合 C API 对接、临时代码、一次性清理。若资源类型会反复出现,仍应封装成完整 RAII 类

所有权归属

RAII 解决“释放动作怎么自动发生”,所有权解决“谁有资格释放”

没有所有权语义,RAII 类也可能被错误复制,最后导致双重释放

所谓所有权,就是谁负责在正确时机销毁资源

现代 C++ 中常见的所有权关系有三种:唯一所有权、共享所有权、非拥有访问

唯一所有权:unique_ptr

唯一所有权表示同一时刻只有一个对象拥有资源,资源可以转移,但不能复制

std::unique_ptr 是这种语义的标准工具

1
2
3
4
5
6
7
8
9
auto p1 = std::make_unique<Widget>(42);

auto p2 = std::move(p1); // 所有权转移

if (!p1) {
std::cout << "p1 no longer owns the object\n";
}

p2->run(); // p2 是唯一所有者

unique_ptr 重点是用类型系统禁止意外共享

不能写 auto p2 = p1;,因为这会要求复制所有权,必须显式写出 std::move,让所有权流动在代码中可见

默认情况下应优先选择 unique_ptr,它表达清晰,运行时代价接近裸指针,不需要引用计数,也不会产生循环引用问题

共享所有权:shared_ptr

共享所有权表示多个对象共同决定资源生命周期,最后一个拥有者离开时,资源才会释放std::shared_ptr 通过引用计数实现这一语义:

1
2
3
4
5
6
7
8
auto s1 = std::make_shared<Widget>(42);  // 引用计数 = 1

{
auto s2 = s1; // 引用计数 = 2
s2->run();
} // s2 析构,引用计数 = 1

// s1 离开作用域时,引用计数 = 0,Widget 被销毁

shared_ptr 解决问题:当一个资源没有自然的单一所有者时,共享生命周期是合理的

但它不是默认选择,引用计数有成本,通常还涉及原子操作;更重要的是,它会让所有权关系变得模糊

如果一个类里到处都是 shared_ptr,通常说明设计还没有回答“谁真正拥有这些对象”:

1
2
3
4
5
class Engine {
std::shared_ptr<Config> config_;
std::shared_ptr<Logger> logger_;
std::shared_ptr<Database> db_;
};

这并不一定错误,但应该追问:这些对象真的都需要和外部共享生命周期吗?如果只是借用,使用引用或裸指针更诚实;如果有单一所有者,使用 unique_ptr 更清楚

非拥有访问:weak_ptr

weak_ptr 观察一个由 shared_ptr 管理的对象,但不拥有它,也不增加引用计数

典型用途是表达“我可能需要访问它,但我不负责延长它的生命周期”

1
2
3
4
5
6
7
std::weak_ptr<Widget> weak = s1;

if (auto locked = weak.lock()) {
locked->run(); // locked 是 shared_ptr,对象在本作用域内保证存活
} else {
std::cout << "object already destroyed\n";
}

使用 weak_ptr 时,应该通过 lock() 一步完成“检查并获取”,不要先 expired() 再访问,因为检查和使用之间对象可能已经被其他线程释放

1
2
3
if (auto locked = weak.lock()) {
locked->run();
}

weak_ptr 还可以打破循环引用:

1
2
3
4
5
struct Node {
std::string name;
std::shared_ptr<Node> child;
std::weak_ptr<Node> parent;
};

如果 parent 也是 shared_ptr,父子互相持有,引用计数永远无法归零

改成 weak_ptr 后,父节点拥有子节点,子节点只观察父节点,生命周期关系就恢复为单向

make_uniquemake_shared

创建智能指针时,优先使用 make_uniquemake_shared,不要直接写 new

1
2
auto p = std::make_unique<Widget>(42);
auto s = std::make_shared<Widget>(42);

原因:

第一,语义更集中。资源的分配和智能指针的构造写在同一个表达式中,不暴露裸指针

第二,异常安全更好。在 C++17 之前,函数实参的求值顺序不完全确定,直接 new 可能出现资源已经分配但智能指针还没构造,另一个实参先抛异常的窗口:

1
func(std::unique_ptr<Widget>(new Widget()), getAnotherArg());

new Widget() 已经执行,getAnotherArg() 随后抛出异常,而 unique_ptr 尚未构造,就会泄漏。make_unique 把分配和托管封装为一个步骤,避免这种窗口。

shared_ptr 来说,make_shared 通常还能把对象和控制块放在一次分配中,减少分配次数。不过如果需要自定义删除器、特殊内存布局或弱引用释放时机控制,就要根据具体场景判断。

非内存资源同样需要 RAII

RAII 不只是内存管理技术,凡是需要成对操作的资源,都应该让对象生命周期托管

文件句柄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class File {
public:
explicit File(const char* path)
: fp_(std::fopen(path, "r")) {
if (!fp_) {
throw std::runtime_error("open failed");
}
}

~File() noexcept {
if (fp_) {
std::fclose(fp_);
}
}

FILE* get() const noexcept {
return fp_;
}

File(const File&) = delete;
File& operator=(const File&) = delete;

File(File&& other) noexcept
: fp_(std::exchange(other.fp_, nullptr)) {}

File& operator=(File&& other) noexcept {
if (this != &other) {
if (fp_) {
std::fclose(fp_);
}
fp_ = std::exchange(other.fp_, nullptr);
}
return *this;
}

private:
FILE* fp_{};
};

文件句柄适合“不可复制、可移动”。复制没有意义,因为两个对象关闭同一个句柄会出错;移动有意义,因为可以把句柄的责任转交出去

互斥锁也必须用 RAII 管理,手动 lock() / unlock() 和手动 new / delete 有同样的问题:中间任何异常都会跳过解锁

1
2
3
4
5
6
std::mutex m;

void update() {
std::lock_guard<std::mutex> guard(m);
modifySharedState();
} // guard 析构,自动 unlock

std::lock_guard 的作用是让“离开作用域必定解锁”成为保证

异常安全与资源类边界

对象一旦构造成功,就应该始终保持有效状态。成员函数不能因为异常把对象破坏成半残状态

一个典型反例是朴素的拷贝赋值:

1
2
3
4
5
6
7
8
9
10
11
12
Buffer& Buffer::operator=(const Buffer& rhs) {
if (this == &rhs) {
return *this;
}

delete[] data_;
data_ = new char[rhs.size_]; // 如果这里抛异常,this 已经被破坏
size_ = rhs.size_;
std::copy(rhs.data_, rhs.data_ + size_, data_);

return *this;
}

这段代码先删除旧资源,再申请新资源。如果 new 抛出异常,对象原来的数据已经丢失,data_ 还可能悬空,对象不再有效

更稳妥的做法是 copy-and-swap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Buffer {
public:
Buffer& operator=(const Buffer& rhs) {
if (this == &rhs) {
return *this;
}

Buffer temp(rhs); // 先构造副本,失败则 this 不变
swap(temp); // swap 不应抛异常
return *this; // temp 析构,释放旧资源
}

void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}

private:
size_t size_{};
char* data_{};
};

这个顺序体现了异常安全的基本判断:危险操作先在临时对象上完成,成功之后再用不抛异常的操作替换当前对象,若构造 temp 失败,当前对象完全不变

移动构造和移动赋值也应该尽量 noexcept

移动的本质是接管已有资源,不应该重新分配大块内存

如果移动操作会抛异常,标准容器在扩容时可能退回到复制,影响性能和语义

三法则、五法则与零法则

当一个类手动管理资源时,特殊成员函数必须一起考虑

三法则:如果你自定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个,通常需要同时考虑另外两个

原因:既然需要手动析构,说明类里有资源;有资源就必须定义复制时到底是深拷贝、禁止拷贝,还是共享

C++11 之后还要考虑移动构造和移动赋值,于是变成五法则:

1
2
3
4
5
6
7
8
9
10
class Resource {
public:
~Resource();

Resource(const Resource&);
Resource& operator=(const Resource&);

Resource(Resource&&) noexcept;
Resource& operator=(Resource&&) noexcept;
};

但现代 C++ 更推荐零法则:业务类不要直接管理裸资源,而是把资源交给已经正确实现 RAII 的成员

1
2
3
4
5
6
7
8
9
10
class Person {
public:
Person(std::string name, std::string email, int age)
: name_(std::move(name)), email_(std::move(email)), age_(age) {}

private:
std::string name_;
std::string email_;
int age_{};
};

std::string 已经正确处理构造、析构、复制和移动,所以 Person 不需要写任何特殊成员函数,让成员自己管理自己的资源,组合出来的类也自然安全

资源类应该尽量少,边界应该清楚:底层少数类负责封装 FILE*、socket、mutex、裸内存等资源;上层业务类使用 std::stringstd::vectorunique_ptrshared_ptr 等 RAII 类型

这样才能把复杂性限制在边界处,而不是扩散到整个程序

通过接口表达所有权意图

函数签名应该诚实表达所有权关系

调用者不应该靠猜测或注释判断一个函数会不会接管资源

参数或返回类型所有权语义适用场景
const T&只读借用查询、打印、不修改对象
T&可写借用修改对象,但不接管生命周期
T拥有一份值需要副本,或通过移动接收值
std::unique_ptr<T>转移唯一所有权函数接管资源
std::shared_ptr<T>共享所有权函数需要延长对象生命周期
std::weak_ptr<T>非拥有观察对象可能失效,需要检查

例如:

1
2
3
4
void print(const Buffer& buf);                 // 借用
void resize(Buffer& buf, size_t n); // 借用并修改
void store(std::unique_ptr<Buffer> buf); // 接管所有权
void cache(std::shared_ptr<Buffer> buf); // 共享生命周期

如果函数只是读取对象,却要求调用者传入 unique_ptr,就是过度要求;如果函数会把对象保存到异步任务里,却只接受 const T&,就是隐藏生命周期依赖

好的接口应该让所有权流动从类型上读得出来

设计判断

面对资源管理问题,可以按下面的顺序判断:

第一,能不用裸资源就不用裸资源。优先使用标准库 RAII 类型,例如 std::vectorstd::stringstd::fstreamstd::lock_guardstd::unique_ptr

第二,若必须封装裸资源,先判断资源是否可复制。文件句柄、锁、socket 往往不可复制;堆上 buffer 如果要表现为值,则需要深拷贝;现实世界中的实体对象,如宠物、窗口、连接,通常不应该复制

第三,默认使用唯一所有权。只有当生命周期确实由多个拥有者共同决定时,才使用 shared_ptr

第四,借用就写引用或裸指针,不要为了“安全感”滥用 shared_ptr。安全来自清楚的生命周期设计,而不是把所有东西都共享

第五,析构只负责兜底清理。需要报告失败的关闭操作应通过显式成员函数完成

$ discussion
# Comments
waline