【笔记】《C++Primer》—— 第13章:拷贝控制

时间:2022-07-22
本文章向大家介绍【笔记】《C++Primer》—— 第13章:拷贝控制,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

好久不见,回来更新了。这一章介绍了对类的拷贝控制的操作,其中最重要的是13.1对类的五大基本操作函数的理解和13.6对右值引用和对象移动的理解,比较长需要慢慢看。

这章的13.1是面向对象编程非常重要的一部分,而13.6的右值引用则几乎是C11最重要的新特性,值得重点理解。从这开始是第三部分"类设计者的工具"了,都是C++最区别于C的地方。

13.1 拷贝,赋值与销毁

  • 我们通过五种特殊的成员函数来控制类的拷贝移动赋值和销毁时的行为:拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符,析构函数
  • 当没有主动定义这些函数时,编译器会自动生成一个“合成”版本的函数
  • 如果一个构造函数的第一个参数是自身类型的引用,且其他任何参数都有默认值,没有返回值,则此构造函数是拷贝构造函数,叫做拷贝构造是因为会分配新的内存重新构造,与移动构造函数区分开
FOO(const FOO& inp) {
        // 拷贝构造的通常形式, 由于是拷贝所以一般不修改源值
        // 而且使用const也提高了接受的参数范围
        // 参数必须是自身引用防止了自赋值问题的产生
}
  • 拷贝构造函数会自动将每个非static成员依次拷贝到正在创建的对象中,其中内置类型会直接拷贝,数组会被逐元素地拷贝,类类型会调用拷贝构造函数来拷贝
  • 拷贝初始化在我们认为发生了拷贝时会进行,例如等号赋值,对象作为实参传递,对象非引用返回,花括号初始化
  • 如果初始化值要求一个explicit构造函数来类型转换,则拷贝初始化还是直接初始化就无关紧要了
  • 重载运算符本质是函数,格式为operator符号,参数和返回值可自定义,其中常见的赋值运算符(即等号)通常参数一个右类型引用,返回一个左类型引用
FOO& operator=(const FOO& inp) {
        // 拷贝赋值运算符常见形式
        // 接受一个右侧类型引用,返回左侧类型引用
}
  • 如果没有定义自己的拷贝赋值运算符,则编译器也会生成一个合成版本的
  • 有了构造函数,也有析构函数,定义方法是一个名字为波浪号接类名的函数,没有返回值且不接受参数

~FOO() {
        // 析构函数,无参无返回值
}
  • 析构函数的行为与构造函数相反,会自动销毁掉非static的成员和调用成员析构
  • 类的初始化是先初始化成员然后执行构造函数,类的销毁是先执行析构然后销毁成员
  • 析构函数没有参数列表,所以成员销毁时的行为完全依赖于成员自己
  • 析构会在变量离开作用域或母构件销毁时销毁,动态分配的对象指针需要手动delete销毁,临时对象在表达式执行完的时候销毁
  • 类应该被看作一个整体,“三五法则”就是指当一个类需要析构函数时,我们几乎肯定也要定义好拷贝和赋值函数,拷贝函数和赋值函数两者又是绑定出现的,如果需要拷贝操作时,最好定义好所有其他操作
  • 由于当我们定义了具体的五大操作函数时,编译器便不会为我们生成对应操作的合成版本了,如果我们需要指定某个手动定义的操作是合成版本的话,可以在后面加=default来表示,注意只能对具有合成版本的函数进行此操作,即默认版本的五大操作
//指定此函数为合成版本的,由编译器生成函数体
FOO(const FOO& inp) = default;
  • 有时我们不希望用户使用一些函数,可以在函数名后加=delete表示删除(操作与=default一样),此时也不需要函数体,可以对任意函数标记,但要注意一定要在函数第一次声明的地方就标记delete
  • 如果我们删除了析构函数,则我们我们不能定义这种变量,但是我们可以动态分配它,但也无法释放它
  • 合成的拷贝控制函数可能会被自动标记为删除,一般是当这个类存在不能被合成默认构造的成员出现
  • 旧标准中我们使用private版本的构造函数来控制构造,但如今如果要控制拷贝最好用=delete
  • 拷贝赋值运算符包括了构造操作和析构操作,因为当覆盖已有对象时需要析构旧对象构造新对象。由于拷贝赋值的这个特性,所以最好将构造和析构的公有操作写为private出来复用

