17.4 - 派生类的构造和初始化
Key Takeaway
-
在前面两节课中,我们探讨了C++中继承的一些基础知识以及派生类初始化的顺序。本节课,我们将进一步了解构造函数在派生类初始化中的作用。为此,我们将继续使用上一课中开发的Base
和Derived
类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
对于非派生类来说,构造函数只需要关心它自己的成员即可。例如,对于Base
类,我们可以像这样创建一个对象:
1 2 3 4 5 6 |
|
下面是实例化base时实际发生的情况:
- 为
base
分配内存; - 调用合适的构造函数;
- 使用成员初始化值列表来初始化变量;
- 构造函数执行其函数体内语句;
- 控制权返还给调用者。
很简单。
对于派生类,事情稍微复杂一些:
1 2 3 4 5 6 |
|
在派生类实例化时会有如下步骤:
- 分配内存(满足
Base
和Derived
的需要); - 调用
Derived
的构造函数; Base
对象会使用合适的Base
构造函数首先初始化。如果没有指定构造函数,则调用默认构造函数;- 使用成员初始化值列表初始化变量;
- 构造函数执行其函数体内语句;
- 控制权返还给调用者。
两个例子中的不同之处在于,在 Derived
的构造函数可以做任何事之前,Base
的构造函数首先会被调用,它会初始化该对象的Base
部分,然后将控制权返还给 Derived
构造函数,然后 Derived
才能去完成它自己的工作。
初始化基类成员
我们所编写的派生类目前的缺点之一是,在创建派生对象时没有办法初始化 m_id
。如果我们想在创建派生对象时同时设置 m_cost
(来自对象的Derived
部分)和 m_id
(来自对象的Base
部分),该怎么办?
新手程序员会尝试这么做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
想法很好但并不完全正确。我们的确需要为构造函数添加额外的参数,否则C++无从知晓我们希望用什么值来初始化m_id
。
但是,C++不允许我们在成员初始化列表中的初始化继承来的成员变量。换句话说,成员变量的值只能在它所属类的构造函数的成员初始化列表中设置。
为什么C++要这样做?答案与const变量和引用变量有关。考虑一下如果m_id
是const
会发生什么。因为const变量必须在创建时用一个值初始化,所以基类构造函数必须在创建变量时设置它的值。但是,当基类构造函数完成时,将执行派生类构造函数的成员初始化列表。然后每个派生类都有机会初始化该变量,可能会改变它的值!通过将变量的初始化限制在这些变量所属类的构造函数中,C++需要确保所有变量只初始化一次。
最终的结果是上面的示例不起作用,因为m_id
是从Base
继承的,并且只有非继承的变量可以在成员初始化器列表中初始化。
但是,继承的变量仍然可以在构造函数体中使用赋值操作更改其值。因此,新程序员通常也会这样做:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
虽然这在本例中实际上是可行的,但如果m_id
是const
或引用,则不可行(因为const
值和引用必须在构造函数的成员初始化列表中初始化)。它的效率也很低,因为m_id
被分配了两次值:一次是在基类构造函数的成员初始化列表中,然后是在派生类构造函数的主体中。最后,如果基类在构造过程中需要访问这个值怎么办?它没有办法访问它,因为它是在执行Derived
构造函数之前才设置的(这基本上是在最后执行)。
那么,我们应该如何子创建Derived
类对象时正确初始化 m_id
呢?
在上面的这些例子中,当我们初始化 Derived
类的对象时,Base
类的部分都是通过它的默认构造函数来创建的。为什么它总是会调用默认构造函数呢?因为我们没有让他不要这么做啊!
所幸,C++ 允许我们显式地指定创建Base
类时应该使用的构造函数!我们只需要在派生类的成员初始化值列表中调用所需的Base
的构造函数就可以了:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
再次执行代码:
1 2 3 4 5 6 7 8 9 10 |
|
基类的构造函数 Base(int)
将会被用来初始化成员m_id
(5)然后派生类的构造函数会被用来初始化 m_cost
到 1.3
!
因此,程序会打印:
1 2 |
|
具体来说:
- 内存分配;
- 构造函数
Derived(double, int)
被调用,cost = 1.3
,id = 5
。 - 编译器查看你是否指定了
Base
类的构造函数。这里我们指定了,所以它会调用Base(int)
且id = 5
。 - 基类构造函数的成员初始化值列表将
m_id
设置为5; - 基类构造函数的函数体执行,什么都没做;
- 基类构造函数返回;
- 派生类构造函数的成员初始化值列表将
m_cost
设置为 1.3; - 派生类构造函数的函数体执行,什么都没做;
- 派生类构造函数返回。
看起来有点复杂,但实际上非常简单。所发生的一切就是Derived
构造函数调用指定的Base
构造函数来初始化对象的Base
部分。因为m_id
位于对象的Base
部分,所以Base
构造函数是唯一可以初始化该值的构造函数。
注意,Base构造函数在Derived
构造函数成员初始化列表中的什么位置被调用并不重要——它总是首先执行。
将成员设为私有
既然已经知道了如何初始化基类成员,就没有必要将成员变量保持为public
。我们再次将成员变量设为私有,因为它们应该是私有的。
快速回顾一下,公共成员可以被任何人访问。私有成员只能由同一类的成员函数访问。注意,这意味着派生类不能直接访问基类的私有成员!派生类将需要使用访问函数来访问基类的私有成员。
考虑下面代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
在上面的例子中,我们将 m_id
和 m_cost
设为私有。这么做完全没有问题,因为我们可以使用相关的构造函数初始化这些成员,并通过公有的成员访问函数来访问它们。
打印结果如我们所想的那样:
1 2 |
|
我们将在下一课中更多地讨论访问说明符。
另外一个例子
再看看之前我们使用过的一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
在之前的例子中, BaseballPlayer
只会初始化它自己的成员,也并没有指定 Person
的构造函数。这意味着 BaseballPlayer
在创建时,调用的都是 Person
的默认构造函数,它会将名字初始化为空白并将年龄初始化为0。因为,因为在创建BaseballPlayer
时给它们一个名称和年龄是有意义的,所以我们应该修改这个构造函数来添加这些参数。
更新代码,让类使用私有成员,同时让 BaseballPlayer
类调用适当的 Person
构造函数来初始化继承的 Person
成员变量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
现在,像下面这样创建一个对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
程序运行结果:
1 2 3 4 |
|
可以看到,基类的名称和年龄已正确初始化,派生类的本垒打数和击球率也已初始化。
继承链
继承链中的类以完全相同的方式工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
在这个例子中,类C是由类B派生而来的,而类B又是由类A派生而来的。那么当我们实例化类C的对象时会发生什么呢?
首先 main()
会调用 C(int, double, char)
。C 的构造函数会调用 B(int, double)
。B的构造函数调用A(int)
。因为A没有继承任何类,所以它会被第一个构造。A 被构造后打印了 5,然后控制权返回给B。B被构造时打印值 4.3,然后返回控制权给C。C被构造时打印‘R’,然后将控制权返回给 main()
。构造结束!
因此程序打印结果如下:
1 2 3 |
|
值得一提的是,构造函数只能调用直接父类(基类)中的构造函数。因此,C构造函数不能直接调用或将参数传递给A构造函数。C构造函数只能调用B构造函数(B构造函数负责调用A构造函数)。
析构函数
当派生类被销毁时,每个析构函数将按构造的逆顺序调用。在上面的例子中,当C被销毁时,首先调用C析构函数,然后是B析构函数,然后是A析构函数。
小结
在构造派生类时,派生类构造函数负责确定调用哪个基类构造函数。如果没有指定基类构造函数,将使用默认基类构造函数。在这种情况下,如果找不到缺省基类构造函数(或缺省创建基类构造函数),编译器将报错。然后按照从最基类到最派生类的顺序构造类。
至此,您已经足够了解C++继承,可以创建自己的继承类了!