高效学 C++|组合类的构造函数
设计好 MyString 类后,就可以像使用普通类型一样使用它了。例如,类的对象可以像普通的变量一样作为另一个类的数据成员。
【例 1】 MyString 类的对象作为 CStudent 类的数据成员。
其输出如下:
对于例 1,在定义 CStudent 类时使用了 MyString 类,比如其数据成员 name 是 MyString 类型的,也就是说 MyString 类的对象 name 作为 CStudent 的数据成员。这样,对于编写 CStudent 类的程序员来说,只需要知道 MyString 类的用法就行了,而不需要再去考虑如动态内存分配等细节,因而大大减轻了程序员的工作量。不过,类毕竟与普通的数据类型不同,因而就带来了一些问题。下面结合程序的输出,分析程序的运行过程如下:
(1)输出的第 1、2 行是程序第 47 行中构造 name 和 major 时产生的。
(2)程序第 48 行会调用 CStudent 类的有参构造函数构造对象 stu、调用 CStudent 的默认构造函数构造 stu2,而输出中的第 7 行才是 CStudent 的有参构造函数中输出的信息、第 10 行才是 CStudent 的默认构造函数输出的信息,因此输出的第 3 行至第 10 行都是因程序第 48 行产生的输出。这些输出表明,在 CStudent 的有参构造函数执行之前,先调用了两次 MyString 类的默认构造函数,然后调用了两次 MyString 类的赋值运算符函数;在执行 CStudent 的默认构造函数之前,先调用了两次 MyString 类的默认构造函数。然而,在 CStudent 类的有参构造函数中没有看到调用 MyString 类的默认构造函数初始化内嵌对象 name 和 major 的地方,那么两次调用 MyString 类的默认构造函数是怎么发生的?同理,在 CStudent 类的默认构造函数的实现中也没有显式调用 MyString 类的默认构造函数初始化内嵌对象 name 和 major 的地方,那么两次调用 MyString 类的默认构造函数是怎么发生的?这就需要介绍构造函数的初始化列表了。另外,在 CStudent 类的有参构造函数中的语句“this->name = name; this->major = major;”中直接使用了类的内嵌对象 name 和 major(由此两次调用 MyString 类的赋值运算符函数,产生输出的第 5 和第 6 行),这说明这两个对象在进入该构造函数之前就已经构造完毕。既然在进入 CStudent 类的构造函数之前就能调用 MyString 类的构造函数初始化 name 和 major,那么能不能通过传递 CStudent 类的有参构造函数中的参数 name 和 major 来调用 MyString 类的复制构造函数初始化 CStudent 类的成员对象 name 和 major 呢?这样做还可以省去在 CStudent 类的有参构造函数中对它们的赋值,即省去两次调用 MyString 类的赋值运算符函数的过程。
(3)程序第 49 行是调用 CStudent 类的复制构造函数,但例 1 中没有设计该函数,因此执行的是编译器自动提供的复制构造函数;程序第 50 行是一个赋值运算,由于例 1 中也没有为 CStudent 设计赋值运算符函数,因此编译器自动提供了默认的赋值运算符函数。显然程序第 51 行和第 52 行产生的输出为输出中的第 15 行和第 16 行,因此程序第 49 行和第 50 行产生的输出为输出中的第 11 行至第 14 行:显示调用了两次 MyString 类的复制构造函数和两次赋值运算符函数。根据输出的第 15 行和第 16 行的内容相同,且程序运行正常,可以判断编译器自动提供的复制构造函数和赋值运算符是正确的。那么,编译器自动提供的复制构造函数和赋值运算符函数是什么样的?
(4)例 1 中设计了 CStudent 类的析构函数,但在其函数体中只有一条输出语句。这是因为 CStudent 类中没有涉及动态内存分配,因此不涉及回收堆内存的问题。注意,MyString 类型的对象 name 和 major 涉及了堆内存,不过回收其堆内存的工作由 MyString 类的析构函数完成。从程序中可以看出,对象的析构顺序为:依次析构对象 stu3、stu2 和 stu,然后析构对象 major 和 name。整个析构过程产生了输出中的第 17 行至第 27 行,其中析构 stu3 产生了输出中的第 17 行至第 19 行。那么,析构组合类对象 stu3 为什么是这样的一个过程?
下面会解释上面提出的问题。不过,在此之前,先介绍一下类的前向引用声明问题,因为这个问题在定义组合类时经常会用到。
在 C++语言中,使用基本数据类型的变量时需要遵循先声明后引用的规则。与此类似,在定义新的类型时也要遵循这一规则。例如在例 1 中,在定义 CStudent 类之前,先通过预编译指令引入了 MyString 类的定义(例 1 的第 5 行)。在声明一个类之前就试图使用这个类则会出现编译错误,如例 2 所示。
【例 2】 在声明一个类之前就试图使用这个类则会出现编译错误。
在例 2 中,在类 A 的定义中引用了类 B。然而,B 类还没有被声明,所以会造成编译错误。解决办法是进行前向类型声明,比如在声明 A 之前加入声明语句“class B;”。
进行了类的前向声明之后,仅能保证声明的符号可见,但在给出类的具体定义之前,并不能涉及类的具体内容,如下面的程序。
在上面的程序中,类 A 的函数 A_fun()试图访问对象 b 的数据成员 j,即试图引用 B 类的具体内容。然而,在此之前,类 B 的具体定义尚未给出,所以会出现编译错误。解决办法是将该函数的实现写在类外并且在类 B 的完整定义之后。
类似地,在给出类的完整定义之前,不能定义类的对象,因为定义类的对象就会涉及对象的构造,从而会涉及类的具体内容,如下面的程序。
在上面的程序中,类 A 试图定义 B 的对象 m_b 和 A 的对象 m_a,然而此时类 B 和类 A 的定义都不完整,因而会造成编译错误。解决办法是:首先把类 B 的完整定义放到类 A 的定义之前;其次,在类 A 中不能定义类 A 的对象,只能定义类 A 的指针,如下面的程序。
01、组合类的构造函数
如前所述,在 CStudent 类的有参构造函数中可以直接使用内嵌的对象 name,这就意味着该对象在程序执行 CStudent 类的有参构造函数之前就已经调用了 MyString 的构造函数完成了初始化。为了解释这个问题,就需要介绍初始化列表的概念了。
类的构造函数都带有一个初始化列表,主要作用是为初始化类的数据成员提供一个机会。如果在设计构造函数时没有在初始化列表中给出数据成员的初始化方式,则编译器会采用数据成员的默认的初始化方式——对于类的对象来说就是调用其默认的构造函数——进行初始化,且初始化列表中的内容会在执行构造函数之前执行。这就是在上面例 1 中的 CStudent 类的有参构造函数中可以使用其成员对象 name 的原因。
一般地,带初始化列表的构造函数的形式如下(仅以写在类的声明内部为例;写在类的声明外部与此相似,只是需要在函数名前加上类名和域作用符):
以写在类的声明外部为例,CStudent 类的有参构造函数可以写成如下形式。
其中初始化列表中的第一个 name 是 CStudent 的数据成员,第二个 name 是构造函数中的参数。在这个实现中,由于在初始化列表中使用复制构造函数初始化了 name 和 major,所以在 CStudent 的构造函数内部就不需要再次为成员 name 和 major 赋值了。另外,基本数据类型 number 和 score 也可以在初始化列表中初始化,但要注意不能写成类似于“number = num”的形式。
另外,需要说明的是构造函数的调用顺序。由于初始化列表的存在,在调用组合类的构造函数之前会先调用其成员对象的构造函数,且当有多个成员对象时,C++语言规定按照成员对象在组合类声明中出现的顺序依次构造,而与它们在初始化列表中出现的顺序无关。例如,虽然 name 和 major 在上述构造函数的初始化列表中出现的顺序与在下面构造函数的初始化列表中出现的顺序不同,但在执行时都是先初始化 name 再初始化 major,程序如下:
最后要强调的是,初始化列表可以省去——此时使用数据成员的默认方式初始化,但不意味着没有初始化列表。例如例 1 中,CStudent 的默认构造函数实际的实现形式为在初始化列表中调用 MyString 的默认构造函数初始化 name 和 major,但基本数据类型的成员 number 和 score 没有初始化,程序如下:
例 1 中 CStudent 的有参构造函数实际的实现形式中的初始化列表与上面的类似:仅在初始化列表中使用 MyString 类的默认构造函数初始化数据成员 name 和 major,没有初始化 number 和 score,程序如下:
显然,这个实现中,为初始化 name 和 major 需要调用两次 MyString 类的默认构造函数和两次赋值运算符函数。因此,充分利用初始化列表还可以减少函数调用的次数,提高程序的运行效率。
02、组合类的析构函数
对于 CStudent 类来说,其析构函数没有多少特殊的地方:其要完成的功能主要是负责该类数据成员的清理。在 CStudent 类中,由于数据成员没有用到堆内存(对象 name 和 major 用到了,但它们由 MyString 类负责处理),所以不需要专门为它编写析构函数。
不过,对于组合类的析构函数也有需要说明的地方,那就是当组合类的对象超出生存期时析构函数的调用顺序问题。这里只需要遵循一个原则:析构函数的调用顺序与构造函数的调用顺序完全相反。如果把对象的初始化过程比喻为按照严格规程生产一台机器的过程,那么显然需要先按照设定的规程生产各个零部件(相当于调用作为数据成员的对象的构造函数),然后调试整台机器(相当于调用组合类的构造函数);当需要拆卸机器时,需要按照完全相反的顺序拆卸(相当于调用各部分的析构函数),否则就无法拆卸开来。对于 CStudent 类的对象,调用析构函数的顺序是:调用 CStudent 类的析构函数析构 CStudent 类的对象,然后调用 MyString 类的析构函数析构对象 major,最后调用 MyString 类的析构函数析构对象 name。
03、组合类的复制构造函数
正象普通的复制构造函数一样,如果没有编写它,编译器就会自动提供一个,并且其完成的功能就是实现对应数据成员的复制。比如,在例 1 中没有给出 CStudent 类的复制构造函数,因此编译器会自动提供一个如下形式的复制构造函数——注意在初始化列表中调用了 MyString 类的复制构造函数来初始化 name 和 major。
如果明确给出了复制构造函数的定义,则编译器就不再提供默认的实现,因此关于复制构造函数的一切都需要程序员负责——一定要在初始化列表中使用复制构造函数初始化对象成员,比如下面这个实现就不太好。
这个实现没有明确给出初始化列表,但这并不意味着没有初始化列表,而是意味着在初始化列表中采用默认的形式对数据成员初始化,即 name 和 major 的初始化是通过调用 MyString 的默认构造函数——而不是复制构造函数——实现的,而基本数据类型的成员 number 和 score 没有初始化。也正因为如此,在上面的实现中需要分别为各数据成员赋值,否则将不能正确完成 CStudent 对象的复制。
04、组合类的赋值运算符
当没有为类提供赋值运算符函数时,编译器会自动提供一个赋值运算符函数,其完成的功能就是对数据成员逐一赋值:对于基本数据类型就是按位赋值,对于对象成员就是调用其赋值运算符函数进行赋值。在 CStudent 类中,虽然其对象成员 name 和 major 使用了堆内存,但因为已经为 MyString 类提供了实现深复制的赋值运算符函数,因此,编译器为 CStudent 类自动提供的赋值运算符函数能够正确运行,其实现形式如下:
在例 1 中,第 50 行调用了 CStudent 类的赋值运算符函数。根据上述编译器为 CStudent 类自动提供的赋值运算符函数的形式,在函数实现中两次调用 MyString 类的赋值运算符函数。这正是第 50 行的程序产生了输出中的第 13 行和第 14 行的原因。
版权声明: 本文为 InfoQ 作者【TiAmo】的原创文章。
原文链接:【http://xie.infoq.cn/article/97d123c18349ae88796795ac2】。文章转载请联系作者。
评论