13.2 拷贝控制和资源管理

  • 我们对一个类的拷贝和资源管理通常表现为两种:像值的类,像指针的类
  • 像值的类需要它拷贝前后两个对象完全独立,改变副本不会产生影响,通常操作是在构造函数中要先拷贝右侧的对象的成员到新副本,然后释放副本的指针部分,接着把右侧的指针部分赋值到左侧,最后返回本副本。要注意这种模式需要小心被复制的指针可能被析构导致源对象消失
  • 像指针的类通常使用shared_ptr来管理,当需要手动管理时,一般采用引用计数法来保持指针引用记录,特点是创建一个唯一的计数器,然后对象间用指针共享计数器:
    • 拷贝时,拷贝指向计数器的指针并递增计数;
    • 赋值时,先递增右侧(被拷贝对象)的计数,然后再递减左侧(被覆盖对象)的计数,这个顺序解决了自赋值的问题
    • 析构时,递减计数器的计数

13.3 交换操作

  • 管理资源的类通常额外定义了一个swap操作方便标准库使用,当类定义了自己的swap时标准库便会调用自定义的这个swap,我们编写自己的调用代码时也应该使用无限定的swap(也就是不要在外部直接用std::swap)来适配不同的swap,编译器自然会进行最佳匹配
  • swap通常是inline内联的,而且经常是对std::swap的一种包装,也就是内部使用std::swap来完成交换核心
  • 有swap的类常常还有“拷贝并交换”的赋值运算符重载,一般接受一个值传递的参数,返回引用,在函数体中将参数的内容与对象自己进行交换

FOO& operator=(FOO inp) {
  // 这个写法保证了自赋值的正确和异常安全
  // 由于赋值是通过与一个副本进行交换值然后再销毁副本
  // 所以自赋值能正常进行
  // 由于这部分中可能发生异常的地方在赋值前构造副本的地方
  // 因此是异常安全的,发生异常也不会影响原值
  swap(*this, inp);
  return *this;
}

13.6 对象移动

  • 很多时候发生拷贝的对象在拷贝后原值就被快速销毁掉了,此时如果我们使用移动可以大幅提高性能,移动操作的目的是解决对象资源所有权转移的问题
  • 而且有些对象如流对象不允许拷贝,但是可以移动
  • C11中我们可以用容器来保存不可拷贝的类型只要这个类型支持移动
  • 具体来说移动操作一般是通过直接接管源对象实现的,而为了完整达成能减少内存消耗的移动语义,需要解决临时变量标记问题,即我们需要指定这个将要被接管的对象已经是无用对象了,于是C11引入了右值引用类型。
  • 但是移动操作只是右值引用的一个附带优点,C11引入了右值引用类型的根本目的是解决完美转发问题,即让我们在一些例如传参的时候可以直接使用临时变量本身的值来传递而不经过拷贝的性能消耗(例如临时值直接传入时是会经历一次拷贝构造的),由于我们要直接使用临时变量,这就打上了无用变量的标记,我们可以认为右值引用的目标对象都是将要被销毁且没有其他用户的,也就是右值引用可以自由使用其引用对象,也就我们可以从引用对象“窃取”状态,正是这个特性让我们可以移动那些不可拷贝的值
  • 之前在4.1中提到过“可以利用&取到地址的值就是左值,也就是我们修改这个值是会连接到指定的栈上的内存的值,我们平时用的变量就是左值;其余的不是左值的值都是右值,例如很多的直接运算结果(1+1)之类的临时值”,对于右值,通过两个引用符来进行右值引用。
// 变量属于左值,最显眼的特性是变量可以取地址
int test = 1;
// 左值引用可以得到变量的引用
int& t_left = test;
// 但是对于1这种临时值,无法进行左值引用,但此时可以进行右值引用
int&& t_right = 1;
  • 右值引用有与左值引用完全相反的特性,我们无法将右值引用绑定到左值上
  • 但是我们可以将const左值引用绑定到右值上

