《C++11Primer》阅读随记 -- 十三、拷贝控制

第十三章 拷贝控制

拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

class Foo{
public:
	Foo();				// 默认构造函数
	Foo(const Foo&);	// 拷贝构造函数
	// ...
};

作为一个例子,Sales_data 类的合成拷贝构造函数等价于:

class Sales_data{
public:
	// 其他成员和构造函数的定义,如前
	// 与合成的拷贝构造函数等价的拷贝构造函数的声明
	Sales_data(const Sales_data&);
private:
	std::string bookNo;
	int units_sold = 0;
	double revenue = 0.0;
};
// 与 Sales_data 的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data& orig):
	bookNo(orig.bookNo), units_sold(orig.units_sold),revenue(orig.revenue)
	{}

拷贝初始化不仅在我们用 = 定义变量时会发生,在下列情况下也会发生

  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

拷贝赋值运算符

重载赋值运算符

重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator= 的函数。

重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this 参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显示参数传递
拷贝赋值运算符接受一个与其所在类相同类型的参数:

class Foo{
public:
	Foo& operator=(const Foo&);	// 赋值运算符
	// ...
};

下面的代码等价于 Sales_data 的合成拷贝赋值运算符

Sales_data&
Sales_data::operator=(const Sales_data& rhs){
	bookNo = rhs.bookNo;
	units_sold = rhs.units_sold;
	revenue = rhs.revenue;
	return *this;
}

如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数

使用 =default

可以通过将拷贝控制成员定义为 =default 来显示地要求编译器生成合成的版本

