在 C++ 中,要将我们的 Vector<T> 实现从手动管理内存改为隐式管理内存,最简单的方法是使用智能指针。这里的思路是将 Vector<T> 中 elems 这个数据成员的类型,从 T* 改为 std::unique_ptr<T[]>。我们将从两个角度来探讨这一改变:
这一改变对 Vector
回顾一下,第 12 章中的简单版本并没有区分对象与原始内存,底层存储中只存放了对象。这使得实现更为简单,但也导致在很多情况下不必要的对象构造,对于非简单可构造的类型来说,其性能明显低于更复杂的实现版本。
这一改变对避免了构造多余对象这一性能陷阱的复杂版本又有何影响?
虽然该版本的实现更为复杂,但它的效率更高。
两种情况下,都将通过分析一些具有代表性的成员函数,来了解这种改变所带来的影响。本书配套的 GitHub库中提供了 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);
}
// ...
这种简化的原因为:
如果 Vector
另一方面,如果 elems 是一个智能指针,当该智能指针本身构造完成时,就会对其指向的内存负责。而这个构造发生在 Vector
C++ 的对象模型确实非常精妙。
即使所在的公司禁止或不鼓励使用异常机制,通过使用智能指针所获得的异常安全性依然有效。这里(悄无声息地)编写了异常安全的代码,而无需写下 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 行)。各位读者可以亲自尝试一下,看看效果!
将相同的技巧应用到更复杂的 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() 等基本抽象之上的,即不需要进行修改就能适应这种表示方式的改变。