4.6. 标准资源管理工具集

标准库提供了大量高效管理内存的类,仅标准容器就是其中的典范。本节将简要介绍几种资源管理类型,虽非详尽列举,但力求展现RAII范式的多样化应用。

设计自动化资源管理类型时,其核心行为通过6个特殊成员函数体现。因此,需要逐一分析以下类型中这些函数的语义特性:

4.6.1 unique_ptr<T> 和 shared_ptr<T>

本节简要介绍C++标准库中两种核心智能指针类型:std::unique_ptr<T>与std::shared_ptr<T>,重点阐述其核心设计理念(具体用法详见第5章,第6章将实现其简化版本)。

std::unique_ptr<T>(本章前文已出现示例)实现“资源独占所有权”语义:

std::shared_ptr<T>实现“资源共享所有权”语义:

shared_ptr中的“共享”是什么意思?

关于std::shared_ptr名称中“共享”的含义,常存在理解偏差。例如:

  • 是否需要在调用方与被调用方间传递指针的情况都该使用?

  • 当使用端代码复制指针以共享目标对象时(如传值方式传递指针,或访问全局管理器中的资源)是否适用?

简而言之,这种思考方式本质是错误的。共享动态分配资源不等于共同拥有资源:

  • std::shared_ptr仅建模后者(共同所有权)

  • 前者完全可用更轻量级的类型实现

我们将在第5章从使用视角深入探讨该问题,并在第6章通过实现者的视角重新审视,从而建立对这类深层技术问题的系统认知。

4.6.2 lock_guard 和 scoped_lock

拥有资源不仅限于拥有内存,假设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
}

显然,使用析构函数自动解锁互斥量在这种情况下非常有利:简化了代码,并使其具备异常安全性。

4.6.3 流对象

C++ 中,流对象同样是资源的所有者。考虑以下代码示例,将文件 in.txt 的每个字节复制到标准输出流:

#include <fstream>
#include <iostream>

int main() {
  std::ifstream in{ "in.txt" };
  for(char c; in.get(c); )
    std::cout << c;
}

这段代码中有几个有趣的细节:

然而,这段代码运行正确,完成了它应该做的事情,并且没有泄漏资源。

如此简单的程序是如何做到这些的? 答案是“析构函数的魔法”,或者更准确地说,良好的 API 设计。现在,来仔细分析一下:

当正确使用时,析构函数(和构造函数!)能让代码更健壮、更简洁。RAII模式是 C++ 资源管理的核心机制,确保资源在对象生命周期结束时自动释放,即使发生异常也不会泄漏。

4.6.4 vector<T> 和其他容器

我们不会对容器(如 std::vector、std::list)与原始数组或其他底层结构(如手动管理的链表或通过使用端代码显式维护的动态数组)进行全面比较。不过,在本书后续章节(第12、13和14章)中,当深入地了解内存管理技术后,将探讨如何编写类似 std::vector 或 std::list 的容器。

但使用 std::vector<T>(举例而言)不仅比手动管理动态分配的 T 数组更简单、更安全 —— 在实践中,可能也更快,至少在合理使用时是如此。普通开发者在日常编码中几乎不可能像标准库容器那样,在内存管理、对象构造、析构、复制或移动操作上投入同等的细致考量。这些容器的析构函数,连同其他特殊成员函数的实现方式,使得它们几乎像 int 对象一样易于使用。如果这都不算一个值得追求的目标,那还有什么能算呢?

简而言之,标准库容器通过自动化资源管理(RAII)和高度优化的底层实现,在安全性、简洁性和性能上均优于手动管理的数据结构,这也是为什么现代 C++ 强烈推荐使用它们,而非裸数组或手动内存操作。