class Sales_data{
public:
	// 拷贝控制成员;使用 default
	Sales_data() = default;
	Sales_data(const Sales_data&) = default;
	Sales_data& operator=(const Sales_data&);
	~Sales_data() = default;
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;

当我们在类内使用 =default 修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明地函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用 =default,就像对拷贝赋值运算符所作那样。

我们只能对具有合成版本的成员函数使用 =default ( 即,默认构造函数或拷贝控制成员 )

阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显示地

=delete

定义删除的函数

在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为 删除的函数(deleted function), 比如 iostream 类阻止了拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指出我们希望将他们定义为删除的:

struct NoCopy{
	NoCopy() = default;							// 使用合成的默认构造函数
	NoCopy(const NoCopy&) = delete;				// 阻止拷贝
	NoCopy operator=(const NoCopy&) = delete;	// 阻止赋值
	~NoCopy() = default;						// 使用合成的析构函数
};

=default 不同, =delete 必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是温和的。一个默认的成员只影响为这个成员而生成的代码,因此 =default 知道编译器生成代码时才需要。而另一方面,编译器需要知道一个函数时删除的,以便禁止试图使用它的操作

=default 的另一个不同之处是,我们可以对任何函数指定 =delete( 我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default

析构函数不能是删除的成员
对于析构函数已经删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针

struct NoDtor{
	NoDtor() = default;		// 使用合成默认构造函数
	~NoDtor() = delete;		// 我们不能销毁 NoDtor 类型的对象
};
NoDtor nd;					// 错误:DoDtor 的析构函数是删除的
NoDtor *p = new NoDtor();	// 正确:但我们不能 delete p;
delete p;					// 错误:NoDtor 的析构函数是删除的

拷贝控制和资源管理

行为像值的类

类值版本的 HashPtr

class HasPtr{
public:
	HasPtr(const std::string& s = std::string()):ps(new std::string(s)), i(0) {}
	// 对 ps 指向的 string, 每个 HasPtr 对象都有自己的拷贝
	HasPtr& operator=(const HasPtr&);
	~HasPtr(){ delete ps; };
private:
	std::string* ps;
	int i;
};

类值拷贝赋值运算符

先拷贝右侧运算对象,并保证在异常发生时代码是安全的。在完成拷贝后,释放左侧运算对象资源,并更新指针指向新分配的 string

HasPtr& HasPtr::operator=(const HasPtr& rhs){
	auto newp = new string(*rhs.ps);	// 拷贝底层 string
	delete ps;							// 释放旧内存
	ps = newp;							// 从右侧运算对象拷贝数据到本对象
	i = rhs.i;
	return *this;						// 返回本对象
}                      

关键概念: 赋值运算符
赋值运算符有两点很重要:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作
  • 大多数赋值运算符组合了析构和拷贝构造函数的工作

当编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中。

// 这样编写赋值运算符是错误的:
HasPtr&
HasPtr::operator=(const HasPtr& rhs){
	delete ps;			// 释放对象指向的 string
	// 如果 rhs 和 *this 是同一个对象,我们就将从已释放的内存中拷贝数据
	ps = new string(*(rhs.ps));
	i = rhs.i;
	return *this;
}

如果 rhs 和本对象是同一个对象,delete ps 会释放 *thisrhs 指向的 string。接下来,当我们在 new 表达式中试图拷贝 *(rhs.ps) 时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。

定义行为像指针的类

对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的 string。我们的类仍然需要自己的析构函数来释放接受 string 参数的构造函数分配的内存。但在本例中,析构函数不能单方面地释放关联地 string。 只有当最后一个指向 stringHasPtr 销毁时,它才可以释放 string

令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源。拷贝( 或赋值 )一个 shared_ptr 会拷贝( 赋值 )一个 shared_ptr 会拷贝( 赋值 )shared_ptr 所指向的指针。shared_ptr 类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时。shared_ptr 类负责释放资源。

但是,有时我们希望直接管理资源。在这种情况下,使用**引用计数(reference count)**就很有用了。为了说明引用计数如何工作,这里将重新定义 HasPtr,令其行为像指针一样,但我们不使用 shared_ptr,而是设计自己的引用计数

引用计数

引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为 1
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为 0,则析构函数释放状态
  • 拷贝赋值运算符递增右侧运算符对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态

唯一的难题是确定在哪里存放引用计数。计数器不能直接作为 HasPtr 对象的成员。下面的例子说明了原因

HasPtr p1("Hiya!");		
HasPtr p2(p1);			// p1 和 p2 指向相同的 string
HasPtr p3(p1);			// p1、p2 和 p3 都指向相同的 string

如果引用计数保存在每个对象中,当创建 p3 时我们应该如何正确更新它呢?可以递增 p1 中的计数器并将其拷贝到 p3 中,但是如何更新 p2 中的计数器呢

解决此问题的一种办法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器

定义一个使用引用计数的类

通过使用引用计数,就可以编写类指针的 HasPtr 版本了

class HasPtr{
public:
	// 构造函数分配新的 string 和新的计数器,将计数器置为 1
	HasPtr(const std::string& s = std::string())
		:ps(new std::string(s)), i(0), use(new std::size_t(1)){}
	// 拷贝构造函数拷贝所有三个数据成员,并递增计数器
	HasPtr(const HasPtr& p)
		:ps(p.ps), i(p.i), use(p.use){ ++*use; };
	HasPtr& operator=(const HasPtr&);
	~HasPtr();
private:
	std::string* ps;
	int i;
	std::size_t* use;	// 用来记录有多少个对象共享 *ps 的成员
};

在此,添加了一个名为 use 的数据成员,它记录有多少对象共享相同的 string。接受 string 参数的构造函数分配新的计数器,并将其初始化为 1,指出当前有一个用户使用本对象的 string 成员。

类指针的拷贝成员“篡改”计数

当拷贝或赋值一个 HasPtr 对象时,我们希望副本和原对象都指向相同的 string。即,当拷贝一个 HasPtr 时,我们将拷贝 ps 本身,而不是 ps 指向的 string。当我们进行拷贝时,还会递增该 string 关联的计数器。

(我们在类内定义的)拷贝构造函数拷贝给定 HasPtr 的所有三个数据成员。这个构造函数还递增 use 成员,指出 psp.ps 指向的 string 又有了一个新的用户

析构函数不能无条件地 delete ps 可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享 string 的对象又少了一个。如果计数器变为 0,则析构函数释放 psuse 指向的内存:

HasPtr::~HasPtr(){
	if(--*use == 0){	// 如果引用计数变为 0
		delete ps;		// 释放 string 内存
		delete use;		// 释放计数器内存
	}
}

拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。即,它必须递增右侧运算对象的引用计数(即,拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(即,析构函数的工作)。

而且与往常一样,赋值运算符必须处理自赋值。我们通过先递增 rhs 中的计数然后再递减左侧运算对象中的计数来实现这一点。通过这种方法,当两个对象相同时,再我们检查 ps( 以及 use ) 是否应该释放之前,计数器就已经被递增过了

HasPtr& HasPtr::operator=(const HasPtr& rhs){
	++*rhs.use;	// 递增右侧运算对象的引用计数
	if(***use == 0){	// 然后递减本对象的引用计数
		delete ps;		// 如果没有其他用户
		delete use;		// 释放本对象分配的成员
	}
	ps = rhs.ps;		// 将数据从 rhs 拷贝到本对象
	i = rhs.i;
	use = rhs.use;
	return *this;		// 返回本对象
}

动态内存管理类

某些类需要再运行时分配可变大小的内存空间。这种类通常可以使用标准库容器来保存它们的数据。

这一节将实现一个简化的 vector 类。命名为 StrVec

我们将使用一个 allocator 来获取原始内存。由于 allocator 分配的内存时未构造的,我们将在需要添加新元素时用 allocator 分配的内存时未构造的,我们将在需要添加新元素时使用 allocatorconstruct 成员在原始内存中创建对象。类似的,当需要删除一个元素时,将使用 destroy 成员销毁元素

每个 StrVec 有三个指针成员指向其元素所使用的内存

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

除了这些指针外,StrVec 还有一个名为 alloc 的静态成员,其类型为 allocator<string>alloc 成员会分配 StrVec 使用的内存。我们的类还有 4 个工具函数:

  • alloc_n_copy 分配内存,并拷贝一个给定范围中的元素
  • free 销毁构造的元素并释放内存
  • chk_n_alloc 保证 StrVec 至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc 会调用 reallocate 来分配更多内存
  • reallocate 在内存用完时为 StrVec 分配新内存

虽然我们关注的是类的实现,但我们也将定义 vector 接口中的一些成员

class StrVec{
public:
	StrVec()
		:elements(nullptr), first_free(nullptr), cap(nullptr){}
	StrVec(const StrVec&);				// 拷贝构造函数
	StrVec& operator=(const StrVec&);	// 拷贝赋值运算符
	~StrVec();							// 析构函数
	void push_back(const StrVec&);		// 拷贝元素
	size_t size() const {  return first_free - elements; }
	size_t capacity() const { return cap - elements; };
	std::string* begin() const { return elements; }
	std::string* end() const { return first_free; }
	// ...
private:
	static std::allocator<std::string> alloc;	// 分配元素
	// 被添加元素的函数所使用
	void chk_n_alloc(){
		if( size() == capacity() ) reallocate();
	}
	std::pair<std::string* std::string*> alloc_n_copy
		(const std::string*, const std::string*);
	void free();				// 销毁元素并释放内存
	void reallocate();			// 获得更多内存并拷贝已有函数
	std::string* elements;		// 指向数组首元素的指针
	std::string* first_free;	// 指向数组第一个空闲元素的指针
	std::string* cap;			// 指向数组尾后位置的指针
};

类体定义了多个成员:

  • 默认构造函数( 隐式地 )默认初始化 alloc 并( 显示地)将指针初始化为 nullptr
  • size 成员返回当前真挣在使用地元素的数目
  • capacity 成员返回 StrVec 可以保存的元素的数量
  • 当没有空间容纳新元素,即 cap==first_free 时,chk_n_alloc 会为 StrVec 重新分配内存
  • beginend 成员分别返回指向首元素(即 elements )和最后一个构造的元素之后位置(即 first_free)的指针

使用 construct

void StrVec::push_back(const string& s){
	chk_n_alloc();	// 确保有空间容纳新元素
	// 在 first_free 指向的元素中构造 s 的副本
	alloc.construct(first_free++, s);
}

alloc_n_copy 成员

alloc_n_copy 成员分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的 pair,两个指针分别指向新空间的开始位置和拷贝的尾后位置

pair<string*, string*>
StrVec::alloc_n_copy(const string* b, const string* e){
	// 分配空间保存给定范围中的元素
	auto data = alloc.allocate(e - b);
	// 初始化并返回一个 pair,该 pair 由 data 和 uninitialized_copy 的返回值构成
	return {data, uninitialized_copy(b, e, data)};
}

alloc_n_copy 用尾后指针减去首元素指针,来计算需要多少空间。在分配内存之后,它必须在此控件中构造给定元素的副本

它是在返回语句中完成拷贝工作的,返回语句中对返回值进行了列表初始化。返回的 pairfirst 成员指向分配的内存的开始位置;second 成员则是 uninitialized_copy 的返回值,此值是一个指针,指向最后一个构造元素之后的位置。

free 成员

void StrVec::free(){
	// 不能传递给 deallocate 一个空指针,如果 elements 为 0,函数什么也不做
	if(elements){
		// 逆序销毁旧元素
		for(auto p = first_free; p != elements; /* 空 */)
			alloc.destroy(--p);
		alloc.deallocate(elements, cap - elements);
	}
}

destroy 函数会运行 string 的析构函数。string 的析构函数会释放自己分配的内存空间。我们传递给 deallocate 的指针必须是之前某次 allocate 调用所返回的指针。因此,在调用 deallocate 之前我们首先检查 elements 是否为空

拷贝控制成员

StrVec::StrVec(const StrVec& s){
	// 调用 alloc_n_copy 分配以容纳与 s 中一样多的元素
	auto newdata = alloc_n_copy(s.begin, s.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
}

析构函数调用 free()

StrVec::~StrVec() { free(); }

拷贝赋值运算符在释放已有元素之前调用 alloc_n_copy, 这样就可以正确处理自赋值了:

StrVec& StrVec::operator=(const StrVec& rhs){
	// 调用 alloc_n_copy 分配内存,大小与 rhs 中元素占用空间一样多
	auto newdata = alloc_n_copy(rhs.begin, rhs.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

在重新分配内存的过程中移动而不是拷贝元素

reallocate

  • 为一个新的、更大的 string 数组分配内存
  • 在内存空间的前一部分构造对象,保存现有元素
  • 销毁原内存空间中的元素,并释放这块内存
移动构造函数和 std::move

通过使用新标准库引入的两种机制,就可以避免在重新分配内存过程中出现的 string 拷贝。

移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。而且我们知道标准库保证”移后源(moved_from)“ string 仍然保持一个有效的、可惜狗的状态。对于 string,可以想象每个 string 都有一个指向 char 数组的指针。可以假定 string 的移动构造函数进行了指针的拷贝,而不是为了字符分配内存空间然后拷贝字符。

第二个机制便是名为 move 的标准库函数,它定义在 utility 头文件中。目前,关于 move 我们需要了解两个关键点。首先,当 reallocate 在新内存中构造 string 时,它必须调用 move 来表示希望使用 string 的移动构造函数。如果它漏掉了 move 调用,将会使用 string 的拷贝构造函数。其次,我们通常不为 move 提供一个 using 声明,直接调用 std::move 而不是 move

reallocate 成员

首先调用 allocate 分配新的内存空间。每次重新分配内存时都会将 StrVec 的容量加倍。如果 StrVec 为空,我们将分配容纳一个元素的空间:

void StrVec::reallocate(){
	// 我们将分配当前大小两倍的内存空间
	auto newcapacity = size() ? 2 * size() : 1;
	// 分配新内存
	auto dest = newdata;		// 指向新数组中下一个空间位置
	auto elem = elements;		// 指向旧数组中下一个元素
	for(size_t i = 0; i != size(); ++i){
		alloc.construct(dest++, std::move(*elem++));
		// a.construct(p, args)	p 必须是一个类型为 T* 的指针,
		// 指向一块原始内存; arg 被传递给类型为 T 的构造函数,
		// 用来在 p 指向的内存中构造一个对象
	}
	free();	// 一般我们移动玩元素就释放旧内存空间
	// 更新我们的数据结构,执行新元素
	elements = newdata;
	first_data = dest;
	cap = elements + newcapacity;
}

construct 的第二个参数( 即,确定使用哪个构造函数的参数)是 move 返回的值。调用 move 返回的结果会令 construct 使用 string 的移动构造函数。由于我们使用了移动构造函数,这些 string 管理的内存将不会被拷贝。反相反,我们构造的每个 string 都会从 elem 指向的 string 那里结果内存的所有权。

在元素移动完毕后,我们调用 free 销毁旧元素并释放 StrVec 原来使用的内存。string 成员不再管理它们曾经指向的内存; 其苏韩剧的管理职责已经转移给了新的 StrVec 内存中的元素了。我们不知道旧 StrVec 内存中的 string 包含什么值,但我们保证对它们执行 string 的析构函数是安全的。

剩下的是更新指针,指向新分配并已经初始化过的数组了。first_freecap 指针分别被设置为指向最后一个构造的元素之后的位置以及指向新分配空间的尾后位置。

对象移动

右值引用

为了支持移动操作,新表准引入了一种新的引用类型–右值引用( rvalue reference )。所谓右值引用就是必须绑定到右值的引用。通过 && 而不是 & 来获得右值引用。右值引用有一个重要的性质–只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用地资源”移动“到另一个对象中。

类似于任何引用,一个右值引用也不过是某个对象的另一个名字而已。如我们所指,对于常规引用( 为了与右值引用区分开,称为左值引用(lvalue reference) ),我们不能将其绑定到要求转换的表达之、字符串常量或是返回右值的表达式。右值引用有者完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:

int i = 42;
int& r = i;				// 正确: r 引用 i
int&& rr = i;			// 错误: 不能将一个右值引用绑定到一个左值上
int& r2 = i * 42;		// 错误: i * 42 是一个右值
const int& r3 = i * 42;	// 正确: 我们可以将一个 const 的引用绑定到一个右值上
int&& rr2 = i * 42;		// 正确: 将 rr2 绑定到乘法结果上

返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。

返回非引用类型的函数,联通算数、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到此类表达式上,但我们可以将一个 const 的左值引用或者一个右值引用绑定到这类表达式上。

左值持久;右值短暂

左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象

由于右值引用只能绑定到临时对象,我们得知:

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用对象地资源。

右值引用指向将要被销毁地对象。因此,我们可以从绑定到右值引用的对象”窃取“状态

变量是左值

变量表达式都是左值,所以我们不能将一个右值引用绑定到一个右值引用类型的变量上

int&& r1 = 42;	// 正确:字面值是右值
int&& r2 = r1;	// 错误: 表达式 rr1 是左值

标准库 move 函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显示地将一个左值转换为对应地右值引用类型。我们可以通过调用一个名为 move 地新标准库函数来获得绑定到左值上地右值引用,此函数定义在头文件 utility 中。

int&& rr3 = std::move(rr1); // ok

move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它:除了对 rr1 赋值或销毁它外,我们将不再使用它。在调用 move 之后,我们不能对移后源对象的值做任何假设。

我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

移动构造函数和移动赋值运算符

StrVec 定义移动构造函数,实现从一个 StrVec 到另一个 StrVec 的元素移动而非拷贝:

StrVec::StrVec(StrVec&& s) noexcept // 移动操作不应抛出任何异常
	// 成员初始化器接管 s 中的资源
	: elements(s.element), first_free(s.first_free), cap(s.cap){
	 // 令 s 进入这样的状态 -- 对其运行析构函数是安全的
	 s.elements = s.first_free = s.cap = nullptr;
}

与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的 StrVec 中的内存。在接管内存之后,它将给定对象中的指针都置为 nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意为者将在其上进行析构函数。StrVec 的析构函数在 first_free 上调用 deallocate。如果我们忘记改变 s.first_free, 则销毁移后源对象就会释放掉我们刚刚移动的内存。

移动操作、标准库容器和异常

由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性做一些额外的工作

一种通知标准库的方法是在我们的构造函数中指明 noexceptnoexcept 是新标准引入的。

class StrVec{
public:
	StrVec(StrVec&&) noexcept;	// 移动构造函数
	// ...
};
StrVec::StrVec(StrVec&&) noexcept: /* 成员初始化器 */
{	/* 构造函数体 */	}

需要在类头文件的声明中和定义中都指定 noexcept

移动赋值运算符

移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动给复制运算符不抛出任何异常,我们就应该将它标记为 noexcept。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值

StrVec& StrVec::operator=(StrVec&& rhs) noexcept
{
	// 直接检测自赋值
	if(this != &rhs){
		free();							// 释放已有元素
		elements = rhs.elements;		// 从 rhs 接管资源
		first_free = rhs.first_free;
		cap = rhs.cap;
		// 将 rhs 置于可析构状态
		rhs.elements = rhs.first_free = rhs.cap = nullptr;
	}
	return *this;
}
合成的移动操作

与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。因此,某些类就没有移动构造函数或移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员时类类型,且该类有对应的移动操作,编译器也能移动这个而成员。

// 编译器会为 X 和 hasX 合成移动操作
struct X{
	int i;				// 内置类型可以移动
	std::string s;		// string 定义了自己的移动操作
};
struct hasX{
	X mem;				// X 有合成的移动操作
};
X x, x2 = std::move(x);	// 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数

与拷贝操作不同,移动操作永远不会隐式地定义为删除地函数。但是,如果我们显示地要求编译器生成 =default 地移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除地函数。除了一个重要例外,什么时候将合成地移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则

  • 与拷贝构造函数不同,移动构造函数被定义为删除的函数的天骄是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者时有类成员为定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似
  • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的
  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
  • 类似拷贝赋值运算符,如果有类成员是 const 的或是引用,则类的移动赋值运算符被定义为删除的
// 假如 Y 是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY{
	hasY() = default;
	hasY(hasY&&) = defualt;
	Y mem;	// hasY 将有一个删除的移动构造函数
}:
hasY hy, hy2 = std::move(hy);	// 错误:移动构造函数时删除的

移动操作和合成的拷贝控制成员间还有最后一个相互作用关系:一个类是否定义了自己的移动该操作对拷贝操作如何合成有影响。如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。

定义了一个移动构造函数或移动赋值运算符的类也必须定义自己的拷贝操作,否则,这些成员默认地被定义为删除的

移动右值,拷贝左值
StrVec v1, v2;
v1 = v2;			// v2 是左值;使用拷贝赋值
StrVec getVec(istream &);	// getVec 返回一个右值
v2 = getVec(cin);			// getVec(cin) 是一个右值;使用移动赋值

但如果没有移动构造函数,右值也被拷贝
如果一个类有一个拷贝构造函数但未定义移动构造函数。编译器不会合成移动构造函数,这意味着此类酱油拷贝构造函数但不会有移动构造函数。如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使试图通过调用 move 来移动它们时也是如此:

class Foo{
public:
	Foo() = default;
	Foo(const Foo&);	// 拷贝构造函数
};
Foo x;
Foo y(x);				// 拷贝构造函数; x 是一个左值
Foo z(std::move(x));	// 拷贝构造函数,因为未定义移动给构造函数

在对 z 进行初始化时,调用了 move(x), 它返回一个绑定到 xFoo&&Foo 的拷贝构造函数是可行的,因为我们可以将一个 Foo&& 转换为一个 const Foo&。因此,z 的初始化将使用 Foo 的拷贝构造函数。

Message 类的移动操作

// 从本 Message 移动 Folder 指针
void Message::move_Folders(Message* m){
	folders = std::move(m->folders);	// 使用 set 的移动赋值运算符;
	for(auto f : folders){				// 对每个 Folder
		f->remMsg(m);					// 从 Folders 中删除旧的 Message
		f->addMsg(this);				// 将本 Message 添加到 Folder 中					
	}
	m->folders.clear();					// 确保销毁 m 是无害的
}

此函数首先移动 folders 集合。通过调用 move,使用了 set移动赋值运算符而不是它的拷贝赋值运算符。如果忽略了 move 调用,代码仍能正常工作,但也带来勒不必要的拷贝。函数然后遍历所有 Folder,从其中删除指向原 Message 的指针并添加指针指向新 Message 的指针。

函数最后对 m.folders 调用 clear

Message 的移动构造函数调用 move 来移动 contents, 并默认初始化自己的 folders 成员

Message::Message(Message&& m): contents(std::move(m.contents))
{
	move_Folders(&m); // 移动 folders 并更新 Folders 指针
}

在构造函数体中,我们调用 move_Folders 来删除指向 m 的指针并插入指向本 Message 的指针

移动赋值运算符直接检查自赋值情况

Message& Message::operator=(Message&& rhs){
	if( this != &rhs ){
		remove_from_Folders();
		contents = std::move(rhs.contents);		// 移动赋值运算符
		move_Folders(&rhs);						// 重置 Folders 指向本 Message
	}
	return *this;
}

右值引用和成员函数

定义了 push_back 的标准库容器提供两个版本: 一个版本有一个右值引用参数,而另一个版本有一个 const 左值引用。假定 X 是元素类型,那么这些容器就会定义以下两个 push_back 版本:

void push_back(const X&);	// 拷贝: 绑定到任意类型的 X
void push_back(X&&);		// 移动: 只能绑定到类型 X 的可修改的右值

我们可以将能转换为类型 X 的任何对象传递给第一个版本的 push_back。对于第二个版本,我们只可以传递给他非 const 的右值。此版本对于非 const 的右值是精确匹配的,因此当我们传递一个可修改的右值时,编译器会选择运行这个版本。此版本会从其参数窃取数据

class StrVec{
public:
	void push_back(const std::string&);	// 拷贝元素
	void push_back(std::string&&);		// 移动元素
	// ...
};
void StrVec::push_back(const string& s){
	chk_n_alloc();				// 确保有空间容纳新元素
	// 在 first_free 指向的元素中构造 s 的一个副本
	alloc->construct(first_free++, s);
}
void StrVec::push_back(string&& s){
	chk_n_alloc();				// 如果需要的话为 StrVec 重新分配内存
	alloc.construct(first_free++, std::move(s));
}

这两个成员几乎时相同的。差别在于右值引用版本调用 move 来将其参数传递给 construct。如前所述,construct 函数使用其第二个和随后的实参的类型来确定使用哪个构造函数。由于 move 返回一个右值引用,传递个 construct 的实参类型是 string&&

当我们调用 push_back,实参类型决定了新元素是拷贝还是移动到容器中

StrVec vec;				// 空 StrVec
string s = "some string or another";
vec.push_back(s);		// 调用 push_back(const string&)
vec.push_back("done");	// 调用 push_back(string&&)

右值和左值引用成员函数

通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。例如:

string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');

此例中,我们在一个 string 右值上调用 find 成员,该 string 右值是通过连接两个 string 而得到的。有时,右值的使用方式可能令人惊讶:

s1 + s2 = "wow!";

此处对两个 string 的连接结果 ---- 一个右值,进行了赋值。

新标准库中仍允许向右值赋值。但,我们可能希望在自己的类中阻止这种用法。在此情况下,我们希望强制左侧运算对象( 即, this 指向的对象 )是一个左值

指出 this 的左值/右值属性的方式与定义 const 成员函数相同,即,在参数列表后防止一个引用限定符(reference qualifier)

class Foo{
public:
	Foo& operator=(const Foo& rhs) &;	// 只能向可修改的左值赋值
	// ...
};
Foo& Foo::operator(const Foo& rhs) &
{
	// 执行
	return *this
}

引用限定符可以是 &&&,分别指出 this 可以指向一个左值或右值。类似 const 限定符,引用限定符只能用于 ( 非 static ) 成员函数,且必须同时出现在函数的声明和定义中。

对于 & 限定的函数,我们只能将它用于左值, && 相反

Foo& retFoo();		// 返回一个引用; retFoo 调用是一个左值
Foo retVal();		// 返回一个值; retVal 调用是一个右值
Foo i, j;			// i 和 j 是左值
i = j;				// 正确: i 是左值
retFoo() = j;		// 正确: retFoo() 返回一个左值
retVal() = j;		// 错误: retVal() 返回一个右值
i = retVal();		// 正确:我们可以将一个右值作为赋值操作的右侧运算对象

一个函数可以同时用 const 和引用限定。在此情况下,引用限定符必须跟随在 const 限定符之后:

class Foo{
public:
	Foo someMem() & const;		// 错误: const 限定符必须在前
	Foo anotherMem() const &;	// 正确: const 限定符在前
};

重载和引用函数

一个成员函数可以根据是否有 const 来区分其重载版本一样,引用限定符也可以群分重载版本。而且,可以综合引用限定符和 const 来区分一个成员函数的重载版本。

class Foo{
public:
	Foo sorted() &&;				// 可用于可改变的右值
	Foo sorted() const &;			// 可用于任何类型的 Foo
	// ...
private:
	vector<int> data;
}:
// 本对象为右值,因此可以原址排序
Foo Foo::sorted() && {
	sort(data.begin(), data.end())p;
	retrun *this;
}

// 本对象是 `const` 或一个左值,那种情况都不能对其进行原址排序
Foo Foo::sorted() const &{
	Foo ret(*this);								// 拷贝一个副本
	sorted(ret.data.begin(), ret.data.end());	// 排序副本
	return ret;									// 返回副本
}

当我们对一个右值执行 sorted 时,它可以安全地直接对 data 成员进行排序。对象是一个右值,意味着没有其他用户。当对一个 const 右值或一个左值执行 sorted 时,不能改变对象,因此需要在排序前拷贝 data

retVal().sorted();		// retVal() 是一个右值,调用 Foo::sorted() &&
retFoo().sorted();		// retFoo() 是一个左值,调用 Foo::sorted() const &

当定义 const 成员函数时,可以定义两个版本,唯一区别就是一个版本有 const 限定二零一个没有。引用限定的函数则不一样。如果定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加

class Foo{
public:
	Foo sorted() &&;
	Foo sorted() const;			// 错误: 必须加上引用限定符
	using Comp = bool(const int&, const int&);
	Foo sorted(Comp*);			// 正确: 不同的参数列表
	Foo sorted(Comp*) const;	// 正确: 两个版本都没有引用限定符
};

本例中声明了一个没有参数, const 版本的 sorted,此声明是错误的。因为 Foo 类中还有一个无参的 sorted 版本,它有一个引用限定符,因此 const 版本也必须有引用限定符。另一方面,接受一个比较操作指针的 sorted 笨笨是没问题的,因为连个函数都没有引用限定符

如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Artintel

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值