【C++】《C++ Primer 5th》笔记-Chapter13-拷贝控制

本文深入探讨C++中的拷贝构造函数、移动构造函数、赋值运算符以及析构函数的作用和实现,阐述了拷贝初始化、直接初始化的区别,以及在对象生命周期中的应用。此外,还讲解了类如何控制其对象的拷贝行为,包括使用默认、删除和自定义拷贝控制成员。最后,讨论了资源管理的重要性,特别是动态内存管理和智能指针在防止内存泄漏中的角色。
摘要由CSDN通过智能技术生成

笔记:
一、拷贝、赋值与销毁
1、拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作。
2、如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
拷贝构造函数的第一个参数必须是一个引用类型(因为拷贝构造函数被用来初始化非引用类类型参数,否则定义就是死循环)。虽然我们可以定义一个接收非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。
拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit的。

3、如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
对某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。

4、直接初始化和拷贝初始化之间的差异:
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

5、拷贝初始化通常使用拷贝构造函数来完成,有时会使用移动构造函数来完成。
6、拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生:
①将一个对象作为实参传递给一个非引用类型的形参;
②从一个返回类型为非引用类型的函数返回一个对象;
③用花括号列表初始化一个数组中的元素或一个聚合类中的成员。

7、当我们初始化标准库容器或是调用其insert或push成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化。
8、在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。
即编译器被允许将下面的代码:
string null_book = "9-999-999";    // 拷贝初始化
改写成:
string null_book("9-999-999");    // 编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如不是private的)。

9、与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。
与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

10、值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。

11、与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

12、析构函数执行与构造函数相反的操作。析构函数释放对象使用的资源,并销毁对象的非static数据成员。
13、由于析构函数不接受参数,因此它不能被重载。对于一个给定类,只会有唯一一个析构函数。
14、在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按照初始化顺序的逆序销毁。
15、隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。
16、当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
17、认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之外隐含的析构阶段中被销毁的。在整个对象销毁的过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

18、如果一个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
如果使用合成的拷贝构造函数和拷贝赋值运算符,这些函数简单拷贝指针成员,意味着多个类对象可能指向相同的内存。

19、如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符;反之亦然。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

20、我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。
当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default。
我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)。

21、在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样的一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:
与=default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。
与=default的另一个不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

22、值得注意的是,我们不能删除析构函数。
23、对某些类来说,编译器将这些合成的成员定义为删除的函数:
①如果类的某个成员的析构函数是删除的或不可访问的(例如是pivate的),则类的合成析构函数被定义为删除的。
②如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
③如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
④如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

24、在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。
25、当拷贝构造函数和拷贝赋值运算符是private的,用户代码将不能拷贝这个类型的对象。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的,但并不定义它们。
声明但不定义一个成员函数是合法的。试图访问一个未定义的成员将导致一个链接时错误。
通过声明(但不定义)private的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。
但是,新标准发布之后,希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。

26、class中,无访问说明符时,成员默认是private的。

二、拷贝控制和资源管理
1、通常,管理类外资源的类必须定义拷贝控制成员。
2、通常,类直接拷贝内置类型(不包括指针)成员;这些成员本身就是值,因此通常应该让它们的行为像值一样(拷贝的时候,副本和原对象是完全独立的,改变副本不会对原对象有任何影响),而不是像指针一样(拷贝的时候,副本和原对象使用相同的底层数据,改变副本也会改变原对象)。
3、当你编写赋值运算符时,有两点需要记住:
①如果将一个对象赋予它自身,赋值运算符必须能正确工作;
②大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
4、引用计数的工作方式如下:
①除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
②拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
③析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
④拷贝赋值运算符递增右侧运算对象的计算器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
另外,注意要将计数器保存在动态内存中。

三、交换操作
1、使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

四、拷贝控制示例
1、拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。

五、动态内存管理类
1、通过使用新标准引入的两种机制,我们就可以避免string的拷贝。第一个机制是所谓的"移动构造函数",移动构造函数通常是将资源从给定对象"移动"而不是拷贝到正在创建的对象。第二个机制是一个名为move的标准库函数,调用move来表示使用string的移动构造函数。

六、对象移动
1、新标准的一个最主要的特性是可以移动而非拷贝对象的能力。
2、标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
3、所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。
一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
4、左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知:
①所引用的对象将要被销毁;②该对象没有其他用户。
变量是持久的,直至离开作用域时才被销毁。
变量是左值的,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
5、虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。
例如:int &&rr3 = std::move(rr1);
move调用告诉编译器:我们有一个左值,但是我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象对象的值。
注意:使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。

6、类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
7、除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
8、移动操作不应抛出任何异常。noexcept通知标准库我们的构造函数不抛出任何异常。
noexcept是我们承诺一个函数不抛出异常的一种方法。我们必须在类头文件的声明中和定义中(如果定于在类外的话)都指定noexcept。
注意:不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。

9、与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的对象中的内存。在接管内存之后,它将给定对象中的指针置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。
10、只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
11、定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
12、值得注意的是,用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符的情况类似)。一般情况下,拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将原对象值置于有效状态。实际上,拷贝构造函数甚至都不会改变原对象的值。
13、所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正常工作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
14、一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。我们通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
15、值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
16、由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。
通过在类代码中小心地使用move,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误,而难以提升应用程序的性能。
17、区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。
18、我们指出this的左值/右值属性的方式与定义const成员函数相同,即,在参数列表后放置一个引用限定符。引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。
一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后。
引用限定符也可以区分重载版本。
注意,如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。

一些术语:
1、移动构造函数和移动赋值运算符接收一个(通常是非const的)右值引用;而拷贝版本则接受一个(通常是const的)普通左值引用。
2、分配了内存或其他资源的类几乎总是需要定义拷贝控制成员来管理分配的资源。如果一个类需要析构函数,则它几乎肯定也需要定义移动和拷贝构造函数及移动和拷贝赋值运算符。
3、删除的函数:不能使用的函数。我们在一个函数的声明上指定delete来删除它。删除的函数的一个常见用途是告诉编译器不要为类合成拷贝和/或移动操作。
4、引用限定符:用来指出一个非static成员函数可以用于左值或右值的符号。限定符&和&&应该放在参数列表之后或const限定符之后(如果有的话)。被&限定的函数只能用于左值;被&&限定的函数只能用于右值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值