C++ 继承
继承(Inherice)可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。
被继承的类称为父类或基类,继承的类称为子类或派生类。
继承的一般语法为:
1 | class 派生类名:[继承方式] 基类名{ |
继承方式
继承方式限定了基类成员在派生类中的访问权限,包括 public(公有的)、private(私有的)和 protected(受保护的)。默认为 private(成员变量和成员函数默认也是 private)。
protected 成员和 private 成员类似,也不能通过对象访问。但是当存在继承关系时,基类中的 protected 成员可以在派生类中使用。
- 继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的。
- 基类中的 private 成员在派生类中始终不能使用(不能在派生类的成员函数中访问或调用)。
- 希望基类的成员既不向外暴露(不能通过对象访问),还能在派生类中使用,那么只能声明为 protected。
基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法使用罢了。private 成员的这种特性,能够很好的对派生类隐藏基类的实现,以体现面向对象的封装性。
在派生类中访问基类 private 成员的唯一方法就是借助基类的非 private 成员函数,如果基类没有非 private 成员函数,那么该成员在派生类中将无法访问。
using 只能改变基类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能使用,所以基类中的 private 成员在派生类中无论如何都不能访问。
1 | //基类People |
名字遮蔽
派生类中的成员(包括成员变量和成员函数)和基类中的成员重名,那么就会遮蔽从基类继承过来的成员。
基类 People 中的被遮蔽的函数仍然可以访问,不过要加上类名和域解析符。
1 | //使用的是从基类继承来的成员函数 |
1 | //基类Base |
Base 类的两个 func 构成重载,而 Derive 类的两个 func 构成另外的重载。
名字查找(name lookup),也就是在作用域链中寻找与所用名字最匹配的声明(或定义)的过程。
只有一个作用域内的同名函数才具有重载关系,不同作用域内的同名函数是会造成遮蔽,使得外层函数无效。派生类和基类拥有不同的作用域,所以它们的同名函数不具有重载关系。
单继承,对象内存模型
没有继承时对象内存的分布情况。成员变量和成员函数会分开存储:
- 对象的内存中只包含成员变量,存储在栈区或堆区(使用 new 创建对象);
- 成员函数与对象内存分离,存储在代码区。
有继承关系时,派生类的内存模型可以看成是基类成员变量和新增成员变量的总和,而所有成员函数仍然存储在另外一个区域——代码区,由所有对象共享。
基类的成员变量排在前面,派生类的排在后面。成员变量按照派生的层级依次排列,新增成员变量始终在最后。
有成员变量遮蔽时的内存分布:
当基类 A、B 的成员变量被遮蔽时,仍然会留在派生类对象 obj_c 的内存中,C 类新增的成员变量始终排在基类 A、B 的后面。
单继承,构造函数
对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有 private 属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。
解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数。
1 | //派生类Student |
派生类构造函数总是先调用基类构造函数再执行其他代码(包括参数初始化表以及函数体中的代码)。
函数头部是对基类构造函数的调用,而不是声明,所以括号里的参数是实参,它们不但可以是派生类构造函数参数列表中的参数,还可以是局部变量、常量等,例如:
1 | Student::Student(char *name, int age, float score): People("小明", 16), m_score(score){ } |
构造函数的调用顺序是按照继承的层次自顶向下、从基类再到派生类的。
派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。
定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);如果没有默认构造函数,那么编译失败。
1 | //基类People |
单继承,析构函数
析构函数也不能被继承。与构造函数不同的是,在派生类的析构函数中不用显式地调用基类的析构函数,因为每个类只有一个析构函数,编译器知道如何选择,无需程序员干涉。
析构函数的执行顺序和构造函数的执行顺序也刚好相反:
- 创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类构造函数,再执行派生类构造函数。
- 而销毁派生类对象时,析构函数的执行顺序和继承顺序相反,即先执行派生类析构函数,再执行基类析构函数。
多继承
多继承的语法:
1 | class D: public A, private B, protected C{ |
D 是多继承形式的派生类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。
多继承,构造函数
在派生类的构造函数中调用多个基类的构造函数。
1 | D(形参列表): A(实参列表), B(实参列表), C(实参列表){ |
基类构造函数的调用顺序和和它们在派生类构造函数中出现的顺序无关,而是和声明派生类时基类出现的顺序相同。
多继承,命名冲突
当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。这个时候需要在成员名字前面加上类名和域解析符::
,以显式地指明到底使用哪个类的成员,消除二义性。
多继承,对象内存模型
A、B 是基类,C 是派生类,假设 obj_c 的起始地址是 0X1000,那么 obj_c 的内存分布如下图所示:
基类对象的排列顺序和继承时声明的顺序相同。
虚继承
为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员。
在继承方式前面加上 virtual 关键字就是虚继承:
1 | //间接基类A |
虚基类
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class)。
必须在虚派生的真实需求出现前就已经完成虚派生的操作。虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身。
在虚继承的最终派生类中只保留了一份虚基类的成员,所以该成员可以被直接访问,不会产生二义性。
不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。
虚继承,构造函数
最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。
1 | //虚基类A |
在最终派生类 D 的构造函数中,除了调用 B 和 C 的构造函数,还调用了 A 的构造函数,这说明 D 不但要负责初始化直接基类 B 和 C,还要负责初始化间接基类 A。而在以往的普通继承中,派生类的构造函数只负责初始化它的直接基类,再由直接基类的构造函数初始化间接基类,用户尝试调用间接基类的构造函数将导致错误。
C++ 规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。
虚继承时构造函数的执行顺序与普通继承时不同:在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序如何,编译器总是先调用虚基类的构造函数,再按照出现的顺序调用其他的构造函数;而对于普通继承,就是按照构造函数出现的顺序依次调用的。
虚继承,对象内存模型
对于普通继承,基类子对象始终位于派生类对象的前面:
A 是最顶层的基类,在派生类 B、C、D 的对象中,A 类子对象始终位于最前面,偏移量是固定的,为 0。b1、b2 是派生类 B 的新增成员变量,它们的偏移量也是固定的,分别为 8 和 12。c1、c2、d1、d2 也是同样的道理。
对于虚继承,恰恰和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量的后面,这样随着继承层级的增加,基类成员变量的偏移就会改变,就得通过其他方案来计算偏移量。
A 是 B 的虚基类,B 又是 C 的虚基类,那么各个对象的内存模型如下图所示:
虚继承时的派生类对象被分成了两部分:
- 不带阴影的一部分偏移量固定,不会随着继承层次的增加而改变,称为固定部分;
- 带有阴影的一部分是虚基类的子对象,偏移量会随着继承层次的增加而改变,称为共享部分。
不同的编译器设计了不同的方案来计算共享部分的偏移。
虚基类表
如果某个派生类有一个或多个虚基类,编译器就会在派生类对象中安插一个指针,指向虚基类表。虚基类表其实就是一个数组,数组中的元素存放的是各个虚基类的偏移。
A 是 B 的虚基类,同时 B 又是 C 的虚基类,那么各对象的内存模型如下图所示:
向上转型
类其实也是一种数据类型,也可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型(Upcasting)。相应地,将基类赋值给派生类称为向下转型(Downcasting)。
将派生类对象赋值给基类对象
赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。
这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。理由很简单,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值。
将派生类指针赋值给基类指针
将派生类指针赋值给基类指针(对象指针之间的赋值)。
多继承的例子,继承关系为:
1 | //基类A |
将派生类指针 pd 赋值给了基类指针 pa,调用 display() 函数时虽然使用了派生类的成员变量,但是 display() 函数本身却是基类的。
将派生类指针赋值给基类指针时,通过基类指针只能使用派生类的成员变量,但不能使用派生类的成员函数。