构造函数 & 析构函数
最后更新于
最后更新于
构造函数(constructor
)是类的一种特殊的成员函数,它会在每次创建类的新对象时执行。构造函数的名称与类的名称是完全相同的,并且不会返回任何类型,也不会返回 void。构造函数可用于为某些成员变量设置初始值。类对象被创建的时候,编译系统对象分配内存空间,并自动调用类的构造函数,由构造函数完成成员的初始化工作,故:构造函数的作用是初始化对象的数据成员。
和普通成员函数一样,构造函数是允许重载的。一个类可以有多个重载的构造函数,创建对象时根据传递的实参来判断调用哪一个构造函数。
构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用,不调用是错误的。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。
这里我们将默认构造函数定义为:无参的构造函数或者所有参数都有默认值的构造函数(二者不属于重重载的关系,一个类只能有一个默认构造函数)。这里的无参构造函数可以是显示定义的也可以是编译器自动生成的。调用默认构造函数也可以省略括号,即用下面的方式创建对象时调用的构造函数可以看成是默认的构造函数:
编译器自动生成的默认构造函数:C++标准规定,如果类中没有显示定义构造函数,则编译器会自动生成一个默认的构造函数,这个构造函数的函数体是空的,也没有形参,也不执行任何操作,叫做无关紧要(trivial
)的默认构造函数。即:一个类必须有构造函数,要么用户自己定义,要么编译器自动生成。一旦用户自己定义了构造函数,不管有几个,也不管形参如何,编译器都不再自动生成。
实际上编译器只有在必要的时候才会生成默认构造函数,而且它的函数体一般不为空,即非平凡(non-
trivival
)默认构造函数。默认构造函数的目的是帮助编译器做初始化工作。默认构造函数不为空的情况见这里。
构造函数的一项重要功能是对成员变量进行初始化,为了达到这个目的,可以在构造函数的函数体中对成员变量一一赋值,还可以采用初始化列表。
使用构造函数初始化列表并没有效率上的优势,仅仅是书写方便,尤其是成员变量较多时,这种写法非常简单明了。
成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。
初始化 const 成员变量的唯一方法就是使用初始化列表。
复制构造函数是构造函数的一种,也称拷贝构造函数,它只有一个参数,参数类型是本类的引用。
复制构造函数的参数可以是 const 引用,也可以是非 const 引用。 一般使用前者,这样既能以常量对象(初始化后值不能改变的对象)作为参数,也能以非常量对象作为参数去初始化其他对象。一个类中写两个复制构造函数,一个的参数是 const 引用,另一个的参数是非 const 引用,也是可以的。
如果类的设计者不显示定义复制构造函数,编译器就会自动生成复制构造函数。大多数情况下,其作用是实现从源对象到目标对象逐个字节的复制,即使得目标对象的每个成员变量都变得和源对象相等。编译器自动生成的复制构造函数称为“默认复制构造函数”。默认构造函数(即无参构造函数)不一定存在,但是复制构造函数总是会存在。
构造函数不能以本类的对象作为唯一参数,以免和复制构造函数相混淆。例如,不能写如下构造函数:Student (Student stu) {...}
拷贝构造函数形参不能值传递,如果用值传递,调用拷贝构造函数,首先要将实参传递给形参,这个传递过程 又要调用拷贝构造函数。如此循环,无法完成拷贝,栈也会满。
当用一个对象去初始化同类的另一个对象时,会引发复制构造函数被调用。
如果函数 F 的参数是类 A 的对象,那么当 F 被调用时,类 A 的复制构造函数将被调用。换句话说,作为形参的对象,是用复制构造函数初始化的,而且调用复制构造函数时的参数,就是调用函数时所给的实参。由此可见:函数的形参的值等于函数调用时对应的实参,不一定是正确的。如果形参是一个对象,那么形参的值是否等于实参,取决于该对象所属的类的复制构造函数是如何实现的。这也解释了为什么拷贝构造函数自己的参数必须是引用类型,如果其参数不是引用类型,则会陷入死循环。
如果函数的返冋值是类 A 的对象,则函数返冋时,类 A 的复制构造函数被调用。换言之,作为函数返回值的对象是用复制构造函数初始化 的,而调用复制构造函数时的实参,就是 return
语句所返回的对象。 需要说明的是,有些编译器出于程序执行效率的考虑,编译的时候进行了优化,函数返回值对象就不用复制构造函数初始化了,这并不符合 C++ 的标准。
用花括号列表初始化一个数组中的元素或以个聚合类中的成员时。
标准库容器调用insert
或push
等操作添加成员时。
构造函数调用顺序:
当普通构造一个对象时,程序先自动调用默认构造函数分配空间,再调用自定义构造函数(如果有的话)。
当拷贝构造一个对象时,程序先自动调用默认构造函数分配空间,然后分两种情况,如果程序有自定义拷贝构造函数,则调用自定义拷贝构造函数;如果没有自定义拷贝构造函数,那么就调用默认拷贝构造函数进行浅拷贝。
注意:2的情况下,当程序自定义拷贝构造函数了,那么程序是不会调用默认构造函数进行浅拷贝,我们需要在自定义的拷贝构造函数里把所有需要拷贝的成员变量都写上,无论是要深拷贝还是浅拷贝。
对于基本类型的数据以及简单的对象,它们之间的拷贝非常简单,就是按位复制内存。如:
b 和 obj2 都是以拷贝的方式初始化的,具体来说,就是将 a 和 obj1 所在内存中的数据按照二进制位(Bit)复制到 b 和 obj2 所在的内存,这种默认的拷贝行为就是浅拷贝,这和调用 memcpy()
函数的效果非常类似。
对于简单的类,默认的拷贝构造函数一般就够用了,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,默认的拷贝构造函数就不能拷贝这些资源了,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据。
本例中显式地定义了拷贝构造函数,它除了会将原有对象的所有成员变量拷贝给新对象,还会为新对象再分配一块内存,并将原有对象所持有的内存也拷贝过来。这样做的结果是,原有对象和新对象所持有的动态内存是相互独立的,更改一个对象的数据不会影响另外一个对象。 这种将对象所持有的其它资源一并拷贝的行为叫做深拷贝,我们必须显式地定义拷贝构造函数才能达到深拷贝的目的。 如果将上例中的拷贝构造函数删除,那么运行结果将变为:
0, 1, 2, 29, 4, 100, 6, 7, 8, 9 0, 1, 2, 29, 4, 100, 6, 7, 8, 9
可以发现,更改
arr2
的数据也影响到了arr1
。这是因为,在创建arr2
对象时,默认拷贝构造函数将arr1.m_p
直接赋值给了arr2.m_p
,导致arr2.m_p
和arr1.m_p
指向了同一块内存,所以会相互影响。 另外需要注意的是,printArray()
函数的形参为引用类型,这样做能够避免在传参时调用拷贝构造函数;又因为printArray()
函数不会修改任何数组元素,所以我们添加了 const 限制,以使得语义更加明确。
如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深拷贝,因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。如果类的成员变量没有指针,一般浅拷贝足以。
另外一种需要深拷贝的情况就是在创建对象时进行一些预处理工作,比如统计创建过的对象的数目、记录对象创建的时间等,如:
Base 类中的 m_time 和 m_count 分别记录了对象的创建时间和创建数目,它们在不同的对象中有不同的值,所以需要在初始化对象的时候提前处理一下,这样浅拷贝就不能胜任了,就必须使用深拷贝了。
初始化和赋值的区别:在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。初始化只能有一次,赋值可以有多次。
当以拷贝的方式初始化一个对象时,会调用拷贝构造函数;当给一个对象赋值时,会调用重载过的赋值运算符。
即使我们没有显式的重载赋值运算符,编译器也会以默认地方式重载它。默认重载的赋值运算符功能很简单,就是将原有对象的所有成员变量一一赋值给新对象,这和默认拷贝构造函数的功能类似。
对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。仍然以上面的 Array 类为例,该类拥有一个指针成员,指向动态分配的内存。为了让 Array 类的对象之间能够正确地赋值,我们必须重载赋值运算符:
将
arr1
赋值给arr2
后,修改arr2
的数据不会影响arr1
。如果把operator=()
注释掉,那么运行结果将变为: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 0, 1, 2, 3, 4 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 0, 1, 2, 234, 4, 5, 6, 920, 8, 9
去掉
operator=()
后,由于m_p
指向的堆内存会被free()
两次,所以还会导致内存错误。
operator=()
的返回值类型为Array &
,这样不但能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的。下面的语句就是连续赋值:arr4 = arr3 = arr2 = arr1;
if( this != &arr)
语句的作用是「判断是否是给同一个对象赋值」:如果是,那就什么也不做;如果不是,那就将原有对象的所有成员变量一一赋值给新对象,并为新对象重新分配内存。
return *this
表示返回当前对象(新对象)。
operator=()
的形参类型为const Array &
,这样不但能够避免在传参时调用拷贝构造函数,还能够同时接收 const 类型和非 const 类型的实参。
赋值运算符重载函数除了能有对象引用这样的参数之外,也能有其它参数。但是其它参数必须给出默认值,例如:Array & operator=(const Array &arr, int a = 100);
和构造函数一样,我们也可以用=default
显式要求编译器生成合成的拷贝构造函数和合成的赋值运算符,同样的如果=default
出现在类内声明里,则它是内联的,类外定义中则不是。
在 C/C++ 中,不同的数据类型之间可以相互转换。无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式地指明如何转换的称为强制类型转换。强制类型转换示例:
p1
是int *
类型,它指向的内存里面保存的是整数,p2
是float *
类型,将p1
赋值给p2
后,p2
也指向了这块内存,并把这块内存中的数据作为小数处理。我们知道,整数和小数的存储格式大相径庭,将整数作为小数处理非常荒诞,可能会引发莫名其妙的错误,所以编译器默认不允许将p1
赋值给p2
。但是,使用强制类型转换后,编译器就认为我们知道这种风险的存在,并进行了适当的权衡,所以最终还是允许了这种行为。
不管是自动类型转换还是强制类型转换,前提必须是编译器知道如何转换,如果编译器不知道转换规则就不能转换,使用强制类型也无用,比如自定义类型和基本类型之间相互转换。C++ 允许我们自定义类型转换规则,用户可以将其它类型转换为当前类类型,也可以将当前类类型转换为其它类型。这种自定义的类型转换规则只能以类的成员函数的形式出现,换句话说,这种转换规则只适用于类。
将其它类型转换为当前类类型需要借助转换构造函数(Conversion constructor)。转换构造函数也是一种构造函数,它遵循构造函数的一般规则。转换构造函数只有一个参数。
在进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容、需要将 double 类型转换为 Complex 类型时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。
转换构造函数也是构造函数的一种,它除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。下面创建对象的方式是正确的:
在以拷贝的方式初始化对象时,编译器先调用转换构造函数,将 240.3 转换为 Complex 类型(创建一个 Complex 类的匿名对象),然后再拷贝给
c2
。
如果已经对+
运算符进行了重载,使之能进行两个 Complex 类对象的相加,那么下面的语句也是正确的:
在进行加法运算符时,编译器先将 29.6 转换为 Complex 类型(创建一个 Complex 类的匿名对象)再相加。
需要注意的是,为了获得目标类型,编译器会“不择手段”,会综合使用内置的转换规则和用户自定义的转换规则,并且会进行多级类型转换,例如:
编译器会根据内置规则先将 int 转换为 double,再根据用户自定义规则将 double 转换为 Complex(int --> double --> Complex);
编译器会根据内置规则先将 char 转换为 int,再将 int 转换为 double,最后根据用户自定义规则将 double 转换为 Complex(char --> int --> double --> Complex)。
从本例看,只要一个类型能转换为 double 类型,就能转换为 Complex 类型。请看下面的例子:
上面的 Complex 类中我们定义了三个构造函数,其中包括两个普通的构造函数和一个转换构造函数。其实,借助函数的默认参数,我们可以将这三个构造函数简化为一个:
C++11引入了对象移动而非拷贝的概念,有时候对象发生拷贝后就被销毁了,这种情况下移动而非拷贝对象会大幅度提升性能。
移动构造函数类似于拷贝构造函数,不同的是移动构造函数的第一个参数是一个右值引用,移动构造函数仅仅移动数据成员,不会分配新的内存,所以比拷贝构造函数性能更好。
移动赋值运算符与拷贝赋值运算符的关系和移动构造函数与拷贝构造函数的关系一样,第一个参数是一个右值引用,移动赋值运算符仅仅移动数据成员,不会分配新的内存。
在自定义移动赋值运算符时,需要检查是否存在自赋值,也就是说如果要赋值的对象与自己的地址一样,则不需要做任何事情。
移动后源对象必须是有效的,可析构的:
移动操作必须确保移动后源对象可以被销毁且销毁后不会影响新创建的对象,例如如果源对象中有数据成员是指针,则必须置为空,否则在源对象执行析构函数时,会将新创建对象中的指针指向的资源释放掉。移动操作还必须保证对象仍然可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。另一方面,移动操作对移动后源对象中留下的值没有任何要求,因此我们的程序不应该依赖于移动后源对象中的数据。
与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符,如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。
当一个类没有定义任何自己版本的拷贝构造函数,拷贝赋值运算符,析构函数,且类的每个非静态数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝构造函数和拷贝赋值运算符,否则拷贝构造函数和拷贝赋值运算符会被定义为删除的。
如果我们使用=default
显式要求编译器生成合成的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则。
移动操作,标准库容器和异常:
由于移动操作窃取资源,它通常不分配任何资源,因此,移动操作通常不会抛出任何异常。不抛出异常的移动构造函数和移动赋值运算符必须标记为
noexcept
,因为某些标准库容器除非知道移动操作是无异常的,否则就会进行拷贝。
委托构造函数(Delegating Constructor)由C++11引入,是对C++构造函数的改进,允许构造函数通过初始化列表调用同一个类的其他构造函数,目的是简化构造函数的书写,提高代码的可维护性,避免代码冗余膨胀。
通俗来讲,一个委托构造函数使用它所属的类的其他构造函数执行自己的初始化过程,或者说它把自己的一些(或者全部)职责委托给了其他构造函数。和其他构造函数一样,一个委托构造函数也有一个成员初始化列表和一个函数体,成员初始化列表只能包含一个其它构造函数,不能再包含其它成员变量的初始化,且参数列表必须与构造函数匹配。如下面的例子:
可以看出,类Base的三个构造函数除了参数不同,初始化列表、函数体基本相同,其代码存在着很多重复。在C++11中,我们可以使用委托构造函数来减少代码重复,精简构造函数:
一个委托构造函数想要委托另一个构造函数,那么被委托的构造函数应该包含较大数量的参数,初始化较多的成员变量。而且在委托其他构造函数后,不能再进行成员列表初始化,而只能在函数体内对其他成员变量进行赋值。
在构造函数较多的时候,我们可能拥有多个委托构造函数,而一些目标构造函数很可能也是委托构造函数,这样依赖,我们就可能在委托构造函数中形成链状的委托构造关系,形成委托坏(Delegation Cycle)。
如果在委托构造函数中使用try,可以捕获目标构造函数中抛出的异常。
在GCC下会委托构造函数可以捕获构造函数的异常,在VS C++下会异常中断。这样的设计是合理的,因为目标构造函数抛出异常说明对象并没有完成初始化,在委托构造函数中进行赋值操作都是一些无意义的动作。
析构函数(Destructor
)也是一种特殊的成员函数,没有返回值,不需要程序员显式调用(程序员也没法显式调用),而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个~
符号。
析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。
在所有函数之外创建的对象是全局对象,它和全局变量类似,位于内存分区中的全局数据区,程序在结束执行时会调用这些对象的析构函数。
在函数内部创建的对象是局部对象,它和局部变量类似,位于栈区,函数执行结束时会调用这些对象的析构函数。
new 创建的对象位于堆区,通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行。
默认析构函数只会释放类的普通数据所占用的空间, 但是像new
或者malloc
申请的堆的内存,它是不会去释放的,所以这部分需要我们自己做。因此需要显示定义并实现析构函数。
在自定义析构函数中,主要做:
一些用户自己申请的堆内存(new / malloc
)的释放。
系统资源的释放(FILE*
等等)。
析构时,编译器先调用自定义析构函数(如果有的话),释放一些引用的堆内存,系统资源等等;然后,再调用默认析构函数,对类的一些普通数据所占用的内存进行释放。
当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。
拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么,赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了此类型的对象销毁时做什么。我们将这些操作称为拷贝控制操作。
由于拷贝控制操作是由三个特殊的成员函数来完成的,所以我们称此为“C++三法则”。在较新的C++11标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++五法则”。也就是说,“三法则”是针对较旧的C++89标准说的,“五法则”是针对较新的C++11标准说的。为了统一称呼,后来人们干把它叫做“C++ 三/五法则”。
三/五法则:描述了什么时候需要析构函数、拷贝构造函数和拷贝赋值操作。
需要析构函数的类也需要拷贝构造函数和拷贝赋值函数。通常,若一个类需要析构函数,则代表其合成的析构函数不足以释放类所拥有的资源,其中最典型的就是指针成员(析构时需要手动去释放指针指向的内存)。所以,若存在自定义(且正确)的析构函数,但使用合成的拷贝构造函数,那么拷贝过去的也只是指针,此时两个对象的指针变量同时指向同一块内存,指向同一块内存的后果很有可能是在两个对象中的析构函数中先后被释放两次。所以需要额外的拷贝控制函数去控制相应资源的拷贝。所以这类例子的共同点就是:一个对象拥有额外的资源(指针指向的内存),但另一个对象使用合成的拷贝构造函数也同时拥有这块资源。当一方对象被销毁后,析构函数释放了资源,这时另一个对象便失去了这块资源(但程序员还不知道)。
需要拷贝操作的类也需要复制操作,反之亦然。
析构函数是不能删除的。
如果一个类有删除的或不可访问的析构函数,那么其默认和拷贝构造函数会被定义为删除的。
如果一个类有const或引用成员,则不能使用合成的拷贝赋值操作。
本质上,当不可能拷贝、赋值、或销毁类的所有成员时,类的合成拷贝控制函数就被定义成删除的了。