目录
继承
继承的概念及用法
继承的作用域
向上转型和向下转型
继承过程中的默认生成函数
菱形继承及其解决方案 - 虚继承
虚继承的原理 - 虚基类表
继承和组合
多态
虚函数
多态的定义及使用
纯虚函数与抽象类
多态的原理
小点补充
虚表的位置
父类指针new一个子类
静态多态和动态多态
概念继承和接口继承
重写、重载和重定义的区别
final和override关键字
继承
继承的概念及用法
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能。那么原来的类就被称为父类或基类,新继承的类就称为子类或派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
继承的定义格式很简单,就是在class类后面跟一个冒号(:),然后继承方式,然后继承哪个类(也就是基类)。其中,C++是支持多继承的,继承多个类时只需要用逗号隔开即可。示例如下:
// student以public的方式继承person(单继承)
class student : public person {……
}
// student以public的方式继承了person,以protected的方式继承了school(多继承)
class student : public person, protected school {……
}
那么这个继承方式又是干什么用的呢?这个继承方式是用于对父类继承过来的成员进行修饰限制的。继承方式有三种:public、protected、private,与基类中三种访问权限对应,可以将继承之后的结果大致概括为如下9种。
类成员 \ 继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可 见 |
一个比较便于记忆的理解方式是:继承方式决定了基类成员继承后的权限的上限,这里的权限高低顺序为:public > protected > private。举个例子来说,比如在基类种为为public的成员,如果是public继承,到了子类以后就是正常的public,但如果是private权限继承的,那么原来在基类中为public的成员到了子类中就变成了private权限的了。
需要注意的是,private继承和父类中的private成员在子类中的表现是不一样的。private继承说明从父类中继承过来的成员最高权限是private,也就是说从父类继承过来的成员在子类中都是private权限的,但子类还是可以把它看作自己的成员正常访问的(基类从private成员除外)。而如果在父类中就是private成员,那么在继承之后子类中确实是有基类中的private成员,它会正常占用空间,只不过子类不能对其访问罢了。
其它相关注意事项如下:
- 虽然继承方式不止一种,但绝大多数情况我们只用public继承。
- C++的继承是可以不指定继承方式的。当不指定继承方式时,class默认是private继承,而struct默认是public继承。
- 基类的友元关系不能被继承。
- static成员与继承。如果基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。与之对应的是,当继承的是一个普通变量时,父类和子类中的这个变量本质上是两个变量,而static变量本质上就是一个。特别的,不论是普通函数还是静态函数,继承之后父子类的函数都是同一个函数。
- 如果不显示地调用父类构造进行初始化,则会调用父类的默认构造。如果父类的默认构造无法被调用,那么就会报错。但规范的写法是定义子类构造时,紧跟着要在初始化列表的位置调用父类构造以初始化从父类继承过来的数据。
- 调用子类构造前,默认调用父类构造。调用子类析构后,必定调用父类析构。即子类和父类调用构造的顺序是,构造-先父后子,析构-先子后父。其中,先继承父类的先构造,后继承父类的先析构。
继承的作用域
在继承体系中,父类和子类各自有着有独立的作用域。父类是一个隐含的对象(类比)。所以普通继承(不是虚继承)的父类就相当于是额外引入的一个父类对象。子类引用对象时默认是子类的,如果引用父类需要用父类类域限定符指定。
其中,如果父类和子类中存在同名成员,那么子类成员将屏蔽掉父类对同名成员。在这种情况下,需要显示指定父类作用域才能访问到父类的成员,否则默认访问的就是子类的成员。这种情况叫做成员的重定义,也叫隐藏。示例如下:
class base
{
public:int data = 100;
};class derive : public base
{
public:int derive_data;int data = 81;
};void test()
{derive dd;//默认访问子类数据cout << dd.data << endl; // 输出:81// 显示指定访问父类的数据cout << dd.base::data << endl; // 输出:100
}
虽然C++允许我们在父类和子类中定义同名成员,但一般情况下,还是不要定义同名成员。
至于类的作用域,如果使用的是VS编译器,可以使用VS的命令提示功能,用如下命令查看一个类的内存布局
c1 [filename].cpp /d1reportSingleClassLayout[className]
其中,[filename]代指的是源文件名,[className]代指的是类名。例如,假设有如下这些定义
class base1
{
public:int base1_data;
};class base2
{
public:int base2_data;
};class base3
{
public:int base3_data;
};class derive : public base1, protected base2, private base3
{int derive_data;
};
那么输入对应的
c1 main_fun.cpp /d1reportSingleClassLayoutderive
之后,就会打印出对应的内存布局:
那么,这张图就能很好的帮助我们理解继承过程中作用域的问题。
向上转型和向下转型
向上转型和向下转型是指,的有继承关系的父类和子类之间的转换。具体概念解释如下:
- 向上转型:本质是子类转换为父类,可以自动转型。比如父类引用指向子类对象。
- 向下转型:本质是父类转化为子类,只能手动转型。比如子类引用指向父类对象。
其中,上行转换是安全的。而下行转换,没有动态类型检查,是不安全的。所以一般情况下,不推荐使用向下转型。
向上转型之所以是安全的,是因为向上转型是将子类转型为父类,而子类本来就是由父类继承而来,在父类的基础上增加一些内容。所以父类有的东西,子类都会有。而且子类的内存分布是父类在上,子类在下。所以向上转型时,只需要截取父类部分的内容即可。
派生类和基类直接如果套了强转,会涉及中间变量的类型转换,就无法直接切片了。
而向下转型则充满了危险和不确定性,因为父类根本不可能知道子类会在它的基础上增加哪些东西,所以本质上讲,将一个父类直接转换成子类是不可能的。但我们可以将一个子类引用或指针向上转型之后形成的父类引用或指针,通过dynam_cast或static_cast函数显示地将这个父类引用或指针再转换为对应的子类的引用或指针。而且转换之后调用的虚函数依旧是子类的。
其中,static_cast是一种比较笨的转型,不会进行动态类型检查,只是暴力的进行转换,所以推荐使用dynamic_cast进行转换。至于C++类型转换的内容可以参考:C++ 类型转换_CSDN
继承过程中的默认生成函数
我们知道,如果我们不写,C++的类会默认为我们生成6个默认的成员函数。那么在继承中,这6个默认成员函数是不会被继承的。如果子类中没有创建这些函数,编译器会自动生成它们。其中,我们一般不会重载取地址符,所以就不进行讨论了。
要点概述如下:
- 子类的构造函数在定义时,最好通过父类的构造函数初始化父类的那一部分成员。如果父类的默认构造无法被调用,那么就必须在子类构造的初始化列表位置调用父类构造。
- 子类的拷贝构造最好通过调用父类的拷贝构造完成父类部分成员的拷贝初始化。
- 子类的operator=最好调用父类的operator=去赋值父类的那部分成员。
- 子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员。所以在定义时就不用显示的调用父类析构了。
以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的所属关系。比如父子之间就是上下级关系,而汽车和轮胎之间就是所属关系。
继承和组合的区别:
- 继承允许我们根据父类的实现来定义子类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语 “白箱” 是相对可视性而言:在继承方式中,父类的内部细节对子类可见 。继承一定程度破坏了父类的封装,父类的改变,对子类有很大的影响。子类和父类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系,耦合度低。 优先使用对象组合有助于我们保持每个类被封装。
多态
虚函数
要学习多态,首先就要先认识虚函数。虚函数的定义很简单,就是在函数声明前加一个virtual声明就表示当前的函数是一个虚函数了。例如:
class base
{
public:virtual void fun() {……}
};
其中,虚函数可以类内声明,类外定义。但在类外定义时,不能再用virtual修饰了。而虚函数相对普通函数要慢一些,因为多了到虚表中找的过程,至于什么是虚函数表,后面会讲到的。
需要注意的是,在C++中,有三类函数不能被设为虚函数:静态函数、内联函数、构造函数。如下是相关解释。
C++下仅非静态成员函数可以是虚拟的,静态函数可以被正常继承,但不能被声明为虚函数。这是因为静态成员在类的所有实例之间(静态地)是共享的,而虚函数则公开动态行为,并根据对象类型允许不同的执行。因此,拥有静态虚函数是没有意义的。而且,静态成员函数没有this指针,也就无法访问虚函数表等内容,自然也就无法定义为虚函数。
而内联函数根本就没有地址,从根本上就断绝了他与虚函数的关系。
构造函数不能是虚函数,是因为虚表指针是在构造函数阶段(初始化列表的位置)初始化的,而虚函数的执行有时依赖于虚函数表的。也就是说,在构造对象期间,虚函数表还没有被初始化,那么自然构造函数就不能被设为虚函数了。
多态的定义及使用
通俗来说,多态就是一个接口多种形态。具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。以买票为例,当普通人买票时,是全价买票,学生买票时,是有优惠的学生票。这种同一个窗口根据不同的人出不同的票,就是一种典型的一种接口多种形态的例子。
多态的构成要满足三个条件,即多态的三要素:
- 有继承关系
- 有虚函数的重写
- 父类指针或引用指向子类实例
一个多态的写法示例如下:
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完成了虚函数的重写。需要注意的是,虚函数重写时还要考虑协变的情况,协变的概念定义如下:
C++中,协变(covariant)是指派生类(子类)中的返回类型可以是基类(父类)中返回类型的子类型。换句话说,如果一个虚函数在基类中返回的是基类类型的指针或引用,那么派生类可以重写该虚函数并返回基类类型的子类类型的指针或引用。
协变在C++中是通过使用返回类型协变(return type covariance)来实现的。返回类型协变是指派生类中重写的虚函数可以具有比基类更具体的返回类型。
这种协变的能力使得在使用多态时更加灵活,可以根据具体的派生类返回不同的子类型,而不需要进行显式的类型转换。
—— 内容摘自:C++协变(covariant)-CSDN博客
特别的,编译器会对析构函数名进行特殊处理,处理成诸如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;
};
存在纯虚函数的类就叫做抽象类,抽象类是无法实体化出对象的,只能定义指针或引用(顾名思义,就是一个抽象的对象,没有实体)。而且,继承抽象类的子类默认也是一个抽象类,只有子类重写了虚函数时才不是抽象类。举个例子来说,水果就是一种抽象类,它没有具体的实体,而其子类,比如苹果、香蕉、西红柿等就有具体的实体。
注意事项:
- 包括析构函数在内,只要能被定义为虚函数的函数,就能被定义为纯虚函数。
- C++中没有接口的概念,但是可以通过纯虚函数实现接口。即让抽象类中只有函数的声明,没有定义,这与就可以把它看作一个接口类来用了。
多态的原理
多态的一个核心内容就是虚函数,那么与虚继承类似,多态的原理也与虚表有关,不过虚基类表和虚函数表完全不是一个概念,只是名字相近,本质上没有关系。
多态的原理也是需要引入一个虚函数指针(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用于修饰派生类的虚函数,检查是否完成重写