标准库提供了大量高效管理内存的类,仅标准容器就是其中的典范。本节将简要介绍几种资源管理类型,虽非详尽列举,但力求展现RAII范式的多样化应用。
设计自动化资源管理类型时,其核心行为通过6个特殊成员函数体现。因此,需要逐一分析以下类型中这些函数的语义特性:
本节简要介绍C++标准库中两种核心智能指针类型:std::unique_ptr<T>与std::shared_ptr<T>,重点阐述其核心设计理念(具体用法详见第5章,第6章将实现其简化版本)。
std::unique_ptr<T>(本章前文已出现示例)实现“资源独占所有权”语义:
不可复制构造/赋值
管理T*指针并在生命周期结束时销毁目标对象
默认使用delete释放资源,支持自定义删除器
默认构造的智能指针表现为空状态(类似nullptr)
移动操作转移所有权,移后源对象转为空状态
析构函数自动释放所持资源
std::shared_ptr<T>实现“资源共享所有权”语义:
多个智能指针可共同管理同一对象
最后一个持有者负责资源释放
实现复杂度较高(第6章将揭示实现机制)
适用于所有权最终持有者不可预知的场景(常见于多线程代码)
默认构造同样表现为空状态
复制操作共享资源(左值先释放原资源)
移动操作转移所有权,移后源对象转为空状态
析构函数释放资源所有权,若为最后持有者则销毁资源
关于std::shared_ptr名称中“共享”的含义,常存在理解偏差。例如:
是否需要在调用方与被调用方间传递指针的情况都该使用?
当使用端代码复制指针以共享目标对象时(如传值方式传递指针,或访问全局管理器中的资源)是否适用?
简而言之,这种思考方式本质是错误的。共享动态分配资源不等于共同拥有资源:
std::shared_ptr仅建模后者(共同所有权)
前者完全可用更轻量级的类型实现
我们将在第5章从使用视角深入探讨该问题,并在第6章通过实现者的视角重新审视,从而建立对这类深层技术问题的系统认知。
拥有资源不仅限于拥有内存,假设string_mutator是一个用于对字符串中的字符执行任意转换的类,但预期在多线程环境中使用,所以需要对该字符串对象的访问进行同步:
#include <thread>
#include <mutex>
#include <string>
#include <algorithm>
#include <string_view>
class string_mutator {
std::string text;
mutable std::mutex m;
public:
// 注意:m是不可复制的,所以string_mutator也是不可复制的
string_mutator(std::string_view src)
: text{ src.begin(), src.end() } {
}
template <class F> void operator()(F f) {
m.lock();
std::transform(text.begin(), text.end(),
text.begin(), f);
m.unlock();
}
std::string grab_snapshot() const {
m.lock();
std::string s = text;
m.unlock();
return s;
}
}
string_mutator对象的函数调用操作符接受一个可应用于char类型,并返回可转换为char类型的任意函数f,然后将f应用于序列中的每个字符。例如,以下调用将显示“I LOVE MY INSTRUCTOR”:
// ...
string_mutator sm{ "I love my instructor" };
sm([](char c) {
return static_cast<char>(std::toupper(c));
});
std::cout << sm.grab_snaphot();
// ...
由于 string_mutator::operator()(F) 可以接受任何符合签名的函数作为参数,因此也可能接受一个可能抛出异常的函数。观察该操作符的实现,在当前(简单)的实现中,如果发生异常,则会锁定 m 但永不解锁,这显然有问题。
有些语言提供了专门的语法结构来解决这个问题。C++不需要这样的特殊支持,只需编写一个在构造时锁定互斥量、在析构时解锁的对象,就能自然地写出健壮的代码。C++ 中最简单的这类类型是 std::lock_guard<M>,其实现可能如下所示:
template <class M>
class lock_guard { // 简化版本
M &m;
public:
lock_guard(M &m) : m { m } { m.lock(); }
~lock_guard() { m.unlock(); }
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
}
最简单的类型往往是最好的。将这个类型应用到 string_mutator 示例中,最终得到了一个更简洁且更健壮的实现:
#include <thread>
#include <mutex>
#include <string>
#include <algorithm>
#include <string_view>
class string_mutator {
std::string text;
mutable std::mutex m;
public:
// 注意:m 是不可复制的,因此 string_mutator 也是不可复制的
string_mutator(std::string_view src)
: text{ src.begin(), src.end() } {
}
template <class F> void operator()(F f) {
std::lock_guard lck{ m };
std::transform(text.begin(), text.end(),
text.begin(), f);
} // 隐式 m.unlock
std::string grab_snapshot() const {
std::lock_guard lck{ m };
return text;
} // 隐式 m.unlock
}
显然,使用析构函数自动解锁互斥量在这种情况下非常有利:简化了代码,并使其具备异常安全性。
C++ 中,流对象同样是资源的所有者。考虑以下代码示例,将文件 in.txt 的每个字节复制到标准输出流:
#include <fstream>
#include <iostream>
int main() {
std::ifstream in{ "in.txt" };
for(char c; in.get(c); )
std::cout << c;
}
这段代码中有几个有趣的细节:
未调用 close()
没有 try 块来管理异常
没有显式调用 open() 来打开文件
没有显式检查文件结束状态……
然而,这段代码运行正确,完成了它应该做的事情,并且没有泄漏资源。
如此简单的程序是如何做到这些的? 答案是“析构函数的魔法”,或者更准确地说,良好的 API 设计。现在,来仔细分析一下:
构造函数的作用是让对象处于正确的初始状态。因为先默认构造流对象再手动打开既低效又多余,因此用它来打开文件。
流读取错误并不罕见。C++ 中从流中读取(如 in.get(c))会返回流的引用,如果流处于错误状态(如文件结束或读取失败),它会在布尔上下文中表现为 false,因此 while (in.get(c)) 会自动处理错误状态。
析构函数负责清理资源。流对象的析构函数会自动关闭它所管理的底层流。通常,显式调用 close() 是不必要的 —— 只需让流对象在有限的作用域内生存,析构时就会自动释放资源。
当正确使用时,析构函数(和构造函数!)能让代码更健壮、更简洁。RAII模式是 C++ 资源管理的核心机制,确保资源在对象生命周期结束时自动释放,即使发生异常也不会泄漏。
我们不会对容器(如 std::vector、std::list)与原始数组或其他底层结构(如手动管理的链表或通过使用端代码显式维护的动态数组)进行全面比较。不过,在本书后续章节(第12、13和14章)中,当深入地了解内存管理技术后,将探讨如何编写类似 std::vector 或 std::list 的容器。
但使用 std::vector<T>(举例而言)不仅比手动管理动态分配的 T 数组更简单、更安全 —— 在实践中,可能也更快,至少在合理使用时是如此。普通开发者在日常编码中几乎不可能像标准库容器那样,在内存管理、对象构造、析构、复制或移动操作上投入同等的细致考量。这些容器的析构函数,连同其他特殊成员函数的实现方式,使得它们几乎像 int 对象一样易于使用。如果这都不算一个值得追求的目标,那还有什么能算呢?
简而言之,标准库容器通过自动化资源管理(RAII)和高度优化的底层实现,在安全性、简洁性和性能上均优于手动管理的数据结构,这也是为什么现代 C++ 强烈推荐使用它们,而非裸数组或手动内存操作。