拷贝控制操作包括:
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
1 3 定义了使用同类型的对象初始化本对象时的操作
2 4 定义了将同类型的对象赋予给本对象时的操作
5 定义了销毁该类型对象时的操作
拷贝构造
拷贝构造函数:
第一个参数为自身类型的引用(而且基本上都是const
引用)
并且其他参数(如果有的话)都有默认值
一般而言 拷贝构造函数常常会被隐式调用 因此不会定义为explicit
与合成默认构造函数不同 即使我们定义了其他构造函数 编译器也会合成一个拷贝构造函数
合成拷贝构造函数会将其参数的非static成员逐个拷贝到正在创建的对象中
使用直接初始化时 编译器使用普通的函数匹配来选择与提供参数最匹配的构造函数
而使用拷贝初始化时 编译器将右侧运算对象拷贝到正在创建的对象中 如果需要的话 会进行类型转换
string s("1123");
string s2(s); //都是直接初始化
string s3=s;
string s4="11"; //都是拷贝初始化
除了使用=
定义变量时显式调用拷贝初始化 在隐式的类型转换时也会进行:
- 将一个对象作为实参传递给一个非引用的形参
- 作为函数返回值返回一个对象
- 用花括号列表初始化一个数组中的元素或者一个聚合类的成员
此外 使用标准库容器时 调用insert
和push
会调用拷贝初始化
而使用emplace
会进行直接初始化
拷贝构造函数被用来初始化非引用类型参数 因此拷贝构造函数自身参数必须是引用 不然就会无限循环调用自己了
重载运算符本质上是函数 由operator
关键字后接表示要定义的运算符的符号组成
类似其他函数 重载运算符也有返回类型和参数
例如拷贝赋值运算符是一个名为operator=
的函数,它接受与类相同类型的参数
重载运算符的参数表示运算对象 某些运算符(如赋值运算符)必须定义为成员函数
若重载运算符是一个成员函数 那么其左侧运算对象默认绑定到隐式的this
对象上
赋值运算符通常应该返回一个指向其左侧运算对象的引用
析构函数
析构函数负责释放对象使用的资源 并销毁对象的非static成员
析构函数没有返回值 也不接受参数 以~关键字开头 如 ~Foo(){ }
由于没有参数 析构函数也不能被重载 对一个类型而言 析构函数是唯一的
构造函数中成员初始化是在函数体执行之前的 且初始化顺序与它们在类中出现的顺序一致
而析构函数中 函数体执行之后才销毁成员 销毁顺序是初始化的逆顺序
但不同于初始化时可以利用初始化列表来控制如何初始化
析构函数中析构部分是隐式的 自动完成
析构函数体并不需要承担销毁成员的责任 销毁成员是在函数体结束后隐式自动完成的
但注意 销毁一个内置指针类型的成员 不会delete
它所指向的对象
而智能指针是类类型 会自动销毁其成员
析构函数会在对象被销毁时(如离开作用域)自动调用
一般而言 拷贝运算符和拷贝构造函数以及析构函数 三者是需要一块定义的
因为往往定义析构函数是为了释放指针的指向内存
然而未定义拷贝构造函数和运算符的话 默认的合成拷贝构造函数和运算符 会直接简单拷贝指针成员 这就导致多个对象中的成员指向相同的内存
当析构函数delete时会多次删除同一块内存 引发未知错误
对于具有默认合成版本的成员函数 可以使用=default
来显式要求使用编译器合成的版本
某些时候 需要阻止拷贝构造和拷贝赋值(如IO类型禁止拷贝以免多个对象读取或写入相同流)
我们需要定义一个对应的拷贝构造和拷贝赋值函数来阻止
因为不定义的话会自动生成合成版本 可以通过将这些函数定义为删除函数(新标准)或者将这些函数定义为私有函数(旧标准方法) 来阻止
删除函数: 在函数的参数列表后加上=delete
定义 意味着虽然声明了该函数 但不能使用它们
=delete
必须出现在函数第一次声明时
但不应该将析构函数定义为删除函数 因为这样就无法销毁该类型对象了
对于删除了析构函数的类型 不能定义这种类型的变量或者成员 但仍可以动态分配这种类型的对象 但是不能释放这些对象
如果类中某个成员的析构函数被删除 则该类的析构函数也被定义为删除的
如果类中某个成员的析构函数或拷贝构造函数被删除 则该类的拷贝构造函数也被定义为删除的
定义了一个移动构造或移动赋值的类必须也定义自己的拷贝操作 否则这些成员默认被定义为删除的
类似析构函数 赋值操作会销毁左侧运算对象的资源
类似拷贝构造 赋值操作会从右侧对象拷贝数据
为了保证安全(如自赋值) 应该先拷贝右侧对象 再释放左值
可以通过自己定义动态内存的引用计数来共享类的状态 从而模拟shared_ptr
Size_t *use(new size_t(1)); //构造时进行初始化
++*use; // 每次拷贝时增加计数
析构时检测--*use==0
来决定是否delete
移动操作
定义swap
运算 应当交换指针而非拷贝内容 可以提高效率
定义了swap
运算的类应当使用swap
来完成赋值运算符 更加安全 并能处理自赋值
移动操作获取了对象资源的控制权,从而避免了不必要的拷贝。
使用移动构造函数时
一方面可以避免拷贝后立刻销毁这样的情况 提高性能
另一方面 对于IO类或者是unique_ptr
这种类型 包含不能被共享的资源 它们不允许被拷贝 但是应当允许移动
为了支持移动操作 必须使用右值引用,即必须绑定到右值的引用,通过&&
获得
移动后源对象必须保证是有效的 可析构的状态
右值引用只能绑定到一个将要销毁的对象
左值引用只能绑定左值 即绑定的是对象的身份 但 const
左值引用可以绑定右值
而右值引用只能绑定到右值 即绑定是对象的值
因此我们不能将一个右值引用变量绑定到另一个变量上 即使这个变量也是右值引用类型
int &&rr1 = 42;
int &&rr2 = rr1; //错误 不能将一个右值引用变量绑定到另一个变量上
左值持久 而右值短暂
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; //正确:将右值引用绑定到了乘法结果上,这是一个临时变量,为右值
通过标准库的move
函数可以获得左值的右值引用
调用move意味着告诉编译器 这个左值将不再使用其值 可以销毁它 也可以赋予新值 但不能使用其值
int &&rr2=std::move(i); //成立 move返回左值i的右值引用
注意 使用move
的代码应该调用std::move
以避免潜在的名字冲突
类似拷贝构造 移动构造函数的第一个参数是该类型的右值引用 其他额外参数必须都有默认值
特别地 移动构造函数必须保证移动完成后源对象必须不再指向被移动的资源 从而保证销毁它是无害的
移动构造函数不分配任何新内存 它接管给定的对象的内存 然后将其中的指针都置为空 最后将其销毁
移动构造函数通常不应该抛出任何异常 可以通过跟在形参列表后面的noexpect
关键字通知编译器
在一个构造函数中noexpect
出现在参数列表和初始化列表开始前的冒号之间(即冒号之前)
// 移动构造函数不应抛出任何异常
// 在成员初始化中接管s中的资源
strvec:: strvec(strvec &&s) noexcept:element(s.element),cap(s.cap){
// 令s处于可析构的状态
s.element = s.cap = nullptr;
}
因为编译器对可能抛出异常的类会做一些额外的工作 声明为noexpect
可以避免浪费
将函数声明为noexpect
还有一个作用 因为移动一个对象可能会改变它的值
如果在移动了部分元素时抛出异常 则可能导致旧空间的移动源元素已经改变而新空间中尚未构造
为了避免这种情况 如vector等知道该元素类型的移动构造函数不会抛出异常
否则在重新分配内存的时候 它都必须使用拷贝构造而非移动构造
如果一个类没有定义移动操作 那么使用时会使用拷贝操作来代替
只有当一个类没有定义任何自己版本的拷贝控制成员 且类中的每个非static成员都可以移动
编译器才会为其合成一个移动构造函数或移动赋值运算符
移动迭代器: 通过改变给定迭代器的解引用运算符行为来适配
移动迭代器的解引用返回一个右值引用
通过make_move_iterator()
将一个普通迭代器转换为移动迭代器
通常而言 调用成员函数不限制是左值还是右值调用
string s1="1",s2="2";
(s1+s2).find('1');
通过引用限定符&
和&&
可以限制成员函数只能被左值或者右值对象调用
引用限定符跟在const
限定符后
Foo someMem() const &; //正确 定义了一个只能被左值const对象调用的成员函数
当通过引用限定符进行重载时 必须对所有重名且具有相同形参列表的函数(即只有引用限定不同的重载函数)都声明其引用限定
而当通过const
限定符进行重载时 仅有const
限定不同的重名函数可以不加限定