《C++ Primer》第13章 拷贝控制(三)

发布时间:2023年12月20日

参考资料:

  • 《C++ Primer》第5版
  • 《C++ Primer 习题集》第5版

13.5 动态内存管理类(P464)

某些类需要在运行时分配可变大小的内存空间。这种类通常可以用使用标准库容器来保存它们的数据。有些时候,我们希望类自己进行内存分配,这种类必须定义自己的拷贝控制成员。

StrVec类的设计

我们将实现标准库 vector 的简化版本,只用于保存 string

标准库 vector 将元素保存在连续内存中。vector 预先分配足够的内存来保存可能需要的元素。vector 每个添加元素的成员函数会检查是否有足够的空间,如果有,成员函数会在下一个可用位置构造一个对象;如果没有,vector重新分配空间,将已有元素移动到新空间中,释放旧空间,并添加新元素

仿照标准库 vectorStrVec 使用 allocator 来获得原始内存,在需要添加成员时使用 allocatorconstruct 成员在原始内存中创建对象;类似的,需要删除一个元素时,使用 allocatordestory 成员来销毁元素。每个 StrVec 有三个指针成员:

  • elements :指向分配的内存中的首元素
  • first_free :指向最后一个实际元素的位置
  • cap :指向分配的内存末尾之后的位置
82ec6f9f51074f76ec7c74a05027a9b

此外,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 对象移动数据后,仍然可以对它执行 emptysize 等操作。

用户不能对移后源对象地值做任何假设。

合成的移动操作

只有当一个类没有定义任何自己版本拷贝控制成员,且每个非 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() &;    // 错误
};
文章来源:https://blog.csdn.net/MaTF_/article/details/135116304
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。