拷贝控制
一个类通过定义五种特殊的成员函数来控制对象拷贝、移动、赋值和销毁,这些函数包括:拷贝构造函数、拷贝赋值允素福、移动构造函数、移动赋值运算符和析构函数。 我们成这些操作为 拷贝控制操作。
1 拷贝、赋值与销毁
1.1 拷贝构造函数
如果一个构造函数的抵押给参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数时拷贝构造函数。
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
};
拷贝构造函数的第一个参数必须是引用类型,拷贝构造函数通常不应该是 explicit(阻止隐式转换) 的。
合成拷贝构造函数
如果我们没有为一个类定义 拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
合成拷贝构造函数用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。
Sales_data 类的合成拷贝构造函数等价于:
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo), // 使用 string 拷贝构造函数
units_sold(orig.units_sold),
revenue(orig.revenue)
{ } // 空函数体
拷贝初始化
下面是一些直接初始化和拷贝初始化的例子:
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-99"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。 当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成,如果一个类由一个移动构造函数,则靠被初始化有时会使用移动构造函数来完成。
拷贝初始化不仅在我们用 = 定义变量时会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参;
- 从一个返回类型为非引用类型的函数返回一个对象;
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
某些类类型还会对它们所分配的对象使用靠被初始化。例如,当我们初始化标准库容器或是调用其 isert 或 push 成员,容器会对其元素进行靠被初始化。
参数和返回值
在调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了 为什么拷贝构造函数自己的参数必须是引用类型。如果参数不是引用类型,则调用永远不会成功 —— 为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝它的实参,我们又需要调用拷贝构造函数,无限循环。
拷贝初始化的限制
如果我们使用的初始化值要求通过一个 explicit 的构造函数来进行类型转化,那么使用拷贝初始还是直接初始化就不是无关紧要的了:
vector<int> v1(10); // 正确 直接初始化
vector<int> v2 = 10; // 错误 接受大小参数的构造函数是 explicit
void f(vector<int>); // f 的参数进行拷贝初始化
f(10); // 错误 不能用一个 explicit 的构造函数拷贝一个实参
f(vector<int>(10)); // 正确 从一个 int 直接构造一个临时 vector
1.2 拷贝复制运算符
重载赋值运算符
重载运算符本质上是函数,起名字由 operator 关键字后接表示要定义的运算符的符号组成。赋值运算符就是一个名为 operator= 的函数。
重载运算符的参数表示运算符的运算对象,如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this 参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。
class Foo{
public:
Foo& operator = (const Foo&); //赋值运算符
};
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个 合成拷贝赋值运算符。对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值,如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非 static 成员赋予左侧运算对象的对应成员。
// 等价于合成拷贝赋值运算符
Sales_data& Sales_data::operator=(const Sales_data &rhs) {
bookNo = rhs.bookNo;
units_sold = ths/units_sold;
revenue = rhs.revenue;
return *this; // 返回一个此对象的引用
}
1.3 析构函数
构造函数初始化对象的非 static 数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成,它没有返回值,也不接受参数:
class Foo {
public:
~Foo(); // 析构函数
};
由于析构函数不接受参数,因此它不能被重载,对一个给定类,只会有唯一的一个的析构函数。
析构函数完成什么工作
在一个构造函数中,成员的初始化时在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。通常,析构函数释放对象在生存期分配的所有资源。
注: 隐式销毁一个内置指针类型的成员不会 delete 它所指向的对象,智能指针是类类型,具有析构函数,智能指针成员在析构阶段会被自动销毁。
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数
- 变量在离开作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁。
- 对于临时对象,当创建完它的完整表达式结束时被销毁。
由于析构函数自动运行,我们的程序可以按需要分配资源,而(通常)无需担心和何时放这些资源。
注: 当指向一个对象的引用或者指针离开作用域时,析构函数不会执行。
合成析构函数
当一个类未定义自己的析构函数时,编译器会对它定义一个 合成析构函数。对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数就为空。
// 等价于 Sales_data 的合成析构函数
class Sales_data {
public:
// 成员会被自动销毁,除此之外不需要做其他事情
~Sales_data() { }
};
在(空)析构函数体执行完毕后,成员会被自动销毁。特别的,string 的析构函数会被调用,它将释放 bookNo 成员所用的内存。
析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
1.4 三/五法则
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。在新标准下, 一个类还可以定义一个移动构造函数和一个移动赋值运
算符。一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
需要析构函数的类也需要拷贝和赋值操作
如果类需要一个析构函数, 我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
如果一个类在构造函数中分配动态内存。合成析构函数不会 delete 一个指针数据成员。因此, 此类需要定义一个析构函数来释放构造函数分配的内存。如果使用合成版本的拷贝构造函数和拷贝赋值运算时:
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) :
ps(new std::string(s)), i(0) {}
~HasPtr() { delete ps; }
// 错误: HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
//其他成员的定义, 如前
}
在这个版本的类定义中,构造函数中分配的内存将在 HasPtr 对象销毁时被释放。但不幸的是, 我们引入了一个严重的错误!这个版本的类使用了合成的拷贝构造函数和拷贝赋值运算符。这些函数简单拷贝指针成员,这意味着多个 HasPtr 对象可能指向相同的内存:
/* HasPtr 是传值参数, 所以将被拷贝 */
HasPtr f(HasPtr hp) {
HasPtr ret = hp; // 拷贝给定的 HasPtr
// 处理 ret
return ret; // ret 和 hp 被销毁
}
当 f 返回时,hp 和 ret 都被销毁, 在两个对象上都会调用 HasPtr 的析构函数。此析构函数会 delete ret 和 hp 中的指针成员。但这两个对象包含相同的指针值。此代码会导致此指针被 delete 两次, 这显然是一个错误。将要发生什么是未定义的。
注: 如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。
第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然 —— 如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。
然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
1.5 使用 =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。
1.6 阻止拷贝
对于某些类来说,定义拷贝构造函数和拷贝赋值运算符,这些操作没有合理的意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream 类阻止了拷贝,以避免多个对象写入或读取相同的 IO 缓存。
定义删除函数
在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为 删除的函数 来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的:
struct NoCopy {
NoCopy() = default; // 使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
};
= delete 通知编译器(以及代码的读者),我们不希望定义这些成员。
与 =default 不同, =delete 必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。
与 =default 的另一个不同之处是,我们可以对任何函数指定 =delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default )。
析构函数不能是删除的成员
如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。因为,如果一个成员的析构函数是删除的,则该成员无法被销毁。而如果一个成员无法被销毁,则对象整体也就无法被销毁了。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象:
struct NoDtor {
NoDtor() = default; // 使用合成默认构造函数
~NoDtor() = delete; // 我们不能销毁 NoDtor 类型的对象
};
NoDtor nd; // 错误:NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能 delete p
delete p; // 错误:NoDtor 的析构函数是删除的
合成的拷贝成员可能是删除的
对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的 析构函数 是删除的或不可访问的(例如,是 private 的),则类的 合成析构函数 被定义为删除的。
- 如果类的某个成员的 拷贝构造函数 是删除的或不可访问的,则类的 合成拷贝构造函数 被定义为删除的。如果类的某个成员的 析构函数 是删除的或不可访问的,则类 合成的拷贝构造函数 也被定义为删除的。
- 如果类的某个成员的 拷贝赋值运算符 是删除的或不可访问的,或是类有一个 const 的或引用 成员,则类的 合成拷贝赋值运算符 被定义为删除的。
- 如果类的某个成员的 析构函数 是删除的或不可访问的,或者 类有一个引用成员,它没有类内初始化器,或者 类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的 默认构造函数 被定义为删除的。
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的。
一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的。
注: 本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。
private 拷贝控制
在新标准前,类是通过将其拷贝构造函数的拷贝赋值运算符声明为 private 的阻止拷贝。但是,友元和成员函数仍然可以拷贝对象。为了阻止友元和成员函数进行拷贝,可以将这些拷贝控制成员声明为 private 的,但并不定义它们。
声明但不定义一个成员函数是合法的。试图访问一个未定义的成员将导致一个链接时错误。通过声明(但不定义)private 的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图 拷贝对象 的用户代码将在 编译阶段 被标记为错误;成员函数或友元函数中的拷贝操作 将会导致 链接时 错误。
注: 希望阻止拷贝的类应该使用 =delete 来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为 private 的。
2 拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员。这种类通常需要通过析构函数来释放对象所分配的资源,而一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
类的行为像一个值,意味着它应该也有自己的状态,当我们拷贝一个像值的对象时,副本和原对象完全独立。行为像指针的类则共享状态,当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。
2.1 行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于 ps 指向的 string,每个 HasPtr 对象都必须有自己的拷贝。
为了实现类值行为,HasPtr 需要
- 定义一个拷贝构造函数,完成 string 的拷贝,而不是拷贝指针
- 定义一个析构函数来释放 string
- 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝 string
类值版本的 HasPtr 如下所示
class Hasptr {
public:
HasPtr(const std::string &s = std:string()):
ps(new std::string(s)), i(0) { }
// 对 ps 指向的 string,每个 HasPtr 对象都有自己的拷贝
HasPtr(const Hasptr &p):
ps(new std::string(*p.ps)), i(p.i) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作,赋值操作会销毁左侧运算对象的资源(析构函数),从右侧运算对象拷贝数据(拷贝构造函数)。如果可能,我们编写的赋值运算符,应该是异常安全的 —— 当异常发生时能将左侧运算对象置于一个有意义的状态。
HasPtr& HasPtr::operator=(const HasPtr &) {
auto newp = new string(*rhs.ps); // 拷贝底层 string
delete ps; // 释放就内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = ths.i;
return *this; // 返回本对象
}
注: 对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能争取工作。一个好的方法是在销毁左侧运算对象资源前拷贝右侧运算对象。
2.2 定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的 string。
令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源。拷贝(或赋值)一个 shared_ptr 会拷贝(赋值)shared_ptr 所指向的指针。shared_ptr 类自己记录由多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr 类负责释放资源。
有时希望直接管理资源。这种情况,使用 引用计数。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
计数器不能直接作为 HasPtr 对象的成员,
HasPtr p1("Hiya!");
HasPtr p2(p1);
HasPtr p3(p1);
如果引用计数器保存在每个对象中,当创建 p3 时,可以递增 p1 中的计数器并拷贝到 p3 中,但是却无法更新 p2 中的计数器。解决这一问题的一种方法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。
定义一个使用引用计数的类
// 定义一个使用引用计数类,我们用动态内存来存储引用计数
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(cosnt 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 的成员
};
类指针的拷贝成员 ”篡改“ 引用计数
当拷贝或复制一个 HasPtr 对象时,我们希望副本和原对象都指向相同的 string。即,当拷贝一个 HasPtr 时,我们将拷贝 ps 本身,而不是 ps 指向的 string。当我们进行拷贝时,还会递增该 string 关联的计数器。
HasPtr::~HasPtr() {
// 如果引用计数变为 0
if(--*use; == 0) {
delete ps; // 释放 string 内存
delete use; // 释放计数器内存
}
}
拷贝赋值运算符执行类似拷贝构造函数和析构函数的工作,它必须递增右侧运算对象的引用计数,并递减左侧运算对象的引用计数,在必要时释放使用的内存。
HasPtr& HasPtr::operator=(const HasPtr & rhs) {
++*rhs.use; // 递增右侧运算对象的引用计数
if(--*use; == 0) {
delete ps; // 释放 string 内存
delete use; // 释放计数器内存
}
ps = rhs.ps; // 将数据从 ths 拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
3 交换操作
除了定义拷贝控制成员,管理资源的类通常还定义了名为 swap 的函数。对于那些与重排元素顺序的算法。一起使用的类,定义 swap 是很必要的,因为虽然 swap 不是必要的但却是优化程序效率的重要手段。这类算法交换两个元素通常使用 swap。
如果一个类定义了自己的 swap,那么算法将使用类自定义的版本。否则将使用标准库定义的 swap。虽然不确定标准库具体如何实现的。但应该是类似于下面:
HasPtr temp = v1; // 创建 v1 的值的一个临时副本
v1 = v2; // 将 v2 的值赋给 v1
v2 = temp; / 将保存的 v1 的值赋予 v2
这段代码将原来 v1 中的 string 拷贝了两次,理论上这些内存分配都是不必要的,我们更希望 swap 交换指针,而不是分配 string 的新副本:
string *temp = v1.ps; // 为 v1.ps 中的指针创建一个副本
v1.ps = v2.ps; // 将 v2.怕是中的指针赋予 v1.ps
v2.ps = temp; // 将保存的 v1.ps 中观来的指针赋予 v2.ps
注: swap 并不是必要的,但是,对于分配了资源的类,定义 swap 可能是一种很重要的优化手段。
在赋值运算符中使用 swap
定义 swap 的类通常用 swap 来定义它们的赋值运算符。这些运算符使用了一种名为 拷贝并交换 的技术。
// 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数将右侧运算对象中的 string 拷贝到 rhs
HasPtr& HasPtr::operatpr=(HasPtr) {
// 交换左侧运算对象和局部变量 rhs 的内容
swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存
return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针
}
在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值的方式传递给了赋值运算符。
注: 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。
4 动态内存管理类
解决问题:某些类需要在运行时分配可变大小的内存空间,这种类通常可以使用标准库容器来保存它们的数据,但是这一策略并不是对每个类都适用;某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
tips: vector 类将其元素保存在连续内存中。为了获得可接受的性能,vector 预先分配足够的内存来保存可能需要的更多元素。vector 的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会再下一个可用位置构造一个对象。如果没有可用空间,vector 就会重新分配空间:将已有元素移动到新空间中,释放就空间,并添加新元素。
StrVec 类的设计
我们使用一个 allocator 来获取原始内存,由于 allocator 获取的原始内存是未构造的,我们将在需要添加新元素时使用 constructor 在原始内存中创建对象,在删除元素时使用 destory 销毁元素。每个StrVec 有三个指针成员指向其元素使用的内存:
- elements:指向分配的内存中的首元素
- first_free:指向最后一个实际元素之后的位置
- cap:指向分配的内存末尾之后的位置
StrVec 还有一个 alloc 的静态成员,其类型为 allocator<string>。alloc 成员会分配 StrVec 使用的内存。类还有4个工具函数:
- alloc_n_copy 会分配内存,并拷贝一个给定范围中的元素
- free 会销毁构造的元素并释放内存
- chk_n_alloc 保证 StrVec 至少有容纳一个新元素的空间;否则会调用 reallocate 分配更多内存
- reallocate 在内存用完时为 StrVec 分配新内存
StrVec 类定义
// 类 vector 类内存分配策略的简化实现
class StrVec {
public:
StrVec(): // allocator 成员进行默认初始化
elements(NULL), first_free(NULL), cap(NULL) { }
StrVec(const StrVec&); // 拷贝构造函数
StrVec& operator=(const StrVec&); // 拷贝赋值运算符
~StrVec(); // 析构函数
void push_back(const std::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 std::allocator<std::string> alloc; // 分配元素
// 被添加元素的函数所使用
void chk_n_alloc() {
if (size() == capacity())
rellocate();
}
// 工具函数,被拷贝构造函数、赋值运算符和析构函数所适用
std::pair<std::string*, std::string*> alloca_n_copy(const std::string*, const std::string*);
void free(); // 销毁元素并释放内存
void rellocate(); // 获取更多内存并拷贝已有内存
std::string *elements; // 指向数组首元素指针
std::string *first_free; // 指向数组中第一个空闲位置
std::string *cap; // 指向数组尾后位置指针
};
使用 construct
函数 push_back 调用 chk_n_alloc 确保有空间容纳新元素。如果需要,调用 reallocate。当 chk_n_alloc 返回时,必有空间容纳新元素,要求 allocator 成员来 construct 新的尾元素:
void StrVec::push_back(const string& s) {
chk_n_alloc(); // 确保有空间容纳新元素
// 在 first_free 指向的元素中构造 s 的副本
alloc.construct(first_free++, s);
}
当我们使用 allocator 分配内存是,必须记住内存是 为构造的。为了使用此原始内存,我们必须调用 construct,再次内存中构造一个对象。传递给 construct 的第一个参数必须是一个指针,指向调用 allocate 所分配的为构造的内存空间。剩余参数确定用哪个构造函数来构造对象。
alloc_n_copy 成员
alloc_n_copy 成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的 pair 对,分别指向 新空间的开始位置 和 最后一个构造元素后的位置:
pair<string*, string*> StrVec::alloca_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); }
}
free 成员
free 成员两个责任:首先 destroy 元素,然后释放 StrVec 自己开辟的空间。
void StrVec::free() {
// 不能传递给 deallocate 一个空指针,如果 elements 为 0,函数什么也不做
if (elements) {
// 逆序销毁旧元素
for (auto p = first_free; p != elements;)
alloc.destroy(--p);
alloc.deallocate(elements, cap - elements);
}
}
一旦元素被销毁,我们就调用 deallocate 来释放本 StrVec 对象分配的内存空间。
拷贝控制成员和析构函数
// 拷贝构造函数
StrVec::StrVec(const StrVec&s) {
// 调用 alloca_n_copy 分配空间以容纳与 s 中一样多的元素
auto newdata = alloca_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
// 析构函数
StrVec::~StrVec() {
free();
}
// 拷贝赋值预算符
StrVec& StrVec::operator=(const StrVec& rhs) {
auto data = alloca_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
再重新分配内存的过程中移动而不是拷贝元素
StrVec 重新分配内存会由旧空间到新空间逐个拷贝 string,string 具有类值行为。当拷贝一个 string,新的 string 和原 string 之间是相互独立的,改变原 stirng 不会影响到副本,反之亦然。
由于 string 行为类似值,拷贝一个 string 必须为这些字符分配内存空间,而销毁一个 string 必须释放所占用的内存。因此拷贝这些 string 中的数据是多余的。在重新分配内存空间时,如果我们能避免分配和释放 string 的额外开销,StrVec 性能会好的多。
移动构造函数和 std::move
新标准库引入的两种机制,可以避免 string 的拷贝。移动构造函数 和 一个名为 move 的标准库函数。
move 定义在 utility 头文件中。关于 move 需要了解两个关键点,首先,在新内存构造 string 时,必须调用 move 表示希望使用 string 的移动构造函数,如果它漏掉了 move 调用,将会使用 string 的拷贝构造函数;其次,我们通常不为 move 提供一个 using 声明,当我们使用 move 时直接调用 std::move。
reallovate 成员
void StrVec::rellocate() {
// 将分配当前大小两倍的内存空间
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(); // 一旦我们移动玩元素旧释放旧内存空间
// 更新数据结构,执行新元素
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
5 对象移动
新标准一个最主要的特性就是可以移动而非拷贝对象的能力。在某些情况下,对象拷贝后旧立即被销毁了,在这样的情况下,移动而非拷贝对象会大幅度提升性能。对于类似 IO 类或 unique_ptr 这样的类,都包含不能被共享的资源(指针或 IO 缓冲),因此,这些类型的对象不能拷贝但可以移动。
注: 标准库、string 和 shared_ptr 类及支持移动也支持拷贝。IO 类和 unique_ptr 类可以移动但不能拷贝。
5.1 右值引用
右值引用 就是必须绑定到右值的引用,我们通过 && 而不是 & 来获取右值引用。右值引用有一个重要的性质 —— 只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动“到另一个对象中。
注: 左值和右值时表达式的属性,一般而言一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的时对象的值。
我们不能将左值引用绑定到要求转换的表达式、字面常量或者是返回右值的表达式,但是可以将一个右值引用绑定到这类表达式上。
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 &&rr1 = 42; // 正确:字面常量是右值
int &&rr2 = rr1; // 错误:表达式 rr1 是左值
变量是持久的,只有离开作用域才会被销毁。
注: 变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
标准库 move 函数
虽然不能将一个右值引用直接绑定到一个左值上,但可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为 move 的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件 utility 中。
int &&rr3 = std::move(rr1);
move 调用告诉编辑器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用 move 就意味着承诺:除了对 rr1 赋值或销毁它外,我们将不再使用它。在调用 move 之后,我们不能对移后源对象的值做任何假设。
注: 我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
5.2 移动构造函数和移动赋值运算符
移动构造函数的 第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个 右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态 —— 销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源一这些资源的所有权已经归属新创建的对象。
我们为 StrVec 类定义移动构造函数,实现从一个 StrVec 到另一个 StrVec 的元素移动而非拷贝:
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常
// 成员初始化器接管 s 中的资源
: elements(s.elements), 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,则销毁移后源对象就会释放掉我们刚刚移动的内存。
移动操作、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
noexcept 是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定 noexcept。在一个构造函数中,noexcept 出现在参数列表和初始化列表开始的冒号之间。我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定 noexcept。
- 虽然移动操作符通常不抛出异常,但是抛出异常也是允许的
- 标准库容器能对异常发生时其自身的行为提供保证,比如 vector 保证如果我们调用 push_back 时发生异常,那么 vector 自身不会发生改变
移动一个对象通常会改变它的值,如果重新分配过程中使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常:旧空间中的移动源元素已经被改变了,但是新空间中未构造的元素可能还不存在。这种情况下, vector 不能满足自身保持不变的要求。
如果 vector 使用的是拷贝构造函数并且发生异常,在新内存中构造元素时旧元素保持不变,这时候如果发生异常 vector 可以直接释放新分配(但还没构造成功)的内存并返回。 vector 中的元素仍然存在。
为了避免这种潜在问题, vector 除非知道元素类型的构造移动函数不会发生异常,否则在重新分配内存的过程中它就必须使用拷贝构造函数而不是移动构造函数。当我们希望在 vector 重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数不会发生异常,可以安全使用。我们通过将移动构造函数(及移动赋值运算符)标记为 noexcept 来做到这一点。
移动赋值预算符
移动赋值运算符 执行与 析构函数和移动构造函 数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为 noexcept。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:
StrVec &StrVec::operator=(StrVec &&rhs) noexcept{
// 直接检测自赋值
if (this != rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = ths.cap;
// 将 rhs 置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
我们进行检查 this 指针与 rhs 的地址是否相同的原因是此右值可能是 move 调用的返回结果。
注: 移后源对象必须是可析构的,在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
合成的移动操作
编译器不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 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&&) = default;
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)); // 拷贝构造函数;因为未定义移动构造函数
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
拷贝并交换赋值运算符和移动操作
我们的 HasPtr 版本定义了一个拷贝并交换赋值运算符,它是函数匹配和移动操作间相互关系的一个很好的示例。如果我们为此类添加一个移动构造函数,它实际上也会获得一个移动赋值运算符:
class HasPtr{
public:
// 添加的移动构造函数
HasPtr(HasPtr &&r) noexcept :
ps(p.ps), i(p.i) { p.ps = 0; }
// 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
HasPtr& operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}
};
移动迭代器
新标准库中定义了一种 移动迭代器 适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
我们通过调用标准库的 make_move_iterator 函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。
原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给 uninitialized_copy:
void StrVec::reallocate() {
// 分配大小两倍于当前规模的内存空间
auto newcapacity = size() ? 2 * size() : 1;
auto first = alloc.allocate(newcapacity);
// 移动元素
auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
free(); // 释放旧空间
elements = first; // 更新指针
first_free = last;
cap = elements + newcapacity;
}
uninitialized_copy 对输入序列中的每个元素调用 construct 来将元素“拷贝”到目的位置。此算法使用迭代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着 construct 将使用移动构造函数来构造元素。
标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
注: 在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确信需要进行移动操作且移动操作是安全的,才可以使用std::move。
5.3 右值引用和成员函数
如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式 —— 一个版本接受一个指向 const 的左值引用,第二个版本接受一个指向非 const 的右值引用。
例如,定义了 push_back 的标准库容器提供两个版本:一个版本有一个右值引用参数,而另一个版本有一个 const 左值引用。假定 X 是元素类型,那么这些容器就会定义以下两个 push_back 版本:
void push_back(const X&); // 拷贝 绑定到任意类型的 X
void push_back(X&&); // 移动 只能绑定到类型 X 可修改的右值
我们可以将能转换为类型 X 的任何对象传递给第一个版本的 push_back。此版本从其参数拷贝数据。对于第二个版本,我们只可以传递给它非 const 的右值,此版本会从其参数窃取数据。
注: 区分移动和拷贝的重载函数通常有一个版本接受一个 const T&,而另一个版本接受一个 T&& 。
我们将为 StrVec 类定义另一个版本的 push_back:
class StrVec {
public:
void push_back(const std::string&); // 拷贝
void push_back(std::string&&); // 移动
};
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));
}
当我们调用 push_back 时,实参类型决定了新元素是拷贝还是移动到容器中:
StrVec vec; // 空 Strvec
string s = "abcd";
vec.push_back(s); // 调用 push_back(const string& s)
vec.push_back("done"); // 调用 push_back(string &&s)
右值和左值引用成员函数
通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。有时,右值的使用方式可能令人惊讶:
s1 + s2 = "wow!";
此处我们对两个 string 的连接结果 —— 一个右值,进行了赋值。在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍允许向右赋值。但是,我们可能希望在自己的类中阻止这种用法。我们希望强制左侧运算对象是一个左值。
我们指出 this 的左值/右值属性的方式与定义 const 成员函数相同,即,在参数列表后放置一个引用限定符:
class Foo {
public:
Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
};
引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值,引用限定符只能用于(非 static)成员函数,且必须同时出现在函数的声明和定义中。
一个函数可以同时用 const 和引用限定。在此情况下,引用限定符必须跟随在const限定符之后:
class Foo {
public:
Foo some(Mem) & const; // 错误:const 限定符必须在前
Foo anotherMem() const &; // 正确
};
重载和引用函数
引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和 const 来区分一个成员函数的重载版本:
class Foo {
public:
Foo sorted() &&; // 可用于可改变的右值
Foo sorted() const &; // 可用于任何类型的 Foo
private:
vector<int> data;
};
// 本对象为右值,因此可以原址排序
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
// 本对象是 const 或是一个左值,哪种情况我们都不能对其进行原址排序
Foo Foo::sorted() const & {
Foo ret(*this); // 拷贝一个副本
sort(ret.data.begin(), ret.data.end()); // 排序副本
return ret; // 返回副本
}
当我们定义 const 成员函数时,可以定义两个版本,唯一的差别是一个版本有 const 限定而另一个没有。引用限定的函数则不一样,如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加:
class Foo{
public:
Foo sorted() &&;
Foo sorted() const; // 错误:必须加上引用限定符
// Comp是函数类型的类型别名
// 次函数类型可以用来比较int值
using Comp = bool(const int &, const int&);
Foo sorted(Comp*); // 正确:不同的参数列表
Foo sorted(Comp*) const; // 正确:两个版本都没有引用限定符
};
注: 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。