参考资料:
- 《C++ Primer》第5版
- 《C++ Primer 习题集》第5版
13.5 动态内存管理类(P464)
某些类需要在运行时分配可变大小的内存空间。这种类通常可以用使用标准库容器来保存它们的数据。有些时候,我们希望类自己进行内存分配,这种类必须定义自己的拷贝控制成员。
StrVec
类的设计
我们将实现标准库 vector
的简化版本,只用于保存 string
。
标准库 vector
将元素保存在连续内存中。vector
预先分配足够的内存来保存可能需要的元素。vector
每个添加元素的成员函数会检查是否有足够的空间,如果有,成员函数会在下一个可用位置构造一个对象;如果没有,vector
会重新分配空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。
仿照标准库 vector
,StrVec
使用 allocator
来获得原始内存,在需要添加成员时使用 allocator
的 construct
成员在原始内存中创建对象;类似的,需要删除一个元素时,使用 allocator
的 destory
成员来销毁元素。每个 StrVec
有三个指针成员:
elements
:指向分配的内存中的首元素first_free
:指向最后一个实际元素之后的位置cap
:指向分配的内存末尾之后的位置
此外,StrVec
还有一个名为 alloc
,类型为 allocator<string>
的静态成员,以及 4 个工具函数:
alloc_n_copy
会分配内存,拷贝一个给定范围中的元素free
会销毁构造的元素,并释放内存chk_n_alloc
保证StrVec
至少有容纳一个新元素的空间,如果没有则调用reallocate
来重新分配内存reallocate
负责为StrVec
分配新内存
StrVec
类定义
class StrVec {
public:
StrVec() :
elements(nullptr), first_free(nullptr), cap(nullptr){ }
StrVec(const StrVec &);
StrVec &operator=(const StrVec &);
~StrVec();
void push_back(const string &);
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
string *begin() const { return elements; }
string *end() const { return first_free; }
private:
static allocator<string> alloc;
string *elements;
string *first_free;
string *cap;
void chk_n_alloc() { if (size() == capacity()) reallocate(); }
pair<string *, string *> alloc_n_copy(const string *, const string *);
void free();
void reallocate();
};
void StrVec::push_back(const string &s) {
// 确保容器有足够的空间容纳新元素
chk_n_alloc();
// 在first_free指向的元素中构造s的副本
alloc.construct(first_free++, s);
}
pair<string *, string *>
StrVec::alloc_n_copy(const string *b, const string *e) {
// 分配大小合适的空间
auto data = alloc.allocate(e - b);
// 返回拷贝的开始位置和尾后位置
return { data, uninitialized_copy(b, e, data) };
}
void StrVec::free() {
// 保证传递给deallocate的是一个先前由allocate返回的指针
if (elements) {
for (auto p = first_free; p != elements;) {
// 逆序销毁旧元素
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}
StrVec::StrVec(const StrVec &s) {
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
StrVec::~StrVec() {
free();
}
StrVec &StrVec::operator=(const StrVec &s) {
// 允许自赋值
auto data = alloc_n_copy(s.begin(), s.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
在重新分配内存的过程中移动而不是拷贝元素
在编写 reallocate
成员之前,我们应首先明确它的功能;
- 为一个新的、更大的
string
数组分配内存 - 在新内存空间的前一部分构造对象,保存现有元素
- 销毁原内存空间中的元素,并释放原内存空间
如果我们选择将元素从旧空间拷贝到新空间,会造成额外的开销。
移动构造函数和std::move
通过新标准库引入的两种机制,我们可以避免 string
拷贝。首先,一些如 string
的标准库类定义了移动构造函数,将资源移动到正在创建的对象中,并保证移后源(moved_from)对象仍然保持一个有效、可析构的状态。
第二个机制是名为 move
的标准库函数,定义在头文件 utility
中。关于 move
,目前需要知道两个关键点:在 reallocate
中我们可以通过调用 move
的方式来使用 string
的移动构造函数;我们通常不 using std::move
,而是直接调用 std::move
。
reallocate
成员
void StrVec::reallocate() {
// 分配原空间两倍大小的新空间
auto newcapacity = size() ? 2 * size() : 1;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
// 将数据移动到新内存
for (size_t i = 0; i != size(); ++i) {
alloc.construct(dest++, std::move(*elem++));
}
free();
first_free = dest;
cap = elements + newcapacity;
}
13.6 对象移动(P470)
新标准的一个最主要特性就是可以移动而非拷贝对象。如果对象拷贝后就被销毁了,移动而非拷贝对象会大幅提高性能。
前面提到的 StrVec
类在重新分配内存时使用移动而非拷贝就是一个很好的例子。此外,如 IO 类或 unique_ptr
这些对象不能拷贝,但能移动。
13.6.1 右值引用(P471)
为了支持移动操作,新标准引入了右值引用(rvalue reference)。右值引用是必须绑定到右值的引用,通过 &&
来获得右值引用。右值引用有一个重要特性:只能绑定到一个将要销毁的对象。
前面我们提到过,我们不能将普通的左值引用绑定到要求转换的表达式、字面常量、返回右值的表达式上。右值引用有着完全相反的特性:我们可以将右值引用绑定到上述表达式上,但不能将其直接绑定到左值上:
double pi = 3.14;
double &r1 = pi; // 正确
double &&r2 = pi; // 错误
int &r3 = pi; // 错误
int &r4 = pi; // 正确
double &r5 = pi + 0.1; // 错误
double &&r6 = pi + 0.1; // 正确
左值持久,右值短暂
左值有持久的状态,右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
变量是左值
变量可以看作一个只有一个运算对象而没有运算符的表达式,变量表达式都是左值:
int &&rr1 = 42;
int &&rr2 = rr1; // 错误,表达式rr1是左值
标准库move
函数
我们可以通过调用 move
函数获得绑定到左值上的右值引用:
int &&rr3 = std::move(rr1); // 正确
move
告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。调用 move
意味着承诺:除了对 rr1
赋值或销毁外,我们将不再使用它。
13.6.2 移动构造函数和移动赋值运算符(P473)
为了让我们自己的类支持移动操作,需要为其定义移动构造函数和移动赋值运算符,从给定对象中“窃取资源”而非拷贝资源。
类似拷贝构造函数,移动构造函数的第一个参数是该类型的一个引用,只不过移动构造函数需要一个右值引用,其他的额外参数必须有默认实参。除了完成资源移动外,移动构造函数还要保证移后源对象处于这样一个状态:销毁它是无害的。
// 移动操作不应抛出异常
StrVec::StrVec(StrVec &&s) noexcept :
elements(s.elements), first_free(s.first_free), cap(s.cap)
{
// 令s进入析构安全状态
s.elements = s.first_free = s.cap = nullptr;
}
移动操作、标准库容器和异常
由于移动操作通常不分配任何异常,所以其不会抛出任何异常。如果我们不指明我们的移动构造函数不会抛出异常,标准库会认为其可能抛出异常,并为了这种可能性而做一些额外的工作。我们可以使用 noexcept
来承诺函数不抛出异常:
class StrVec{
public:
StrVec(StrVec &&) noexcept;
};
StrVec::StrVec(StrVec &&s) noexcept: /*成员初始化器*/
{ /*成员函数体*/ }
必须同时在声明和定义中指定 noexcept
。
不抛出异常的移动构造函数和移动赋值运算符必须标记为 noexcept
。为什么会有这个要求呢?以标准库容器 vector
为例。vector
保证,如果我们调用 push_back
时发生异常,vector
自身不会发生任何改变。由于 push_back
发生异常通常是在重新分配内存空间的时候,如果我们在重新分配过程中使用移动构造函数,并且在移动中途抛出异常,此时 vector
将不能满足自身保持不变的要求,而使用拷贝构造函数则不会有这个问题。所以除非 vector
知道移动构造函数不会抛出异常,否则再重新分配内存的过程中,它就必须使用拷贝构造函数而非移动构造函数。
移动赋值运算符
StrVec &StrVec::operator=(StrVec &&s) noexcept {
// 检测自赋值
if (this != &s) {
free(); // 释放已有资源
elements = s.elements; // 接管资源
first_free = s.first_free;
cap = s.cap;
s.elements = s.first_free = s.cap = nullptr;
}
return *this;
}
移后源对象必须可析构
有时在移动操作完成后,源对象会被销毁,所以我们必须保证移后源对象进入一个可析构状态。此外,还应该保证移后源仍然是有效的:可以安全地为其赋予新值,或者可以安全地使用而不依赖其当前值。例如,我们从一个 string
对象移动数据后,仍然可以对它执行 empty
、size
等操作。
用户不能对移后源对象地值做任何假设。
合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且每个非 static
数据成员都可以移动时,编译器才会为它合成移动构造函数和移动赋值运算符。
这部分懒得写了,建议直接看书 P475
移动右值,拷贝左值
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。
但如果没有移动构造函数,右值也被拷贝
const
的左值引用也可以绑定到右值上。
拷贝并交换赋值运算符和移动操作
class HasPtr {
public:
HasPtr(HasPtr &&p) noexcept: ps(p.ps), i(p.i) { p.ps = nullptr; }
HasPtr &operator=(HasPtr rhs)
{ swap(*this, rhs); return *this; }
};
上面的拷贝并交换赋值运算符实现了拷贝赋值运算符和移动赋值运算符两种功能:当右侧运算对象为右值时,调用移动构造函数;当右侧运算对象为左值时,调用拷贝构造函数。
更新“三/五法则”:五个拷贝控制成员应该看做一个整体。
移动迭代器
新标准库定义了一种移动迭代器(move iterator)适配器。一个移动迭代器通过改变给定迭代器的解引用运算的行为来适配此迭代器,移动迭代器的解引用运算符生成一个右值引用。
我们通过调用 make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器。
只有在确信移后源对象没有其他用户时,才建议使用移动操作。
13.6.3 右值引用和成员函数(P481)
如果一个成员函数同时提供拷贝和移动版本,它也能从中受益:
void StrVec::push_back(const string &s) {
chk_n_alloc();
alloc.construct(first_free++, s);
}
void StrVec::push_back(string && s) {
chk_n_alloc();
alloc.construct(first_free++, std::move(s));
}
右值和左值引用成员函数
通常,我们在一个对象上调用成员函数,不管该对象是左值还是右值:
string s1 = "hello", s2 = "world";
auto n = (s1 + s2).find('a');
但这种灵活的使用方式有时可能令人费解:
(s1 + s2) = "wow"; // 语法上成立,因为重载赋值运算符本质上也是成员函数
我们显然希望阻止上述用法。新标准库允许我们在参数列表后放置一个引用限定符(reference qualifier)(&
或 &&
)来要求成员函数必须由左值对象或右值对象调用:
class Foo {
public:
Foo &operator=(const Foo &) &;
};
Foo &Foo::operator=(const Foo &rhs) &{
// ...
return *this;
}
类似 const
限定符,引用限定符只能用于非 static
成员函数,且必须同时出现在声明和定义中。如果一个函数同时拥有 const
限定和引用限定,那么引用限定符必须跟在 const
后面。
重载和引用函数
和 const
限定符一样,我们也可以通过引用限定符重载函数,不同点在于,如果我们定义两个及以上具有相同名字、参数列表的成员函数,就必须对每个函数都加上引用限定符,或者都不加:
class Foo {
public:
void fun();
void fun() const;
void cal();
void cal() &; // 错误
};