13.3. 隐式管理 —— 基于智能指针

在 C++ 中,要将我们的 Vector<T> 实现从手动管理内存改为隐式管理内存,最简单的方法是使用智能指针。这里的思路是将 Vector<T> 中 elems 这个数据成员的类型,从 T* 改为 std::unique_ptr<T[]>。我们将从两个角度来探讨这一改变:

两种情况下,都将通过分析一些具有代表性的成员函数,来了解这种改变所带来的影响。本书配套的 GitHub库中提供了 Vector<T> 的简单版本和复杂版本的完整实现,可供查阅和使用。

13.3.1 对简单版 Vector<T> 实现的影响

如果基于第 12 章中最初的简单版本(即 elems 只是指向一个连续的 T 对象序列)来进行简化,那么这项工作将会相当简单。我们可以将:

// 简单实现,需手动显式管理内存
// 数据成员的声明...
pointer elems{};
size_type nelems{}, cap{};

改为:

// 简单实现,需手动显式管理内存
// 数据成员的声明...
std::unique_ptr<value_type[]> elems;
size_type nelems{}, cap{};

然后将 begin() 成员函数的实现从这样:

// 简单实现,需手动显式管理内存
iterator begin() {
  return elems; // 指向内存块的原始指针
}
const_iterator begin() const {
  return elems; // 指向内存块的原始指针
}

改为:

// 简单实现,隐式内存管理
iterator begin() {
  return elems.get(); // 指向底层内存块起始位置的原始指针
}
const_iterator begin() const {
  return elems.get(); // 同上
}

仅完成这些改动就足以显著简化 Vector<T> 类型的实现 —— 内存的释放将变得隐式化。例如,可以简化各个构造函数的实现,甚至完全移除异常处理代码。以下这段原来的实现:

// 简单实现,需手动显式管理内存
  Vector(size_type n, const_reference init)
    : elems{ new value_type[n] }, nelems{ n }, cap{ n } {
    try {
      std:: fill(begin(), end(), init);
    } catch (...) {
      delete [] elems;
      throw;
    }
  }
// ...

就可以简化为:

//  简单实现,隐式管理内存
  Vector(size_type n, const_reference init)
    : elems{ new value_type[n] }, nelems{ n }, cap{ n } {
    std:: fill(begin(), end(), init);
  }
// ...

这种简化的原因为:

即使所在的公司禁止或不鼓励使用异常机制,通过使用智能指针所获得的异常安全性依然有效。这里(悄无声息地)编写了异常安全的代码,而无需写下 try 或 catch 这样的关键字。

通过引入隐式内存管理所带来的其他简化示例,还包括 Vector<T> 的移动操作和析构函数。它们原本是这样的:

// 简单实现,需要显示手动管理内存
  Vector(Vector &&other)
  : elems{ std::exchange(other.elems, nullptr) },
    nelems{ std::exchange(other.nelems, 0) },
    cap{ std::exchange(other.cap, 0) } {
  }

  Vector& operator=(Vector &&other) {
    Vector{ other }.swap(*this);
    return *this;
  }

  ~Vector() {
    delete [] elems;
  }
// ...

可简化为:

// 简单实现,隐式管理内存
  Vector(Vector&&) = default;
  Vector& operator=(Vector&&) = default;
  ~Vector() = default;
// ...

将移动操作设为 =default 能够正常工作,是因为 std::unique_ptr 类型在移动时会“做正确的事”,即将所指向对象的所有权从源对象转移到目标对象。

注意

通过将移动操作设为 =default,我们在 Vector<T> 的实现中引入了一点语义上的变化。C++ 标准建议,一个移动过的对象应处于“有效但未指定的状态”,但并没有详细说明“有效”具体为什么。在我们手动编写的移动操作中,移动的源对象会重置为一个相当于默认构造的 Vector<T> 对象的状态;而使用 =default 的移动操作则会让移动的源对象保留一个空指针的 elems,但其 size 和 capacity 可能仍为非零值。在实践中,只要用户代码在重新赋值之前,不再使用移动后的对象,这种状态仍然是可以接受的。但这种变化确实带来了语义上的细微差别,值得我们注意和承认。

另一个有趣的简化示例:是 resize() 成员函数的实现。在最初的简单版 Vector<T> 实现中,有如下代码:

// 简单实现,需要显示手动管理内存
void resize(size_type new_cap) {

  if(new_cap <= capacity()) return;

  auto p = new T[new_cap];

  if constexpr(std::is_nothrow_move_assignable_v<T>) {
    std::move(begin(), end(), p);
  } else try {
    std::copy(begin(), end(), p);
  } catch (...) {
    delete[] p;
    throw;
  }

  delete[] elems;
  elems = p;
  cap = new_cap;
}

这里我们也面临这样的风险:当一个 T 对象复制赋值给另一个 T 对象时可能会抛出异常,因此需要处理这些异常,以避免资源泄漏。当从显式资源管理转向隐式资源管理后,代码变成了如下形式:

// 简单实现,隐式管理内存
void resize(size_type new_cap) {

  if(new_cap <= capacity()) return;

  auto p = std::make_unique<value_type[]>(new_cap);

  if constexpr(std::is_nothrow_move_assignable_v<T>) {
    std::move(begin(), end(), p.get());
  } else {
    std::copy(begin(), end(), p.get());
  }

  elems.reset(p.release());
  cap = new_cap;
}

整个异常处理代码都不再需要了。对象 p 拥有新的数组,并会在函数执行结束时自动销毁。当复制(或移动,取决于 T 的移动赋值是否标记为 noexcept)完成,elems 就会通过 reset() 放弃对原有数组的拥有权(同时销毁它),并通过 release() “偷取” p 所释放的数组所有权。请注意,写成 elems = std::move(p); 也会达到类似的效果。

将这种简化过程应用到整个 Vector<T> 的实现中后,源码会逐渐减少。对于像这个仅包含对象、底层存储末尾没有原始内存块的简单版 Vector<T> 容器来说,可以节省近 25% 的代码行数(在这个教学实现中,从大约 180 行减少到 140 行)。各位读者可以亲自尝试一下,看看效果!

13.3.2 对复杂版 Vector<T> 实现的影响

将相同的技巧应用到更复杂的 Vector<T> 上,需要做更多的工作,类型为 std::unique_ptr<T[]> 的对象的析构函数默认行为是对它所拥有的指针应用 operator delete[]。现在已经知道,复杂实现可以概念化为由两个(可能为空的)“部分”组成:一个初始部分由手动放置在原始内存中的 T 对象组成,后面接着另一个未初始化的、没有对象存在的原始内存区域,所以需要以不同的方式处理每个“部分”。

我们仍将使用 std::unique_ptr<T[]> 对象来管理内存,但需要使用一个自定义的删除器对象(在第5章和第6章中讨论过的内容),以考虑到我们实现的具体细节。该对象需要了解它所伴随的 Vector<T> 对象的运行时状态,必须知道底层存储的每个“部分”从哪里开始、到哪里结束,而这些信息在代码执行过程中是变化的。

这种实现的第一个重要点 —— 这是一个反复出现但我们可能没有给予足够强调的要点 —— 希望实现无论在实现方式变化时,都向使用端代码暴露相同的接口。有时候实现这一点是不可能或不合理的,但无论如何,仍然是一个有意义且值得追求的目标。这也包括对内部公共类型的选取:例如,使用智能指针来管理底层内存,但这并不改变一个元素的指针仍然是 T* 的事实。

// ...
template <class T>
class Vector {
public:
  using value_type = T;
  using size_type = std::size_t;

  using pointer = T*;
  using const_pointer = const T*;

  using reference = T&;
  using const_reference = const T&;
  // ...

现在,希望将 elems 定义为一个拥有并管理底层存储的智能指针,而不是一个原始指针,因此需要定义该智能指针所使用的自定义删除器。

这个问题的一方面是,自定义删除器需要知道 Vector<T> 对象的状态,以确定底层存储中的哪一部分保存着实际的对象。因此,为 std::unique_ptr<T[]> 所使用的自定义删除器将是带有状态的,并会存储一个名为 source 的对 Vector<T> 对象的引用。通过 source,删除器对象的函数调用操作符就能访问容器中的对象序列(即从 source.begin() 到 source.end() 的半开区间),并能够在释放底层存储之前对这些对象调用 destroy():

  // ...
private:
  struct deleter {
    Vector& source;
    void operator()(value_type* p) {
      std::destroy(std::begin(source),
      std::end(source));
      std::free(static_cast<void*>(p));
    }
  };

  std::unique_ptr<value_type[], deleter> elems;
  size_type nelems{},
            cap{};
  // ...

elems 这个数据成员知道自定义删除器的类型将是 deleter,但实际担任删除器角色的对象还必须知道自己将与哪一个 Vector<T> 对象进行交互。Vector<T> 的构造函数将负责提供这一信息,因此需要在实现移动操作时格外小心,以确保不会转移删除器对象的状态,从而避免导致代码不一致。

正如简单版本中提到的那样,需要调整 begin() 等成员函数,以考虑到这样一个事实:elems 是一个智能指针,但迭代器接口依赖的是原始指针:

// ...
using iterator = pointer;
using const_iterator = const_pointer;

iterator begin() { return elems.get(); }
const_iterator begin() const { return elems.get(); }
// ...

我们的构造函数需要适应这样一个事实:有一个自定义的删除器,会在发生异常或程序正常结束时负责清理工作。以下是三个 Vector<T> 构造函数的示例:

// ...
constexpr Vector()
  : elems{ nullptr, deleter { *this } } {
}

Vector(size_type n, const_reference init)
  : elems{ static_cast<pointer>(
      std::malloc(n * sizeof(value_type))
    ), deleter{ *this }
  } {
  std::uninitialized_fill(begin(), begin() + n, init);
  nelems = cap = n;
}

Vector(Vector&& other) noexcept
  : elems{ std::exchange(
      other.elems.release()), deleter{ *this }
    },
    nelems{ std::exchange(other.nelems, 0) },
    cap{ std::exchange(other.cap, 0) } {
}
// ...

在这种情况下我们并没有使用 = default 来定义移动构造函数,我们不希望将自定义删除器转移(即复制或移动)到新的对象中。我们的实现让删除器对象与一个特定的 Vector<T> 对象绑定在一起。

这里需要做一个小说明:我们将 *this 传递给了删除器对象的构造函数,但这是在 *this 的构造完成之前进行的。因此,在 *this 的构造完成之前(也就是构造函数的右括号 } 之前),删除器对象所做的操作都需要格外小心。

