一、C++继承和派生的概念
1.1 继承和派生的定义及语法
C++ 中的继承是类与类之间的关系,是一个很简单很直观的概念,与现实世界中的继承类似。
继承(Inheritance) 可以理解为一个类从其它类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。
在C++中,派生(Derive) 和 继承(Inheritance) 是一个概念,只是站的角度不同。继承是儿子接收父亲的产业,派生是父亲把产业传承给儿子。
Tips: 被继承的类称为父类或基类,继承的类称为子类或派生类。“子类”和“父类”通常放在一起称呼,“基类”和“派生类”通常放在一起称呼。
派生类只有一个直接基类,称为 单继承(Single Inheritance)。
派生类(子类)除了拥有基类的成员,还可以定义自己的新成员,以增强类的功能。
以下是两种典型的使用继承的场景:
- 当创建的新类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承,这样不但会减少代码量,而且新类会拥有基类的所有功能。
- 当需要创建多个类,它们拥有很多相似的成员变量或成员函数时,也可以使用继承。可以将这些类的共同成员提取出来,定义为基类,然后从基类继承,既可以节省代码,也方便后续修改成员。
继承的一般语法为:
|
|
继承方式包括: public(公有的)、private(私有的)和 protected(受保护的),此项是可选的,如果不写,那么默认为 private。
Tips: 从基类继承过来的成员,可以通过子类对象访问,就像自己的一样。
示例:
|
|
本例中,People 是基类,Student 是派生类。Student 类继承了 People 类的成员,同时还新增了自己的成员变量 score 和成员函数 setscore()、getscore()。
1.2 继承和派生的方式详解
继承方式 是限定基类成员在派生类中的访问权限,继承方式包括 public(公有的)、private(私有的)和 protected(受保护的)。
此项是可选项,如果不写,对于class 关键字定义的子类默认为 private(成员变量和成员函数默认也是 private)方式继承;对于 struct 关键字定义的子类默认为 public(公有的)。
Tips: public、protected、private 三个关键字除了可以修饰类的成员,还可以指定继承方式。
public、protected、private 修饰类的成员是时,类成员的访问权限由高到低依次为 public --> protected --> private
,在《C++类和对象》一节中讲解了 public 和 private:public 成员可以通过对象来访问,private 成员不能通过对象访问(只能在成员函数中访问)。
现在再来补充一下 protected。protected 成员和 private 成员类似,也不能通过对象访问(只能在成员函数中访问)。但是当存在继承关系时,protected 和 private 就不一样了:基类中的 protected 成员可以在派生类中使用,而基类中的 private 成员不能在派生类中使用,下面是详细讲解。
public、protected、private 指定了子类不同的方式继承父类,这会影响父类成员在子类中的访问权限:
1、public继承方式
- 基类中所有 public 成员在派生类中为 public 属性;
- 基类中所有 protected 成员在派生类中为 protected 属性;
- 基类中所有 private 成员在派生类中不能使用。
2、protected继承方式
- 基类中的所有 public 成员在派生类中为 protected 属性;
- 基类中的所有 protected 成员在派生类中为 protected 属性;
- 基类中的所有 private 成员在派生类中不能使用。
3、private继承方式
- 基类中的所有 public 成员在派生类中均为 private 属性;
- 基类中的所有 protected 成员在派生类中均为 private 属性;
- 基类中的所有 private 成员在派生类中不能使用。
不同继承方式对不同属性的成员的限制结果:
继承方式/基类成员 | public成员 | protected成员 | private成员 |
---|---|---|---|
public继承 | public | protected | 不可见 |
protected继承 | protected | protected | 不可见 |
private继承 | private | private | 不可见 |
继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的, 具有以下及点特征:
- 基类成员在派生类中的访问权限不得高于继承方式中指定的权限。
- 不管继承方式如何,基类中的 private 成员在派生类中始终不能使用(不能在派生类的成员函数中访问或调用)。
- 如果希望基类的成员能够被派生类继承并且毫无障碍地使用,那么这些成员只能声明为 public 或 protected;只有那些不希望在派生类中使用的成员才声明为 private。
- 如果希望基类的成员既不向外暴露(不能通过对象访问),还能在派生类中使用,那么只能声明为 protected。
注意:这里说的是基类的 private 成员不能在派生类中使用,并不是说基类的 private 成员不能被继承。 实际上,基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法直接使用罢了。private 成员的这种特性,能够很好的对派生类隐藏基类的实现,以体现面向对象的封装性。
在派生类中访问基类 private 成员的唯一方法就是借助基类的非 private 成员函数,如果基类没有非 private 成员函数,那么该成员在派生类中将无法访问。
1.3 using改变基类成员在派生类中的访问权限
使用 using 关键字可以改变基类成员在派生类中的访问权限,例如将 public 改为 protected 或 private、将 protected 改为 public 或 private。
注意:using 只能在派生类中改变从基类继承来的 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能直接使用,所以基类中的 private 成员在派生类中无论如何都不能直接访问(只可通过基类的public 和 protected 成员函数间接去访问)。
using 关键字使用示例:
|
|
1.4 继承时的名字遮蔽问题
1.4.1 遮蔽问题简介
如果派生类中的成员(包括成员变量和成员函数)和基类中的成员重名,那么就会遮蔽从基类继承过来的成员。
所谓 遮蔽,就是在派生类中使用该成员(包括在定义派生类时使用,也包括通过派生类对象访问该成员)时,实际上使用的是派生类新增的成员,而不是从基类继承来的。 下面是一个成员函数的名字遮蔽的例子:
|
|
本例中,基类 People 和派生类 Student 都定义了成员函数 show(),它们的名字一样,会造成遮蔽。stu.show();
语句中,stu 是 Student 类的对象,默认使用 Student 类的 show() 函数。
但是,基类 People 中的 show() 函数仍然可以访问,不过要加上类名和域解析符 stu.People::show();
。
在类的继承和派生中,基类成员函数和派生类成员函数不构成重载。
基类成员和派生类成员的名字一样时会造成 遮蔽,这句话对于成员变量很好理解,对于成员函数要引起注意,不管函数的参数相同还是不同,只要名字一样就会造成遮蔽。换句话说,基类成员函数和派生类成员函数不会构成重载,如果派生类有同名函数,那么就会遮蔽基类中的所有同名函数,不管它们的参数是否一样。
出现成员 遮蔽 后,在派生类中 或 派生类对象 要访问基类 被 遮蔽 的成员时, 需要加上基类内做完作用域说明。 示例:
|
|
本例中,Base 类的func()、func(int)和 Derived 类的func(char *)、func(bool)四个成员函数的名字相同,参数列表不同,它们看似构成了重载,能够通过对象 d 访问所有的函数,实则不然,Derive 类的 func 遮蔽了 Base 类的 func,导致d.func(); d.func(10); //compile error
行代码没有匹配的函数,所以调用失败。
如果说有重载关系,那么也是 Base 类的两个 func 构成重载,而 Derive 类的两个 func 构成另外的重载。
Base 类 2个 func 与 Derived 类的 2 个 func 不构成重载。
1.4.2 遮蔽问题的本质
类其实也是一种作用域,每个类都会定义它自己的作用域,在这个作用域内定义类的成员。当存在继承关系时,派生类的作用域嵌套在基类的作用域之内,如果一个名字在派生类的作用域内无法找到,编译器会继续到外层的基类作用域中查找该名字的定义。
换句话说,作用域能够彼此包含,被包含(或者说被嵌套)的作用域称为内层作用域(inner scope),包含着别的作用域的作用域称为外层作用域(outer scope)。一旦在外层作用域中声明(或者定义)了某个名字,那么它所嵌套着的所有内层作用域中都能访问这个名字。同时,允许在内层作用域中重新定义外层作用域中已有的名字。
假设 Base 是基类,Derived 是派生类,那么它们的作用域的嵌套关系如下图所示: 派生类的作用域位于基类作用域之内,虽然代码中派生类和基类的定义是相互分离的, 也恰恰因为类作用域有这种继承嵌套的关系,所以派生类才能像使用自己的成员一样来使用基类的成员。
类作用域嵌套的示例:
|
|
本例中,B 继承自 A,C继承自 B,它们作用域的嵌套关系如下图所示:
obj 是 C 类的对象,通过 obj 访问成员变量 n 时,在 C 类的作用域中就能够找到了 n 这个名字。虽然 A 类和 B 类都有名字 n,但编译器优先在C类作用域中查找到了名字你(如果没找到,才会依次到B类、A类作用域中找),就不会到A 类和 B 类的作用域中查找了,所以A 类和 B 类 中的成员变量 n 在C类对象中是 不可见的,也即派生类C中的 n 遮蔽了基类中的 n。
通过 obj 访问成员函数 func() 时,在 C 类的作用域中没有找到 func 这个名字,编译器继续到 B 类的作用域(外层作用域)中查找,仍然没有找到,再继续到 A 类的作用域中查找,结果就发现了 func 这个名字,于是查找结束,编译器决定调用 A 类作用域中的 func() 函数。
这个过程叫做 名字查找(name lookup),也就是在作用域链中寻找与所用名字最匹配的声明(或定义)的过程。
对于成员变量这个过程很好理解,对于成员函数要引起注意,*编译器仅仅是根据函数的名字来查找的,不会理会函数的参数。换句话说,一旦内层作用域有同名的函数,不管有几个,编译器都不会再到外层作用域中查找,编译器仅把内层作用域的这些同名函数作为一组候选函数;这组候选函数就是一组重载函数。
说白了,只有同一个作用域内的同名函数才具有重载关系,嵌套的不同作用域内的同名函数是会造成遮蔽,使得外层函数无效。派生类和基类拥有不同的作用域(嵌套),所以派生类和基类中的同名函数不具有重载关系。
函数重载 只在同一层作用域内存在; 遮蔽问题 只在内层作用域内存在(遮蔽外层名字);
所以,前面的Derived 类和 Base 类示例中,虽然 Derived 类和 Base 类都有同名的 func 函数,但它们位于不同的作用域,Derived 类的 func 会遮蔽 Base 类的 func。d 是 Derived 类的对象,调用 func 函数时,编译器会先在 Derived 类中查找“func”这个名字,发现有两个,也即void func(char*) 和 void func(bool),这就是一组候选函数。
执行到 d.func(); d.func(10); //compile error
行代码时,在候选函数中没有找到签名匹配的函数,所以调用失败,这时编译器会抛出错误信息,而不是再到 Base 类中查找同名函数。
二、C++基类和派生类的构造函数 和 析构函数
2.1 构造函数
继承和派生中介绍到基类的成员函数可以被继承,可以通过派生类的对象访问,但这仅仅指的是普通的成员函数,类的构造函数不能被继承。构造函数不能被继承是因为即使继承了,它的名字和派生类的名字也不一样,不能成为派生类的构造函数,当然更不能成为普通的成员函数。
在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是继承过来的大部分基类成员都有 private 属性,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。
这种矛盾在C++继承中是普遍存在的,解决这个问题的方法是:在派生类的构造函数中调用基类的构造函数。 在派生类的构造函数中调用基类的构造函数的示例:
|
|
示例中 Student 类的构造函数中,初始化列表中的 People(name, age)
就是调用基类 People 的构造函数,并将 name 和 age 作为实参传递给它,m_score(score)
是派生类的参数初始化表,它们之间以逗号 ,
隔开。
也可以将基类构造函数的调用 People(name, age)
放在 m_score(score)
参数 后面的初始化表后面中, 它们的顺序是不固定的。
|
|
但是不管它们的顺序如何,派生类构造函数总是先调用基类构造函数再执行其他代码(包括参数初始化表以及函数体中的代码)。 总体上看和下面的形式类似:
|
|
当然这段代码只是为了方便大家理解,实际上这样写是错误的,因为基类构造函数不会被继承,不能当做普通的成员函数来调用。换句话说,只能将基类构造函数的调用放在函数头部,不能放在函数体中。
另外,函数头部是对基类构造函数的调用,而不是声明,所以括号里的参数是实参,它们不但可以是派生类构造函数参数列表中的参数,还可以是局部变量、常量等,例如:
|
|
2.2 构造函数的调用顺序
从上面的分析中可以看出,基类构造函数总是被优先调用,这说明创建派生类对象时,会先调用基类构造函数,再调用派生类构造函数,如果继承关系有好多层的话,例如 A --> B --> C
那么创建 C 类对象时构造函数的执行顺序为:
|
|
构造函数的调用顺序是按照继承的层次自顶向下、从基类再到派生类的。这种调用的本质是,派生类构造函数总是先调用基类构造函数,这样逐级往上层基类推导调用 的原理。
还有一点要注意,派生类构造函数中只能调用直接基类的构造函数,直接基类再起调用其直接基类的构造函数,依次逐级递推,派生类不能直接调用间接基类的。以上面的 A、B、C 类为例,C 是最终的派生类,B 就是 C 的直接基类,C 只能调用 B 的构造函数,A 就是 C 的间接基类,C 不能直接调用 A 的构造函数,而是通过调用 B 的构造函数时,间接的通过 B 的构造函数来调用 A 的构造函数的。
C++ 这样规定是有道理的,因为在 C 中调用了 B 的构造函数,B 又调用了 A 的构造函数,相当于 C 间接地(或者说隐式地)调用了 A 的构造函数,如果再在 C 中显式地调用 A 的构造函数,那么 A 的构造函数就被调用了两次,相应地,初始化工作也做了两次,这不仅是多余的,还会浪费CPU时间以及内存,毫无益处,所以 C++ 禁止在 C 中显式地调用 A 的构造函数。
2.3 基类构造函数调用规则
通过派生类创建对象时必须要调用基类的构造函数,这是语法规定。换句话说,定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);如果没有默认构造函数,那么编译失败。 请看下面的例子:
|
|
运行结果:
|
|
创建对象 stu1 时,执行派生类的构造函数Student::Student(),它并没有指明要调用基类的哪一个构造函数,从运行结果可以很明显地看出来,系统默认调用了不带参数的构造函数,也就是People::People()。
创建对象 stu2 时,执行派生类的构造函数Student::Student(char *name, int age, float score),它指明了基类的构造函数。
在 Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
行代码中,如果将People(name, age)去掉,也会调用默认构造函数, stu2.display();
行的输出结果将变为:
|
|
如果将基类 People 中不带参数的构造函数删除,那么会发生编译错误,因为创建对象 stu1 时需要调用 People 类的默认构造函数, 而 People 类中已经显式定义了构造函数,编译器不会再生成默认的构造函数。
2.4 析构函数
和构造函数类似,析构函数也不能被继承。与构造函数不同的是,在派生类的析构函数中不用显式地调用基类的析构函数,因为每个类只有一个析构函数,编译器知道如何选择,无需程序员干涉。
另外析构函数的执行顺序和构造函数的执行顺序也刚好相反:
- 创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类构造函数,再执行派生类构造函数。
- 而销毁派生类对象时,析构函数的执行顺序和继承顺序相反,即先执行派生类析构函数,再执行基类析构函数。
请看下面的例子:
|
|
运行结果:
|
|
三、C++多继承(多重继承)详解
3.1 多继承(多重继承)的概念
C++支持 多继承(Multiple Inheritance),即一个派生类可以有两个或多个直接基类。
多继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中较少使用,后来的 Java、C#、PHP 等干脆取消了多继承。
多继承的语法也很简单,将多个基类用逗号隔开即可。例如已声明了类A、类B和类C,那么可以这样来声明派生类D:
|
|
D 是多继承形式的派生类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。D 根据不同的继承方式获取 A、B、C 中的成员,确定它们在派生类中的访问权限。
3.2 多继承下的构造函数 和 虚构函数
多继承形式下的构造函数和单继承形式基本相同,只是要在派生类的构造函数中调用多个基类的构造函数。以上面的 A、B、C、D 类为例,D 类构造函数的写法为:
|
|
基类构造函数的调用顺序和它们在派生类构造函数中出现的顺序无关,而是和声明派生类时基类出现的顺序相同。仍然以上面的 A、B、C、D 类为例,即使将 D 类构造函数写作下面的形式:
|
|
那么也是先调用 A 类的构造函数,再调用 B 类构造函数,最后调用 C 类构造函数。 多继承的实例:
|
|
运行结果:
|
|
从运行结果中还可以发现,多继承形式下析构函数的执行顺序和构造函数的执行顺序相反。
3.3 多继承下的命名冲突
当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。这个时候需要在成员名字前面加上类名和域解析符::,以显式地指明到底使用哪个类的成员,消除二义性。
修改上面的代码,为 BaseA 和 BaseB 类添加 show() 函数,并将 Derived 类的 show() 函数更名为 display():
|
|
请读者注意, 在Derived 类的 display 函数中, 我们显示的指明了要调用哪个基类的 show() 函数。
|
|
多重继承中, 如果派生类的成员名 与 基类的成员名相同时,和单继承一样将发生 名字遮蔽 现象。
四、C++虚继承和虚基类详解
4.1 多继承(Multiple Inheritance)导致的名字冲突
多继承(Multiple Inheritance) 是指从多个直接基类中派生类的能力,多继承的派生类继承了所有父类的成员。尽管概念上非常简单,但是多个基类的相互交织可能会带来错综复杂的设计问题,命名冲突就是不可回避的一个。
多继承时很容易产生命名冲突,即使我们很小心地将所有类中的成员变量和成员函数都命名为不同的名字,命名冲突依然有可能发生,比如典型的是 菱形继承,如下图所示: 类 A 派生出类 B 和类 C,类 D 继承自类 B 和类 C,这个时候类 A 中的成员变量和成员函数继承到类 D 中变成了两份,一份来自 A–>B–>D 这条路径,另一份来自 A–>C–>D 这条路径。
在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A –>B–>D 这条路径,还是来自 A–>C–>D 这条路径。下面是菱形继承的具体实现:
|
|
这段代码实现了上图所示的菱形继承, void seta(int a){ m_a = a; } //命名冲突
行代码试图直接访问成员变量 m_a,结果发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。
为了消除歧义,我们可以在 m_a 的前面指明它具体来自哪个类:
|
|
这样表示使用 B 类的 m_a。当然也可以使用 C 类的:
|
|
4.2 虚继承(Virtual Inheritance)
为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了 ** 虚继承(Virtual Inheritance)**,使得在派生类中只保留一份间接基类的成员。
虚继承(Virtual Inheritance) 的语法是在继承方式前面加上 virtual 关键字就是虚继承,请看下面的例子:
|
|
这段代码使用虚继承重新实现了上图所示的菱形继承,这样在派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。
虚继承 的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为 虚基类(Virtual Base Class),本例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份 虚基类的成员。
现在让我们重新梳理一下本例使用虚继承解决菱形继承中的命名冲突问题的继承关系,如下图所示: 观察这个新的继承体系,我们会发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上图中,当定义 D 类时才出现了对虚派生的需求,但是如果 B 类和 C 类不是从 A 类虚派生得到的,那么 D 类还是会保留 A 类的两份成员。
Tips: 换个角度讲,虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身。
在实际开发中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。通常情况下,使用虚继承的类层次是由一个人或者一个项目组一次性设计完成的。对于一个独立开发的类来说,很少需要基类中的某一个类是虚基类,况且新类的开发者也无法改变已经存在的类体系。
C++标准库中的 iostream 类就是一个虚继承的实际应用案例。iostream 从 istream 和 ostream 直接继承而来,而 istream 和 ostream 又都继承自一个共同的名为 base_ios 的类,是典型的菱形继承。此时 istream 和 ostream 必须采用虚继承,否则将导致 iostream 类中保留两份 base_ios 类的成员。
虚继承在C++标准库中的实际应用如下图:
4.3 虚基类成员的可见性
因为在虚继承的最终派生类中只保留了一份虚基类的成员,所以该成员可以被直接访问,不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,那么仍然可以直接访问这个被覆盖的成员。但是如果该成员被两条或多条路径覆盖了,那就不能直接访问了,此时必须指明该成员属于哪个类。
以图上图中的菱形继承为例,假设 A 定义了一个名为 x 的成员变量,当我们在 D 中直接访问 x 时,会有三种可能性:
- 如果 B 和 C 中都没有 x 的定义,那么 x 将被解析为 A 的成员,此时不存在二义性。
- 如果 B 或 C 其中的一个类定义了 x,也不会有二义性,派生类的 x 比虚基类的 x 优先级更高。
- 如果 B 和 C 中都定义了 x,那么直接访问 x 将产生二义性问题。
可以看到,使用多继承经常会出现二义性问题,必须十分小心。上面的例子是简单的,如果继承的层次再多一些,关系更复杂一些,程序员就很容易陷人迷魂阵,程序的编写、调试和维护工作都会变得更加困难,因此一般不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。也正是由于这个原因,C++ 之后的很多面向对象的编程语言,例如 Java、C#、PHP 等,都不支持多继承。
4.4 虚继承时的构造函数 和 析构函数
在虚继承中,虚基类的成员变量是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。
继续以菱形继承为例来演示构造函数的调用:
|
|
运行结果:
|
|
在最终派生类 D 的构造函数中,除了调用 B 和 C 的构造函数,还调用了 A 的构造函数,这说明 D 不但要负责初始化直接基类 B 和 C,还要负责初始化间接基类 A。而在以往的普通继承中,派生类的构造函数只负责初始化它的直接基类,再由直接基类的构造函数初始化间接基类,用户尝试调用间接基类的构造函数将导致错误。
现在采用了虚继承,虚基类 A 在最终派生类 D 中只保留了一份成员变量 m_a,如果由 B 和 C 初始化 m_a,那么 B 和 C 在调用 A 的构造函数时很有可能给出不同的实参,这个时候编译器就会犯迷糊,不知道使用哪个实参初始化 m_a。
为了避免出现这种矛盾的情况,C++ 干脆规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。在第 50 行代码中,调用 B 的构造函数时试图将 m_a 初始化为 90,调用 C 的构造函数时试图将 m_a 初始化为 100,但是输出结果有力地证明了这些都是无效的,m_a 最终被初始化为 50,这正是在 D 中直接调用 A 的构造函数的结果。
另外需要关注的是构造函数的执行顺序。虚继承时构造函数的执行顺序与普通继承时不同:在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序如何,编译器总是先调用虚基类的构造函数,再按照出现的顺序调用其他的构造函数;而对于普通继承,就是按照构造函数出现的顺序依次调用的。
本例中如下改变构造函数出现的顺序:
|
|
虽然我们将 A() 放在了最后,但是编译器仍然会先调用 A(),然后再调用 B()、C(),因为 A() 是虚基类的构造函数,比其他构造函数优先级高。如果没有使用虚继承的话,那么编译器将按照出现的顺序依次调用 B()、C()、A()。
析构函数以构造函数的执行顺序相反。
五、C++将派生类对象赋值给基类对象(向上转型)
5.1 类型转换
在 C/C++ 中经常会发生 数据类型的转换,例如将 int 类型的数据赋值给 float 类型的变量时,编译器会先把 int 类型的数据转换为 float 类型再赋值;反过来,float 类型的数据在经过类型转换后也可以赋值给 int 类型的变量。
数据类型转换 的前提是,编译器知道如何对数据进行取舍。
类其实也是一种数据类型,类的对象也可以发生数据类型转换,不过这种转换 只有在基类对象和派生类对象之间才有意义,并且只能将派生类对象赋值给基类对象,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为 向上转型(Upcasting)。相应地,将基类赋值给派生类称为 向下转型(Downcasting)。
向上转型非常安全,可以由编译器自动完成;向下转型有风险,需要程序员手动干预。
Tips: 向上转型和向下转型是面向对象编程的一种通用概念,它们也存在于 Java、C# 等编程语言中。
5.2 将派生类对象赋值给基类对象
将派生类对象赋值给基类对象示例:
|
|
运行结果:
|
|
本例中 A 是基类, B 是派生类,a、b 分别是它们的对象,由于派生类 B 包含了从基类 A 继承来的成员,因此可以将派生类对象 b 赋值给基类对象 a。通过运行结果也可以发现,赋值后 a 所包含的成员变量的值已经发生了变化。
赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。 运行结果也有力地证明了这一点,虽然有a=b;这样的赋值过程,但是 a.display() 始终调用的都是 A 类的 display() 函数。换句话说,对象之间的赋值不会影响成员函数,也不会影响 this 指针。
将派生类对象赋值给基类对象时,会舍弃派生类新增的成员,也就是“大材小用”,如下图所示: 可以发现,即使将派生类对象赋值给基类对象,基类对象也不会包含派生类的成员,所以依然不同通过基类对象来访问派生类的成员。对于上面的例子,a.m_a 是正确的,但 a.m_b 就是错误的,因为 a 不包含成员 m_b。
这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。理由很简单,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值。
要理解这个问题,还得从赋值的本质入手。赋值实际上是向内存填充数据,当数据较多时很好处理,舍弃即可;本例中将 b 赋值给 a 时(执行a=b;语句),成员 m_b 是多余的,会被直接丢掉,所以不会发生赋值错误。但当数据较少时,问题就很棘手,编译器不知道如何填充剩下的内存;如果本例中有b= a;这样的语句,编译器就不知道该如何给变量 m_b 赋值,所以会发生错误。
5.3 将派生类指针赋值给基类指针
除了可以将派生类对象赋值给基类对象(对象变量之间的赋值),还可以将派生类指针赋值给基类指针(对象指针之间的赋值)。先来看一个多继承的例子,继承关系为: 下面的代码实现了这种继承关系:
|
|
运行结果:
|
|
本例中定义了多个对象指针,并尝试将派生类指针赋值给基类指针。与对象变量之间的赋值不同的是,对象指针之间的赋值并没有拷贝对象的成员,也没有修改对象本身的数据,仅仅是改变了指针的指向。
1、通过基类指针访问派生类的成员
将 派生类D 的指针 pd 赋值给了 基类A 的指针 pa,从运行结果可以看出,调用 display() 函数时虽然使用了 派生类D 的成员变量,但是 display() 函数本身却是基类的。也就是说,将派生类指针赋值给基类指针时,通过基类指针只能使用派生类的成员变量,但不能使用派生类的成员函数,这看起来有点不伦不类,究竟是为什么呢?派生类D 的指针 pd 赋值给了 基类 B 的指针pb、 和 基类 C 的指针pc 也是类似的情况。
pa 本来是基类 A 的指针,现在指向了派生类 D 的对象,这使得隐式指针 this 发生了变化,也指向了 D 类的对象,所以最终在 display() 内部使用的是 D 类对象的成员变量,相信这一点不难理解。
编译器虽然通过指针的指向来访问成员变量,但是却不通过指针的指向来访问成员函数:编译器通过指针的类型来访问成员函数。对于 pa,它的类型是 A,不管它指向哪个对象,使用的都是 A 类的成员函数,具体原因已在《C++类和对象》中的 “成员函数的编译和实现” 小节做了详细讲解。
概括起来说就是:
- 编译器通过指针来访问成员变量,指针指向哪个对象就使用哪个对象的数据;
- 编译器通过指针的类型来访问成员函数,指针属于哪个类的类型就使用哪个类的函数;
2、赋值后值不一致的情况 本例中我们将最终派生类的指针 pd 分别赋值给了基类指针 pa、pb、pc,按理说它们的值应该相等,都指向同一块内存,但是运行结果却有力地反驳了这种推论,只有 pa、pb、pd 三个指针的值相等,pc 的值比它们都大。也就是说,执行pc = pd;语句后,pc 和 pd 的值并不相等。
这非常出乎我们的意料,按照我们通常的理解,赋值就是将一个变量的值交给另外一个变量,不会出现不相等的情况,究竟是什么导致了 pc 和 pd 不相等呢?我们将在《将派生类指针赋值给基类指针时到底发生了什么?》一节中解开谜底。
5.4 将派生类引用赋值给基类引用
引用在本质上是通过指针的方式实现的,这一点已在《C++引用(Reference)详解》中进行了讲解,既然基类的指针可以指向派生类的对象,那么我们就有理由推断:基类的引用也可以指向派生类的对象,并且它的表现和指针是类似的。
修改上例中 main() 函数内部的代码,用引用取代指针:
|
|
运行结果:
|
|
ra、rb、rc 是基类的引用,它们都引用了派生类对象 d,并调用了 display() 函数,从运行结果可以发现,虽然使用了派生类对象的成员变量,但是却没有使用派生类的成员函数,这和指针的表现是一样的。
引用和指针的表现之所以如此类似,是因为引用和指针并没有本质上的区别,引用仅仅是对指针进行了简单封装。
最后需要注意的是,向上转型后通过基类的对象、指针、引用只能访问从基类继承过去的成员(包括成员变量和成员函数),不能访问派生类新增的成员。
5.5 将派生类指针赋值给基类指针时到底发生了什么?
前面的 基类指针和引用指向派生类对象 的例子我们发现,将派生类的指针赋值给基类的指针(引用也一样)后,它们的值有可能相等,也有可能不相等。例如执行 pc = pd;
语句后,pc 的值为 0x600003754078,pd 的值为 0x600003754070,它们不相等。
我们通常认为,赋值就是将一个变量的值交给另外一个变量,这种想法虽然没错,但是有一点要注意,就是赋值以前编译器可能会对现有的值进行处理。例如将 double 类型的值赋给 int 类型的变量,编译器会直接抹掉小数部分,导致赋值运算符两边变量的值不相等。
同样的,将派生类的指针赋值给基类的指针时也是类似的道理,编译器也可能会在赋值前进行处理。 要理解这个问题,首先要清楚 D 类对象的内存模型,如下图所示:
首先要明确的一点是,对象的指针必须要指向对象的起始位置。对于 A 类和 B 类来说,它们的子对象的起始地址和 D 类对象一样,所以将 pd 赋值给 pa、pb 时不需要做任何调整,直接传递现有的值即可;而 C 类子对象距离 D 类对象的开头有一定的偏移,将 pd 赋值给 pc 时要加上这个偏移,这样 pc 才能指向 C 类子对象的起始位置。也就是说,执行pc = pd;
语句时编译器对 pd 的值进行了调整,才导致 pc、pd 的值不同。
下面的代码演示了将 pd 赋值给 pc 时编译器的调整过程:
|
|
如果我们把 B、C 类的继承顺序调整一下,让 C 在 B 前面,如下所示:
|
|
那么输出的地址结果就会变为:
|
|