// 但我们可以将const左值引用绑定到右值上
const int& t_cleft = 1;
  • 那么当我们要使用移动语义时,常常我们需要移动左值,那么要如何转换为右值引用呢,C11提供了标准库函数move,调用move就能够生成一个右值引用。一旦我们调用了move就代表承诺了此时我们放弃了对原先的对象的控制,也不会对移动后的右值引用的值进行任何的假设了
  • 清楚了右值引用的前提后,用一个实例来说明移动构造函数的需求,那就是例如流对象和套接字。我们知道如果对于一个目标我们有多于一个的套接字控制着它,那套接字的运用会变得非常混乱因为无法同步。最好的解决方法就是我们把拷贝构造delete,制止其他用户对其拷贝(在其他语言中一般用单例模式private构造之类的方法实现),但是当我们制止了拷贝,我们就相当于因为无法拷贝我们无法用这个套接字当作参数传递了。思考一下这个情景下我们希望的其实是将这个套接字的控制权在不同的函数间转移,并不会产生新的拷贝套接字,所以使用右值引用来定义移动构造函数,使用右值引用的特性将传入前的那个对象当作右值(将要销毁),然后把控制权转移进来,结束的时候再通过同样的方法传出去,这就是移动构造
  • 移动构造的具体写法类似拷贝构造,但是构造参数是自己类型的右值引用,为了完成移动构造,我们需要保证移动后源对象处于可以无害销毁的状态,源对象的指针不再指向原先的资源,而且移动构造不应该抛出任何的异常,这是为了防止在移动构造的途中被打断了资源转移的过程,从而摧毁了原先的资源
  • 常见的移动构造函数便如下所示,用C11的新关键字noexcept来指出某个函数必然不会抛出异常

FOO(FOO&& inp) noexcept
        :p(inp.p) {
        // 移动构造的一般形式
        // 先声明不会抛出异常
        // 然后在初始化部分中复制传入的右值引用的指针
        // 右值引用的操作类似于一个普通的对象
        inp.p = nullptr;
        // 再将传入的对象的指针赋值为nullptr保证可以无害析构
        // 最后在函数外将传入的对象销毁完成控制权转移
}
  • 移动赋值运算符的编写类似之前的拷贝赋值运算符,但要注意在一开始的时候用if(this!=&inp)来检测是否发生自赋值,若发生则不要进行内部的控制权转移部分
  • 强调移动后的源对象必须保证是有效且可安全析构的状态,而且不能假设这个源对象的任何值
  • 如果我们不对类进行操作函数的重载,则编译器会生成合成的各种操作函数包括移动构造函数,但是一旦我们自定义了拷贝构造,拷贝赋值或析构,编译器便不会生成合成的移动系列函数了。只有当一个类没有任何自己定义的拷贝操作且所有非static成员都可移动时才会生成合成的移动构造函数,内置成员和有移动构造函数的对象是可移动的
  • 和拷贝不同,移动操作不会被隐式定义为删除,我们也可以显式要求default合成移动函数,当不满足移动条件时移动构造函数会被定为删除
  • 一个类可以既有移动拷贝也有拷贝构造,此时编译器将会进行最佳匹配,参数是左值使用拷贝,参数是右值或不可拷贝使用移动,利用这个特性我们可以自由地使用赋值运算符等而不怕性能损失
  • 但是当一个类没有移动构造函数时,我们依然可以传参为右值,此时类会对此右值进行拷贝构造。要注意用拷贝代替移动几乎肯定是安全的,拷贝的好处是大多数时候不会改变源对象的值
  • 由于有了移动操作,C11的标准库就定义了10.4的移动迭代器,对移动迭代器进行解引用会得到一个右值引用,我们通过make_move_iterator来转换出一个移动迭代器。移动迭代器的操作与普通迭代器完全一致,标准库算法并不保证哪些地方适用移动迭代器,因此我们自己要把握好所使用的算法必须在移动元素后不会再去访问源值
  • 总结一下为了达成易用性与性能间的平衡,当我们定义自己的函数时,可以对其重载一个constX&参数的左值引用形式和X&&的右值引用形式,一般来说不会用const X&&参数或普通的X&参数,因为我们一般都会去修改右值引用,一般都不会改变拷贝的源对象
  • 有些时候我们希望可以控制一个函数的执行目标的属性,例如我们不希望向一个右值赋值,C11增加了引用限定符,我们通过在参数列表后附加一个引用符&表示此函数的对象必须是可修改的左值,通过在参数列表后附加两个引用符&&表示此函数的对象必须是右值,这两个限定符可以放在const之后搭配使用
// 从上到下依次是:
// 普通函数,左值限定函数,右值限定函数,左值限定函数常量版本,右值限定函数常量版本
FOO test_normal();
FOO test_left()&;
FOO test_right()&&;
FOO test_left()const&;
FOO test_right()const&&;
  • 由于有了不同的限定符,可以想到引用限定符也可以用来区分重载的办吧,这里C++有一个要求就是如果某个函数出现了引用限定符,则其具有相同参数列表的所有版本都需要有引用限定符,如下若将第一个函数的引用限定去掉或给第二个函数补上所需的引用限定都可以解决这个报错