拷贝控制
拷贝控制操作:显式或隐式地指定类的对象拷贝、移动、赋值和销毁时做什么
具体有:
- 拷贝构造函数、拷贝赋值函数
- 移动构造函数、移动赋值函数
- 析构函数
拷贝、赋值与销毁
拷贝构造函数通常不应该是 explicit
的
与默认构造函数不同,即使定义了其他构造函数,编译器依然会为我们提供一个合成拷贝构造函数
编译器可以跳过拷贝或移动构造函数,直接创建对象,如:
string null_book = "9-999-99999-9";
可能被编译器优化为:
string null_book("9-999-99999-9");
即使编译器可以这么干,但是我们仍然必须保证拷贝或者移动构造函数必须可以访问
合成拷贝赋值运算符、合成析构函数(为空)
- 需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然
= default
可以放在 cpp 文件里面,但是 = delete
不行,它只能放在类声明里面(因为编译器用它来报错)
= delete
也可以用来引导函数匹配过程
合成的拷贝控制成员可能是删除的:
- 如果类中有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被删除
- 如果类中有数据成员的析构函数被删除,那么就不提供合成默认和拷贝构造函数
- 如果含有引用,那么不提供合成默认构造函数
- 如果含有
const
成员或者含有引用,那么不提供合成拷贝赋值运算符
拷贝控制和资源管理
管理类外资源的类必须定义拷贝控制成员
首先必须确定拷贝语义,一般有两种选择:
- 类的行为像一个值:对象有自己的状态,且不同对象状态独立,例如
string
- 类的行为像一个指针:对象之间共享状态,例如
shared_ptr
通常需要实现一个 swap
函数
对象移动
旧版本 C++ 中,容器保存的类必须可拷贝,然而在新标准中,容器可以保存不可拷贝的类型,只要它们能被移动就行
右值引用只能绑定到一个将要销毁的对象
int i = 42;
int & r = i; // 正确
int && rr = i; // 错误:右值引用不能引用左值
int & r2 = i * 42; // 错误:左值引用不能引用右值(表达式的结果)
const int & r3 = i * 42; // 正确:const 引用可以引用右值
int && rr2 = i * 42; // 正确
注意,我们可以让一个 const
左值引用来引用右值表达式,这样是没问题的,因为我们不会修改这个 const
但是我们可以使用 const_cast
去掉这个 const
左值和右值区别:
- 左值持久:左值有持久的状态
- 右值短暂:一般都是临时对象,并且没有其他用户,我们就能接管它
单个变量的表达式是左值
vector
容器、移动与 noexcept
:
最好使用 noexcept
来通知标准库,让它不要做额外的工作,例如,对于 vector
来说,如果移动构造函数可能抛出异常,那么它在扩容时会使用拷贝构造函数,原因是:
- 如果
vector
使用移动构造函数,且在移动了一部分之后抛出了异常,会出现问题,旧空间中的移动源元素已经被改变了,而新空间未构造的元素可能尚不存在,这种情况下vector
并不能满足自身保持不变的要求 - 而如果使用拷贝构造,那么完全可以停止拷贝,释放新空间并抛出异常
移动赋值运算符也是同样
移动赋值运算符必须正确处理自赋值!
移动后源对象必须可析构
如果一个类定义了拷贝构造、拷贝赋值或者析构函数,那么编译器就不会为它生成合成移动构造函数。只有当一个类并没有定义任何自己版本的拷贝控制成员,并且类的每个非 static
数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符
与拷贝操作不同,编译器不会隐式定义移动操作为 delete
,只有当我们手动定义其 = delete
或者 = default
并且不能生成合成的移动操作时才会是 delete
的
移动右值,拷贝左值,但如果没有移动构造函数,右值也会被拷贝
三五法则现在要更新:如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作
移动迭代器:解引用运算符生成右值引用
引用限定符
用来限定赋值对象
class Foo {
public:
Foo & operator=(const Foo &) &; // 只能向可修改的左值赋值
};
Foo & Foo::operator=(const Foo & rhs) & {
...
}
引用限定符可以是 &
或 &&
,分别表示 this
可以指向一个左值或右值
可以同时使用 const
和引用限定,但是引用限定必须在 const
之后
注意:const &
和复合类型一样,可以匹配所有类型 的 Foo
如果有多个函数重载,且其中一个加上了引用限定,每个函数重载都必须加上引用限定:
class Foo {
public:
Foo sorted() &&;
Foo sorted() const; // 错误:必须加上引用限定符
}
重载运算与类型转换
某些运算符不应该被重载:
&&
和||
,如果重载它们,会丧失短路属性- 逗号运算符和取地址运算符,C++ 已经赋予了它们的特殊含义
使用和内置类型一致的含义
输出运算符尽量减少格式化操作,输入运算符应该处理读取错误的情况
赋值运算符、下标运算符、函数调用运算符、类型转换运算符必须是成员函数
递增递减运算符:
- 前置:
C & operator++()
和C & operator--()
,它们应该返回左值引用 - 后置:
C operator++(int)
和C operator--(int)
,它们应该返回右值(递增递减之前的值)
前置没传参,后置会传参
成员访问运算符
解引用运算符:C & operator*() const
箭头运算符:C * operator->() const
,箭头运算符一般直接调用解引用运算符,然后取地址返回
对于形如 point->mem
这样的语句来说:
- 如果
point
是指针,那么直接使用(*point).mem
- 否则,如果它重载了
operator->()
,那么先调用这个,接着递归判断
函数调用运算符
标准库中提供的函数对象:
function
类型
类型转换运算符
使用 explicit
来强制显式转换
但是有例外:
if
、while
及do
语句的条件部分for
语句的条件表达式!
、||
、&&
运算符- 三元条件运算符
?:
的条件表达式
避免过度使用类型转换运算符
避免有二义性的类型转换:
- 不要为类定义相同的类型转换
- 不要在类中定义两个及以上转换源或转换目标是算术类型的转换,让标准类型转换(指内置类型之间的转换)完成剩余的工作
在函数重载时,如果两个用户定义的类型转换都提供了可行的匹配,那么我们认为这些转换一样好,而不考虑需要的标准类型转换:
struct E {
E(double);
};
struct C {
C(int);
};
void manip2(const C &);
void manip2(const E &);
manip2(10); // C 和 E 两个用户定义的类型转换都可以用在这里,所以它们一样好
上面代码会产生二义性
函数重载与运算符重载
运算符重载会有更多的候选函数,如:
a + b;
可能调用两种东西:
a.operator+(b);
operator+(a, b);
当我们对同一个类既提供了转换目标是算术类型的类型转换,又提供了重载的运算符,则会遇到重载运算符与内置运算符的二义性问题
面向对象程序设计
基类、派生类、虚函数、纯虚函数、抽象基类、虚析构
override
关键字标识派生类中的虚函数
如果声明了析构函数(无论它是否是默认的、= default
、虚的),编译器就不会合成移动构造函数和移动赋值运算符
合成拷贝控制与继承
继承的构造函数(使用 using
):
- 和普通成员的
using
不一样,构造函数的using
不会改变该构造函数的访问级别 - 不会继承默认实参,而是会根据默认实参的个数来生成多个构造函数,例如
Type(int, int, int v = 1, int v2 = 2)
会生成三个构造函数 - 默认、拷贝和移动构造函数不会被继承
容器与继承:继承体系的对象必须采用间接存储的方式
模板与泛型编程
参考 C++ Templates 的部分和 Effective Modern C++