实现中,如果类型为 T 的对象构造过程中抛出了异常,就会触发 elems 的析构函数,从而调用删除器对象。必须确保,每当删除器对象有可能介入时,*this 的数据成员的值是保持一致的。

由于 begin() 和 end() 成员函数返回的迭代器定义了一个半开的对象区间,而且我们知道 std::uninitialized_fill() 会在构造对象时调用构造函数,并在抛出异常时销毁已经构造的对象,必须确保在所有对象构造完成之前,elems 的值为 nullptr。

我们将初始化的区间定义为 begin() 到 begin() + n,并在调用 std::uninitialized_fill() 之后才修改 nelems。这样,当抛出异常时,begin() == end(),删除器对象就不会尝试去销毁“非对象”。

Vector<T> 类的其他构造函数也以类似的方式简化;在这里不一一展示,可以将它们视为留给读者的家庭作业。

Vector<T> 的简化在一些特殊的成员函数中表现得尤为明显,这些函数现在几乎不需要做任何工作。特别值得一提的是析构函数,现在可以直接使用 = default;正如本节前面提到的移动构造函数一样,没有对移动赋值操作符使用 = default,以避免将自定义删除器的内部状态转移。这一点可以从以下代码段中看出:

// ...
~Vector() = default;

void swap(Vector& other) noexcept {
  using std::swap;
  swap(elems, other.elems);
  swap(nelems, other.nelems);
  swap(cap, other.cap);
}

Vector& operator=(const Vector& other) {
  Vector{ other }.swap(*this);
  return *this;
}

Vector& operator=(Vector&& other) {
  Vector{ std::move(other) }.swap(*this);
  return *this;
}

reference operator[](size_type n) { return elems[n]; }
  const_reference operator[](size_type n) const {
  return elems[n];
}

成员函数 swap() 和 operator[] 的实现表明,std::unique_ptr<T[]> 在很多方面表现得像一个普通的 T 对象数组一样。Vector<T> 的许多其他成员函数保持不变,例如 front()、back()、operator==()、operator!=()、row()、push_back() 和 emplace_back()。有关这些函数的详细信息,请参考第12章。

通过使用智能指针,reserve() 和 resize() 函数也可以简化,不再需要显式地处理异常管理,但仍能保证异常安全,因为 std::unique_ptr<T[]> 是一种 RAII 类型,为我们管理内存。

以 reserve() 为例,现在使用智能指针 p 来持有分配的内存,然后将 elems 中的对象通过 move() 或 copy() 复制到 p 所指向的内存中。完成之后,对 elems 中剩余的对象调用 destroy(),随后 p 放弃其指针控制权,并将其转移给 elems,剩下的唯一操作就是更新容器的容量(capacity):

// ...
void reserve(size_type new_cap) {

  if (new_cap <= capacity()) return;

  std::unique_ptr<value_type[]> p{
    static_cast<pointer>(
      std::malloc(new_cap * sizeof(T))
    )
  };

  if constexpr (std::is_nothrow_move_assignable_v<T>) {
    std::uninitialized_move(begin(), end(), p.get());
  } else {
    std::uninitialized_copy(begin(), end(), p.get());
  }

  std::destroy(begin(), end());
  elems.reset(p.release());
  cap = new_cap;
}

对于 resize() 函数,现在同样使用智能指针 p 来持有分配的内存,然后将 elems 中的对象通过 move() 或 copy() 复制到 p,并在内存块的剩余部分构造默认的 T 对象。完成之后,对 elems 中剩余的对象调用 destroy(),随后 p 释放其指针对其进行控制权转移给 elems,剩下的唯一操作就是更新容器的容量:

// ...
void resize(size_type new_cap) {

  if (new_cap <= capacity()) return;

  std::unique_ptr<value_type[]> p =
    static_cast<pointer>(
      std::malloc(new_cap * sizeof(T))
    );

  if constexpr (std::is_nothrow_move_assignable_v<T>) {
    std::uninitialized_move(begin(), end(), p.get());
  } else {
    std::uninitialized_copy(begin(), end(), p.get());
  }

  std::uninitialized_fill(
    p.get() + size(), p.get() + new_cap, value_type{}
  );

  std::destroy(begin(), end());
  elems.reset(p.release());
  nelems = cap = new_cap;
}
// ...

可以说,这一切的“魔力”在于,其他的成员函数(如 insert() 和 erase())是建立在 reserve()、begin()、end() 等基本抽象之上的,即不需要进行修改就能适应这种表示方式的改变。