拷贝控制——拷贝赋值与销毁
Posted acgame
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了拷贝控制——拷贝赋值与销毁相关的知识,希望对你有一定的参考价值。
当定义一个类时,我们显示地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通常定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作。
如果一个类没有定义这些拷贝控制成员,编译器会自动为它定义缺失的操作。但是,对一些类来说,依赖这些操作的默认定义会导致灾难。
一、拷贝、赋值与销毁
1、拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。拷贝构造函数的第一个参数必须是一个引用类型。拷贝构造函数在几种情况下都会被隐式地使用,因此,拷贝构造函数不应该是explicit的。
1)合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器为为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
对于某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
2)拷贝初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
1 #include <iostream> 2 #include <string> 3 4 class Demo 5 { 6 public: 7 Demo(int data = 0) :m_data(data) 8 { 9 std::cout << "create" << std::endl; 10 } 11 Demo(const Demo &rhs) 12 { 13 m_data = rhs.m_data; 14 std::cout << "copy" << std::endl; 15 } 16 int m_data; 17 }; 18 int main() 19 { 20 Demo a(1); 21 std::cout << "----" << std::endl; 22 Demo b(a); 23 std::cout << "----" << std::endl; 24 Demo c = a; 25 std::cout << "----" << std::endl; 26 Demo d = 2; 27 return 0; 28 }
拷贝初始化通常使用拷贝构造函数来完成。但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
拷贝初始化不仅在我们用=定义变量时发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类的成员
2、拷贝赋值运算符
1)重载赋值运算符
重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显示参数传递。
1 #include <iostream> 2 #include <string> 3 4 class Demo 5 { 6 public: 7 Demo(int data = 0) :m_data(data) 8 { 9 std::cout << "create" << std::endl; 10 } 11 Demo(const Demo &rhs) // 拷贝构造函数 12 { 13 m_data = rhs.m_data; 14 std::cout << "copy" << std::endl; 15 } 16 Demo &operator=(const Demo &rhs) // 拷贝赋值运算符重载 17 { 18 m_data = rhs.m_data; 19 std::cout << "copy ==" << std::endl; 20 return *this; 21 } 22 int m_data; 23 }; 24 int main() 25 { 26 Demo a(1); 27 std::cout << "----" << std::endl; 28 Demo b(a); 29 std::cout << "----" << std::endl; 30 Demo c = a; 31 std::cout << "----" << std::endl; 32 Demo d = 2; 33 std::cout << "----" << std::endl; 34 d = c; 35 return 0; 36 }
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
2)合成拷贝赋值运算符
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
3、析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非statiic数据成员。
析构函数是一个类的成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数,因此它不能被重载。对于一个给定类,只会有唯一一个析构函数。
1)析构函数做什么工作
如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
通常,析构函数体释放对象在生存期分配的所有资源。不存在类似构造函数初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
注意:隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。
2)什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用delete运算符对象时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
注意:当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
3)合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。对于某些类,合成析构函数被用来阻止该类型对象被销毁。如果不是这种情况,合成析构函数的函数体为空。
认识到析构函数体自身不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分进行的。
4、三/五法则
C++语言并不要求我们定义所有拷贝控制操作:可以只定义一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
1)需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
2)需要拷贝操作的类也需要赋值操作,反之亦然
如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
5、使用=default
我们可以通过将拷贝控制成员定义为=default来显示地要求编译器生成合成的版本。
当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default。
1 #include <iostream> 2 #include <string> 3 4 class Demo 5 { 6 public: 7 Demo(int data = 0) :m_data(data) 8 { 9 std::cout << "create" << std::endl; 10 } 11 Demo(const Demo &rhs) = default; 12 Demo &operator=(const Demo &rhs); 13 int m_data; 14 }; 15 16 Demo &Demo::operator=(const Demo &rhs) = default; 17 18 int main() 19 { 20 return 0; 21 }
注意:我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)
6、阻止拷贝
注意:大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显示地。
虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。
1)定义删除的函数
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的。
1 class Demo 2 { 3 public: 4 Demo(int data = 0) :m_data(data) 5 { 6 std::cout << "create" << std::endl; 7 } 8 Demo(const Demo &rhs) = delete; // 阻止拷贝 9 Demo &operator=(const Demo &rhs) = delete; // 阻止赋值 10 int m_data; 11 };
与=default不同,=delete必须出现在函数第一次声明的时候。与=default的另一个不同之处是,我们可以对任何函数指定delete。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
2)析构函数不能是删除的成员
我们不能删除析构函数。如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象,而且,如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。
注意:对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
3)合成的拷贝控制成员可能是删除的
对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的(例如,是private的),则类的合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数是删除的。
4)private拷贝控制
在新标准发布前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝:
1 #include <iostream> 2 #include <string> 3 4 class PrivateCopy 5 { 6 7 PrivateCopy(const PrivateCopy &rhs); 8 PrivateCopy &operator=(const PrivateCopy &rhs); 9 public: 10 PrivateCopy(int data = 0) :m_data(data) 11 { 12 std::cout << "create" << std::endl; 13 } 14 int m_data; 15 }; 16 17 int main() 18 { 19 return 0; 20 }
由于拷贝构造函数和拷贝赋值运算符是private的,用户代码将不能拷贝这个类型的对象。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的,但并不定义它们。
声明但不定义一个成员函数是合法的。通过声明但不定义private的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。
以上是关于拷贝控制——拷贝赋值与销毁的主要内容,如果未能解决你的问题,请参考以下文章