第13章_拷贝控制
拷贝、赋值与销毁
- 拷贝控制操作
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
- 如果一个类没有定义所有这些拷贝控制成员, 编译器会自动为它定义缺失的操作
拷贝构造函数
- 如果一个构造函数的第一个参数是自身类类型的引用, 且任何额外参数都有默认值, 此构造函数是拷贝构造函数
- 拷贝构造函数在几种情况下都会被隐式调用, 因此, 拷贝构造函数通常不应该是 explicit 的
合成拷贝构造函数
- 与合成默认构造函数不同, 即使我们定义了其他构造函数, 编译器也会为我们合成一个拷贝构造函数
- 一般情况下, 合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中, 编译器从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中
- 每个成员的类型决定了它如何拷贝: 对类类型的成员, 会使用其拷贝构造函数来拷贝; 内置类型的成员则直接拷贝
- 虽然我们不能直接拷贝一个数组, 但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员, 如果数组元素是类类型, 则使用元素的拷贝构造函数来进行拷贝
1
2
3
4
5
6
7
8
9class Test {
public:
int a[2] = {0,0};
};
Test t1;
t1.a[0] = 1;
Test t2(t1);
std::cout << t1.a[0] << " " << t2.a[0] << std::endl; // 1 1
参数和返回值
- 在函数调用过程中, 具有非引用类型的参数要进行拷贝初始化
- 当一个函数具有非引用的返回类型时, 返回值会被用来初始化调用方的结果
- 拷贝构造函数被用来初始化非引用类类型参数, 这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型
编译器可以绕过拷贝构造函数
- 在拷贝初始化过程中, 编译器可以(但不是必须)跳过拷贝\移动构造函数, 直接创建对象
1
2std::string str = "yzy"; // 拷贝初始化
std::string str("yzy"); // 编译器略过了拷贝构造函数 - 即使编译器略过了拷贝/移动构造函数, 但在这个程序点上, 拷贝/移动构造函数必须是存在且可访问的(例如, 不能是 private 的)
拷贝赋值运算符
- 重载运算符本质上是函数, 其名字由 operator 关键字后接表示要定义的运算符的符号组成. 因此, 赋值运算符就是一个名为 operator= 的函数
- 某些运算符, 包括赋值运算符, 必须定义为成员函数
- 如果一个运算符是一个成员函数, 其左侧运算对象就绑定到隐式的 this 参数
- 拷贝赋值运算符接受一个与其所在类相同类型的参数. 为了与内置类型的赋值保持一致, 赋值运算符通常返回一个指向其左侧运算对象的引用
析构函数
- 由于析构函数不接受参数, 因此不能被重载
- 在一个构造函数中, 成员的初始化是在函数体执行之前完成的, 且按照它们在类中出现的顺序进行初始化; 在一个析构函数中, 首先执行函数体, 然后销毁成员, 成员按初始化顺序的逆序销毁
- 对于临时对象, 当创建它的完整表达式结束时被销毁
三/五法则
- 需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作, 反之亦然
使用 =default
- 可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本
- 在类内用 =default 修饰成员时, 合成的函数将隐式地声明为内联的; 若不希望合成的成员是内联函数, 应该只对成员的类外定义使用 =default
阻止拷贝
- 虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符, 但对某些类来说, 这些操作没有合理的意义(eg: iostream 类阻止了拷贝, 以避免多个对象写入或读取相同的 IO 缓冲)
- 为了阻止拷贝, 看起来可能应该不定义拷贝控制成员. 但是, 这种策略是无效的(若类未定义这些操作, 编译器为它生成合成的版本)
定义删除的函数
- 在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的
- 对于析构函数已删除的类型, 不能定义该类型的变量, 但可以定义该类型动态分配的对象(但是不能释放这些对象)
合成的拷贝控制成员可能是删除的
- 如果类的某个成员的析构函数是删除的或不可访问的, 则类的合成析构函数被定义为删除的
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的, 则类的合成拷贝构造函数被定义为删除的; 如果类的某个成员的析构函数是删除的或不可访问的, 则类合成的拷贝构造函数也被定义为删除的
- 如果类的某个成员的拷贝赋值运算符是删除或不可访问的, 或是类有个 const 的或引用成员, 则类的合成拷贝赋值运算符被定义为删除的
- 如果类的某个成员的析构函数是删除的或不可访问的, 或是类有一个引用成员, 它没有类内初始化器, 或是类有一个 const 成员, 它没有类内初始化器且其类型未显示定义默认构造函数, 则该类的默认构造函数被定义为删除的
- 本质上: 如果一个类有数据成员不能默认构造, 拷贝, 复制或销毁, 则对应的成员函数将被定义为删除的
private 拷贝控制
- 老版本是通过将拷贝构造函数和拷贝赋值运算符声明为 private 的来阻止拷贝(新版本应该使用 =delete)
拷贝控制和资源管理
行为像值的类
定义行为像指针的类
交换操作
- 如果一个类定义了自己的 swap, 那么 swap 算法函数将使用类自定义版本
拷贝控制实例
动态内存管理类
对象移动
- 在某些情况下, 对象拷贝后就立即被销毁了, 在这些情况下, 移动而非拷贝对象会大幅度提升性能
- 使用移动而不是拷贝另一个原因源于 IO 类或 unique_ptr 这样的类, 这些类都包含不能被共享的资源(如 IO 缓冲或指针), 因此, 这些类型的对象不能拷贝但可以移动
右值引用
- 所谓右值引用就是必须绑定到右值的引用, 通过 && 而不是 & 来获得右值引用
- 右值引用有一个重要的性质: 只能绑定到一个将要销毁的对象
- 一个左值表达式表示的是一个对象的身份, 而一个右值表达式表示的是对象的值
- 类似任何引用, 一个右值引用也不过是某个对象的另一个名字而已
- 不能将一个右值引用绑定到一个左值上
- 左值有持久的状态, 而右值要么是字面常量, 要么是在表达式求值过程中创建的临时对象
- 右值引用指向将要被销毁的对象, 因此, 我们可以从绑定到右值引用的对象窃取状态
- 变量是左值, 因此不能将一个右值引用直接绑定到一个变量上, 即使这个变量是右值引用类型也不行
标准库 move 函数
- 虽然不能将一个右值引用直接绑定到一个左值上, 但可以显式地将一个左值转换为对应的右值引用类型, 还可以通过调用一个名为 move 的新标准库函数来获得绑定到左值上的右值引用
- 可以销毁一个移后原对象, 也可以赋予它新值, 但不能使用一个移后源对象的值
- 使用 move 的代码应该使用 std::move 而不是 move, 这样可以避免潜在的名字冲突
移动构造函数和移动赋值运算符
- 由于移动操作窃取资源, 通常不分配任何资源. 因此, 移动操作通常不会抛出任何异常(当编写一个不抛出异常的移动操作时, 应该将此事通知标准库, 避免标准库做一些额外的工作)
- 必须在类头文件的声明和定义中都指定 noexcept
- 在移动操作之后, 移后原对象必须保持有效的, 可析构的状态, 但是用户不能对其值进行任何假设
- 只有当一个类没有定义任何自己版本的拷贝控制成员, 且它的所有数据成员都能移动构造或移动赋值时, 编译器才会为它合成移动构造函数或移动赋值运算符
- 与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。若显式地要求编译器生成 =default 的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数
- 将合成的移动操作定义为删除的函数需满足:
- 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的
- 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
- 类似拷贝赋值运算符,如果有类成员是 const 的或是引用,则类的移动赋值运算符被定义为删除的
- 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则,这些成员默认被定义为删除的
- 如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似
右值引用和成员函数
- 区分移动和拷贝的重载函数通常有一个版本接受一个 const T&,而另一个版本接受一个 T&&
1
2void push_back(const std::string&);
void push_back(std::string&&);
右值和左值引用成员函数
- 引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值。类似 const 限定符,引用限定符只能用于(非 const)成员函数,且必须同时出现在函数的声明和定义中。(一个函数可以同时用 const 和引用限定,在此情况下,引用限定必须跟随在 const 限定符之后)
1
2
3
4class Foo{
public:
Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
};
重载和引用函数
- 就像一个成员函数可以根据是否有 const 来区分重载版本一样,引用限定符也可以区分重载版本
- 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符