而向下转型则充满了危险和不确定性,因为父类根本不可能知道子类会在它的基础上增加哪些东西,所以本质上讲,将一个父类直接转换成子类是不可能的。但我们可以将一个子类引用或指针向上转型之后形成的父类引用或指针,通过dynam_cast或static_cast函数显示地将这个父类引用或指针再转换为对应的子类的引用或指针。而且转换之后调用的虚函数依旧是子类的。
其中,static_cast是一种比较笨的转型,不会进行动态类型检查,只是暴力的进行转换,所以推荐使用dynamic_cast进行转换。至于C++类型转换的内容可以参考:C++ 类型转换_CSDN
继承过程中的默认生成函数
我们知道,如果我们不写,C++的类会默认为我们生成6个默认的成员函数。那么在继承中,这6个默认成员函数是不会被继承的。如果子类中没有创建这些函数,编译器会自动生成它们。其中,我们一般不会重载取地址符,所以就不进行讨论了。
要点概述如下:
以operator=为例,父类和子类的operator=写法示例如下:
class base
{
public:
int data = 100;
base& operator=(const base& other)
{
if (&other != this)
{
this->data = other.data;
}
return *this;
}
};
class derive : public base
{
public:
int val = 200;
derive& operator=(const derive& other)
{
if (&other != this)
{
// 显示调用父类的operator=,赋值父类区域的部分
// 因为向上转型是简单的内容截断
// 所以是安全的,可以直接传子类
base::operator=(other);
this->val = other.val;
}
}
};
菱形继承及其解决方案 - 虚继承
前面我们知道,C++是支持多继承的,那么就必然就会出现一种菱形继承的情况。
如上图所示,如果一个子类同时继承了两个父类,而这两个父类又可以追溯到同一个类,那么就会造成菱形继承的问题。这里的追溯是指的并不一定要直接的C继承A,而D继承C,也可以是C继承另一个类E,然后E继承的是A。也就是说,只要D的两个父类的来源有相交的部分,就会造成菱形继承的问题。
那么说了这么多,菱形继承的问题是什么呢?菱形继承使得一个类中存在两份相同的数据。以上图为例,类D同时继承了B和C,而B和C又同时继承了类A,那么D中就会存在两份类A的数据。比如以如下代码为例:
class A {
int dataA;
};
class B : public A {
int dataB;
};
class C : public A {
int dataC;
};
class DD : public B, public C {
int dataD;
};
那么对应的内存布局图就为
所以如果我们以如下形式访问DD对象的dataA时,就会造成dataA不明确的问题。所以一个解决方案就是显示指明是哪个父类的dataA,示例如下
DD dd;
//dd.dataA; // error,不明确
dd.B::dataA; // 显示指明访问B中的dataA
dd.C::dataA; // 显示指明访问C中的dataA
但是这样做很麻烦,而且二义性很严重,所以C++又引入了另一种解决方案——虚继承。虚继承使得菱形继承情况下的基类中只存在一份“祖宗”类的数据,不会出现存在两份数据相冲突的情况了。
虚继承的写法也很简单,就是一个virtual关键字。在继承时只要在基类前加一个virtual声明,就表示这是一个虚继承了。写法示例如下:
class person : virtual public something {
……
};
class somebody : public virtual something {
……
};
虚继承的原理 - 虚基类表
那么虚基类表的原理又是什么呢?这里我们先说结论,再看验证。
虚继承的实现,是基于在继承时,将被virtual修饰的虚继承的基类,在派生类中映射为一个虚基类,而不是一个正常的类。其中,虚基类一般放在派生类内容的最后部分,并额外引入了一个虚基类指针(vbptr),指向一张虚基表,这张表中存放的就是派生内中每一个虚基类相对于当前的虚基类指针的偏移量,这样就能很快的找到对应的虚基类的内容了。也就是说,其实我们在访问虚基类成员的时候,编译器是通过偏移量的方式来访问的。而这张虚基类表在对象刚初始化(构造函数)的时候就被创建出来了。
而这个虚基类指针(vbptr)是编译器为我们默默生成的一个类内成员,在虚继承发生后一般放在派生类的首位置(不存在虚函数的情况下),所以这个vbptr我们一般是看不见的。除了它对用户不可见以外,它与其它的指针成员一样,同样占用内存空间。其中,虚表是存放在虚拟内存的.rodata段(只读段)的。
在继承时,基类中的虚基类内容和虚基类指针也会被一起继承,也是把虚基类的内容放在最后,并根据当前派生类的实际情况对重新生成一张虚基类表,让派生类中的虚基类指针指向这个新的虚基类表。而在多继承的情况下,如果有不止一个的基类中存在虚基类,那么每个基类的虚基类指针都会被拷贝,同时也会创建多份虚基表,但虚基类的内容只会拷贝一份。可以理解为,在继承时会记录每一个虚基类,如果在继承时发现当前的虚基类已经被继承过了,就不会再拷贝第二份了。
例如,有如下这些类的定义:
class A {
int val_A;
};
class B : virtual public A {
int val_B;
};
class C : virtual public A {
int val_C;
};
class Derive : public B, public C {
int val_D;
};
Derive类对应的内存布局示意图如下所示:
可以看到,继承之后,派生类中的每一个基类都有单独的作用域。B中和C中共有两个虚基类指针,它们分别指向两张虚表,虚表中的第一个参数为当前虚指针在当前基类作用域中的偏移量,之后的参数就是虚基类相对当前虚指针的偏移量。可以看到,通过偏移量计算之后,B的虚表和C的虚表中的第二个参数的偏移量都是相对virtual base A的偏移量,所以也就验证了虚继承确实可以解决基类中变量名冲突问题。
需要注意的是,如果类B或者类C不是直接继承的类A,比如C是继承的类E,而类E又继承的是类A。那么我们该让E虚继承还是C虚继承比较好呢?答案是让E虚继承A比较好,因为E虚继承之后的虚基类就是类A,使得最后的Derive相对简洁。而如果是让C虚继承E,那么在C中就会形成E的虚基类内容,首先E一定不会比A小,而且最关键的是,在多继承时虚基类A与虚基类E并不会合并,所以如果是C虚继承的E,实际上根本就没有解决菱形继承的命名冲突问题。
注意,上述的原理是在Windows的32位环境下测试的,不同的环境也许会有不同的结果,但基本原理的大同小异的。
继承和组合
继承我们刚刚谈过,但组合可能大家会比较陌生。组合不是继承,没有基类和派生类之分,比如如果要让B继承A,组合的做法就是直接在B中定义一个A的对象。
可以说,继承是一种is-a的上下级关系,而组合是一种have-a的所属关系。比如父子之间就是上下级关系,而汽车和轮胎之间就是所属关系。
继承和组合的区别:
多态
虚函数
要学习多态,首先就要先认识虚函数。虚函数的定义很简单,就是在函数声明前加一个virtual声明就表示当前的函数是一个虚函数了。例如:
class base
{
public:
virtual void fun() {
……
}
};
其中,虚函数可以类内声明,类外定义。但在类外定义时,不能再用virtual修饰了。而虚函数相对普通函数要慢一些,因为多了到虚表中找的过程,至于什么是虚函数表,后面会讲到的。
需要注意的是,在C++中,有三类函数不能被设为虚函数:静态函数、内联函数、构造函数。如下是相关解释。
多态的定义及使用
通俗来说,多态就是一个接口多种形态。具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。以买票为例,当普通人买票时,是全价买票,学生买票时,是有优惠的学生票。这种同一个窗口根据不同的人出不同的票,就是一种典型的一种接口多种形态的例子。
多态的构成要满足三个条件,即多态的三要素:
一个多态的写法示例如下:
class calculate
{
public:
virtual void result(double lnum, double rnum)
{
cout << "noting to do!" << endl;
}
};
class cal_add : public calculate
{
public:
virtual void result(double l, double r)
{
cout << l + r << endl;
}
};
void test()
{
calculate* cal = new cal_add;
cal->result(168, 88); // 输出结果:256
}
刚才我们认识了虚函数,那么什么叫做虚函数的重写呢?虚函数重写又叫覆盖,表示除了协变的情况,父子类中的虚函数的函数名、返回值类型以及参数要完全一致(参数名可以不一样)。
子类cal_add就对父类calculate的result完成了虚函数的重写。需要注意的是,虚函数重写时还要考虑协变的情况,协变的概念定义如下:
特别的,编译器会对析构函数名进行特殊处理,处理成诸如destrutor()的统一函数,所以父类析构函数不加 virtual 的情况下,子类析构函数和父类析构函数构成重定义(隐藏)关系。这也就是为什么父子类的析构函数名不同但却可以构成重载的原因。
那么为什么要用父类指针或引用指向子类对象呢,父类实体不可以吗?这与多态的实现原理即虚函数表有关。而引用之所以可以是因为引用的底层本质就是指针,所以本质就是父类指针指向子类对象的实体。
需要注意的是,当delete一个父类指针时,只有父子类的析构构成多态,才能正确调用子类的析构。这是因为,这是一个父类的指针,调用的自然是父类析构,所以如果父类不是虚析构,那么delete时就是调用的父类析构。所以,一般情况下父类的析构函数需要定义为虚函数。
还有一点,多态在调用时,如果父类和子类的函数缺省值冲突了,那么是以父类的为准的。可以理解为函数体是子类的,但虚函数的声明部分还是用的父类部分的,例如:
class calculate
{
public:
virtual void result(double lnum = 280, double rnum = 130)
{
cout << "noting to do!" << endl;
}
};
class cal_add : public calculate
{
public:
virtual void result(double lnum = 150, double rnum = 250)
{
cout << lnum + rnum << endl;
}
};
void test()
{
calculate* cal = new cal_add;
cal->result();
}
那么,运行上述的test函数的输出结果就是:410
纯虚函数与抽象类
当在一个虚函数的声明后面加上一个=0就表示这是一个纯虚函数,例如:
class calculate {
public:
virtual void result(double lnum = 280, double rnum = 130) = 0;
};
存在纯虚函数的类就叫做抽象类,抽象类是无法实体化出对象的,只能定义指针或引用(顾名思义,就是一个抽象的对象,没有实体)。而且,继承抽象类的子类默认也是一个抽象类,只有子类重写了虚函数时才不是抽象类。举个例子来说,水果就是一种抽象类,它没有具体的实体,而其子类,比如苹果、香蕉、西红柿等就有具体的实体。
注意事项:
多态的原理
多态的一个核心内容就是虚函数,那么与虚继承类似,多态的原理也与虚表有关,不过虚基类表和虚函数表完全不是一个概念,只是名字相近,本质上没有关系。
多态的原理也是需要引入一个虚函数指针(vfptr)和虚函数表(vftable),虚函数指针指向这个虚函数表,虚函数指针也是同样占据内存空间的。其中,虚函数指针放在派生类的开头,如果同时也存在虚基类指针的话,那么虚基类指针就要放在第二个。
虚函数表中存放的内容是每个虚函数的地址(即入口),在发生继承时,会进行虚函数表内容的覆盖,如果某个虚函数发生了重写,就会将虚函数表中将对应的函数入口内容覆盖掉。所以虚函数的重写又叫做覆盖,这里的覆盖就是指的虚函数表内容的覆盖。
而且,在继承多个有虚函数的类时,对应有几个类中有虚函数,就会有几个虚表,也就会有几个虚函数指针。而自己单独的虚函数,默认放第一个虚表的后面。
例如有如下的类的定义:
class person {
virtual void vfunc_1(){
……
}
virtual void vfunc_2() {
……
}
virtual void vfunc_3() {
……
}
};
那么person类的内存与虚函数表示意图如下:
其中,和虚基类表一样,虚函数表也是在对象初始化时,即构造函数的部分创建的。虚函数和普通的函数一样,都是放在虚拟内存的代码段的,而虚表是存放在虚拟内存的.rodata段(只读段)的。
所以,其实父子类的赋值兼容一部分原因是为了兼容多态,因为父类指针需要拿到子类的虚表。
多态要用指针或者引用而不能用普通对象的原因是,因为普通的对象会涉及中间临时变量的拷贝,使得子类的虚表内容被覆盖,所以最后其实还是调的父类的函数,没有调用子类的虚函数,所以并不能形成多态。
小点补充
虚表的位置
虚表(虚函数表和虚基表)是存放在虚拟内存空间的.rodata段(只读段)的,而虚函数和普通的函数一样,都是存放在代码段的。
内容参考:C++类的虚函数表和虚函数在内存中的位置-CSDN博客
父类指针new一个子类
以父类指针new一个子类的方式创建的对象,类型还是父类的,但内容是子类的。
父类new一个子类和子类直接创建一个对象的区别:
静态多态和动态多态
静态绑定(静态多态),又称前期绑定(早绑定),是指在编译期间就已经确定了程序的行为,例如函数重载、运算符重载等就属于静态多态。
动态绑定(动态多态),又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为。而我们广义上,一般口中说的多态就是指的这个动态多态。
概念继承和接口继承
概念继承(普通的继承),是一种实现继承。比如子类继承了一个函数,继承的就是函数的实现。
而虚函数的继承就是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
重写、重载和重定义的区别
final和override关键字
final修饰类时,表示此类不能再被继承。final修饰虚函数(必须是虚函数)时,表示不能被重写
override用于修饰派生类的虚函数,检查是否完成重写