「C++ 基础」继承 & 多态 内存原理
继承
继承可以重用代码功能和提高执行效率的效果。
一个类可以派生自多个类。类派生列表以一个或多个基类命名,形式如下:
1 | class derived-class: access-specifier base-class |
未使用访问修饰符 access-specifier,则默认为 private。
一个派生类继承的基类方法不包括如下几种:
- 基类的构造函数、析构函数和拷贝构造函数。
- 基类的重载运算符。
- 基类的友元函数。
继承类型
通常使用 public
继承
- public 继承:基类 public 成员,protected 成员,private 成员的访问属性在派生类中均不变
- protected 继承:基类 public 成员的访问属性在派生类中变成 protected。其他两种访问属性不变。
- private 继承:基类 public 成员,protected 成员,private 成员的访问属性在派生类中均变成 private
多继承
即一个子类可以有多个父类,它继承了多个父类的特性。
1 | class <派生类名>:<继承方式1><基类名1>,<继承方式2><基类名2>,… |
例:
1 | // 基类 Shape |
虚函数
首先明确虚函数的目的:让不同的派生类将继承自父类的同一个虚成员函数(接口),根据派生类的功能需求进行不同行为的实现,以此达到不同的派生类提供调用层的决策代码同一个函数接口的不同实现版本,从而保持对调用层代码逻辑无需变动,而且隐藏了同一个函数接口的不同版本的实现细节。
在派生类中重新定义基类中定义的虚函数时,会告诉编译器不要静态链接到该函数。在程序中任意点可以根据所调用的对象类型来选择调用的函数,即动态链接。虚函数必须被实现。
1 | virtual ReturnType FunctionName(Parameter); |
尽管C++编译器允许父类和子类中定义相同名称的非虚成员函数,但这是一种不良的设计,因为一个适当的函数名标识了它要实现某个具体的功能。当我们在多个类中要为不同的类实现某个具体功能的不同版本,就应该使用虚函数。
虚表中存放的是虚函数的地址。
类的虚表会被这个类的所有对象所共享。类的对象的虚表指针都指向同一个虚表,从这个意义上说,我们可以把虚表简单理解为类的静态数据成员。值得注意的是,虽然虚表是共享的,但是虚表指针并不是,类的每一个对象有一个属于它自己的虚表指针。
虚指针也是在构造函数里面初始化的,因此构造函数不可能是虚函数,没有初始化的虚指针无法调用虚函数。
纯虚函数
如果在基类中不能对虚函数给出有意义的实现,就会用到纯虚函数。其声明为:
1 | virtual void funtion1()=0; |
在有动态分配堆上内存的时候,析构函数必须是虚函数,但没有必要是纯虚的。
友元不是成员函数,只有成员函数才可以是虚拟的,因此友元不能是虚拟函数。但可以通过让友元函数调用虚拟成员函数来解决友元的虚拟问题。
虚继承
多继承(环状继承),例如:
1 | class D{......}; |
这个菱形继承中,D 的成员变量和成员函数继承到类 A 中变成了两份,这样就可能会产生命名冲突。我们需要在冲突成员前指明它具体来自哪个类:B::chengyuan
。
为解决多继承时的命名冲突和冗余数据问题,c++提出了虚继承,使得在派生类中只保留一份间接基类的成员。
class 类名: virtual 继承方式 父类名
1 | class D{......}; |
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 D 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。
此外,如果虚基类的成员只被一条派生路径覆盖,那么仍然可以直接访问这个被覆盖的成员。但是如果该成员被两条或多条路径覆盖了,那就不能直接访问了,此时必须指明该成员属于哪个类。
假设 D 定义了一个名为 x 的成员变量,当我们在 A 中直接访问 x 时,会有三种可能性:
- 如果 B 和 C 中都没有 x 的定义,那么 x 将被解析为 D 的成员,此时不存在二义性。
- 如果 B 或 C 其中的一个类定义了 x,也不会有二义性,派生类的 x 比虚基类的 x 优先级更高。
- 如果 B 和 C 中都定义了 x,那么直接访问 x 将产生二义性问题。
可以看到,使用多继承经常会出现二义性问题,必须十分小心。也因此c++之后很多面向对象编程语言都不支持多继承。
C++标准库中的 iostream 类就是一个虚继承的实际应用案例。iostream 从 istream 和 ostream 直接继承而来,而 istream 和 ostream 又都继承自一个共同的名为 base_ios 的类,是典型的菱形继承。此时 istream 和 ostream 必须采用虚继承,否则将导致 iostream 类中保留两份 base_ios 类的成员。
内存布局
首先,每个使用虚函数的类或从基类派生的虚函数的类都被赋予自己的虚表。该表只是C++编译器在编译时设置的静态数组。虚表包含当前类中所有虚成员函数的函数指针的相关条目,那么填入虚表的虚成员函数指针有四种来源:
派生类本身原创定义的虚函数。
从父类继承的虚成员函数,且该函数未被派生类重写。
从父类继承的虚成员函数,但该函数已被派生类重写。需要注意的是,虚表的虚成员函数指针始终指向该类中的最新的派生版本的虚成员函数。也就是说重写后,派生类虚表中存的该函数的地址是重写后该函数在内存中的地址。
若当前类定义了虚析构函数,那么该类的虚析构函数的函数的地址会“成双成对”地填入虚表中。按照惯例,由于定义类时优先定义解构函数,再实现其他成员函数,因此该虚解构函数对的地址通常会出现在表中头两行。
为什么是两个函数?
- 第一个析构函数,称为**完整对象析构函数(complete object destructor)**,执行销毁操作时无需在对象上调用delete()。
- 第二个解构函数称删除析构函数( deleting destructor),在销毁对象后调用delete()。
- 两者都摧毁了任何虚拟基类。一个独立的非虚函数称为基类对象解构函数(base object destructor),执行对象的销毁操作,但不执行其虚拟基类子对象的销毁操作,并且不调用delete()。
然后,当类对象实例化时,会将*_vptr
设置为指向该类的虚表。
多态
编译时,多态性是通过类成员函数重写和operator函数重载实现的。
运行时,多态性是通过使用继承和虚函数实现的。C++编译器在运行时,根据决策逻辑判断传入对象的类型,然后查找并根据该类虚表中的虚成员函数的地址,进行动态调度目标类中的成员函数。
静态绑定 & 动态绑定
绑定:是指将变量和函数名转换为地址的过程。
静态绑定(前绑定):在程序执行之前,程序编译阶段就确定的绑定。
- 早期绑定意味着绑定的函数或者变量,该语句在编译阶段已经被编译成
call 函数地址
或callq 函数地址
这样的汇编指令格式,并且这些汇编指令中的函数地址在程序编译后是固定不变的。 - 优点是效率高、编译器会帮你检查。非虚函数、静态函数都属于静态绑定。
动态绑定(后绑定):是指在运行时才确定的函数调用。
- 在一些带有决策性的业务逻辑的代码中,要等到用户的反馈,直到运行时,根据决策的结果才能知道将调用哪个函数。这称为后期绑定(或动态绑定),动态绑定的技术的本源就是函数指针。在C ++中运行时多态正是使用的就是函数指针。
- 优点是不用申明类型,运行时方便修改。python 的动态语言特性(无需考虑变量类型)就是因为 Python 的解析器的底层就是用到了运行时的一系列类型检测和类型检测后的内存分配以及C的函数指针的间接调用等技术完成了对Python代码的解析和资源初始化,这一切是以低性能为代价的。
C++编译器仅当遇到如下条件才会做动态绑定:
- 通过类型指针,该指针是
upcast
操作的指针 - 该类型指针调用的是虚函数
体现在汇编中,比如 callq *%rdx
,就是调用寄存器中缓存的虚函数指针所指向的虚函数。而如果是静态绑定,那么就会是这样的汇编语句:callq 0x401384
Upcasting & downcasting
将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型(Upcasting
)。向上转型无需强制类型转换,但会丢失精度。
所谓向下转型(downcasting
),即父类对象转换为子类对象,需要类型转换。
强制类型转换:
1 | // false |
动态类型转换:
1 | destType* dstObj=dynamic_cast<destType*>(src) |
dynamic_cast
是运行时处理的,运行时要进行运行时类型检查。转换如果成功的话返回的是指向类的指针或引用,转换失败的话则会返回NULL。
如果要进行动态类型转换,基类中一定要有虚函数,因为运行时类型检查需要 “ 运行时类型信息(Runtime type information,RTTI
)”,而这个信息存储在类的虚函数表中,只有定义了虚函数的类才有虚函数表。某些语言实现仅保留有限的类型信息,例如 [ 继承树 ] 信息,而某些实现会保留较多信息,例如对象的属性及方法信息。这确实增加了开销。但是RTTI可以确保进行类型转换(包含隐式转换和动态类型转换)之类的操作可以安全地进行。
如果运行时src
和destType
所引用的对象是相同类型,或者存在is-a
关系(public继承)则转换成功;否则转换失败。
1 | class Base{ |
在类的转换时,在类层次间进行上行转换时,dynamic_cast
和static_cast
的效果是一样的。在进行下行转换时,dynamic_cast
具有类型检查的功能,比 static_cast
更安全。
内存原理
首先,我们知道对于我们的自定义类型,如果我们没有重载=
,是无法强制将一个类型对象类型转换并赋值给另一个类型的对象的。因为当我们尝试执行a=b
,那么其实质就是调用了对象 a 的operator=()
操作符函数,即等价于如下代码
1 | a.operator=(const B &b); |
而对于类实例的指针,我们是可以强制转换并赋值的。
1 |
|
若从大尺寸的类B强制类型转换类A,内存会将拷贝低地址位的内存数据,而丢弃高地址位的内存数据。若小尺寸到大尺寸转换,则源操作数的所有字节数据会按低地址到高地址的顺序依次拷贝到目标操作数,目标操作数超出源操作数尺寸的剩余高地址部分数据,编译器会以0填充。
继承链
继承动作的实质其实是:派生类通过继承得到类成员函数在内存中的地址。
父类公开或受保护的成员函数(包括虚函数)同样是被派生类继承,但继承的只是父类成员函数的调用权,在继承关系中,派生类从基类继承的成员函数实质上继承的是存储在代码段(Code Segment)内存区中,基类可共享的成员函数的内存地址,因为每个成员函数都有一个唯一的内存地址。
而所谓类型,其实就是规定这个类型的变量对哪部分内存拥有操作权限。
在继承中,派生类都从父类获得一份公开(public)或受保护(protected)的父类数据成员(属性)的副本,也就是说,每个派生类对象内部都持有一份“特殊版本”的父类实例的信息。所以父类类型指针,规定访问的是父类大小的内存区域,若我们将继承类实例化的对象赋给该指针,自然而然父类副本外的内存(即继承类的自定义部分)无法被父类指针访问。
因此,Upcast
操作仅仅是拷贝了派生类中的基类实例副本,派生类所属的内存区域对于基类对象是一无所知的。这也产生了一个问题 —— “**对象切片(Object Slicing
)**”,即当拷贝时,派生类原创的成员(属性和方法)会被编译器”阉割”掉。
在对象切片的作用下可能出现以下情况:
对于非虚成员函数来说,基类对象只能得到基类原创定义且可被继承的成员函数的地址,派生类原创定义的成员函数的地址,对于 upcast 操作后的基类对象是不可见的。
对于虚成员函数来说,如下三种情况。对于基类对象运行时绑定哪个虚成员函数的地址,是依据填入基类的虚表的函数地址来判断的。
- 若该函数是派生类原创定义的,对于upcast操作后的基类对象是不可见的。
- 若该函数是基类原创定义且未被派生类重写,对于 upcast 操作后的基类对象,该基类版本的虚函数可见。
- 若该函数是基类原创定义且已被派生类重写,对于 upcast 操作后的基类对象,该派生类版本的虚成员函数可见。
注:所谓内存访问限制,访存本身其实是没什么限制的,只是越界了会访问到无效数据而已(比如栈上的对象越界访问可能会访问到其他局部变量,堆上的对象越界访问可能会访问到堆块对齐的部分)编译器能做的只是从语法层面去限制生成越界的访存机器码,而不是防止访存的行为本身(即便编译器也能被骗过)