4.3.1 为什么不能是派生类的指针或者引用?
答:因为只有基类的指针和引用才能做到既可以指向基类对象也可以指向派生类对象。而一个派生类的指针或者引用,只能指向派生类对象,不能指向基类对象。
4.3.2 为什么不能是父类的对象呢?
答:因为如果是一个父类对象,假定为 A,那么将一个派生类对象赋值给父类对象 A 时,会发生切片,会用该派生类中父类的那部分成员变量的值去初始化该父类对象 A,但是并不会把该派生类对象中的虚表拷贝给父类对象,所以不管是将基类对象赋值给基类对象 A,还是将一个派生类对象赋值给基类对象 A,该基类对象 A 中的虚表永远都是基类自己的,去调用的始终是基类自己的虚函数,无法做到传基类调用基类的虚函数,传派生类调用派生类的虚函数,多态就无法实现。而父类的指针和引用之所以能够实现,是因为父类对象的指针和引用指向一个父类对象当然是没问题的,指向派生类对象时,会发生形式上的切片,即这种切片并不是真的切片,假设这里有一个基类的指针 p,此时它指向一个派生类对象,这里的切片本质上是限定了 p 指针的“视野范围”,即 p 指针只能“看到”该派生类对象中继承自父类的那部分成员,并没有像前面那样去实实在在的重新创建一个基类对象。而且根据 4.2 小节那张监视窗口的截图我们可以发现,派生类的虚表本质上是作为父类成员的一部分继承下来的,但是会对该虚表中的内容稍作修改(具体如何修改请看 4.2 小节),使之成为派生类自己的虚表,所以 p 指针指向一个派生类对象的时候,就能去根据派生类的虚表去调用派生类自己的虚函数。这样才能满足多态的要求。
class Person { public: virtual void func1() const { cout << "virtual void Person::fun1()" << endl; } virtual void func2() const { cout << "virtual void Person::fun2()" << endl; } virtual void func3() const { cout << "virtual void Person::fun3()" << endl; } //protected: int _a = 1; }; class Student : public Person { public: virtual void func1() const { cout << "virtual void Student::fun1()" << endl; } virtual void func3() const { cout << "virtual void Student::fun3()" << endl; } virtual void func4() const { cout << "virtual void Student::fun4()" << endl; } //protected: int _b = 2; }; int main() { Person Mike; Student Jack; Jack._a = 9; Mike = Jack; }
小Tips:这里如果把虚函数表也拷贝过去那就乱套了,如果真拷贝过去了,那当一个基类的指针(Person*)指向 Mike 时,去调用的就是派生类的虚函数,而且有一些虚函数是派生类自己的,那这也太离谱了吧,一顿操作下来,一个基类的指针竟然能去调用一个自己这个类里面没有的函数。太离谱了,太离谱了,千万不能这样搞。这里总结一下:就是想告诉大家,将一个派生类对象赋值给基类对象的过程中,会涉及到切片,但是不会把虚表拷贝过去的。
4.3.3 派生类中为什么要对父类的虚函数进行重写?
答:派生类中的虚表本质上是继承自父类的,会先把父类的虚表拷贝一份,如果对父类的虚函数进行重写了,那么就会对拷贝的虚表进行修改,存派生类重写的虚函数地址。如果派生类没有对基类的虚函数进行重写,那么派生类的虚表中存的就是从基类虚表中拷贝过来的基类虚函数的地址,这就失去了多态原本的目的,是没有意义的。
4.4 动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
- 4.3 小节中汇编代码的截图就很好的展示了什么是静态(编译器)绑定和动态(运行时)绑定。
五、多继承关系的虚函数表
5.1 普通的多继承
上面我们都是在单继承体系中去探究虚函数表的,那多继承关系中的虚函数表是怎么样的呢?下面我们就来一探究竟。根据前面的经验,监视窗口展示给我们的内容已经不能再相信了,所以这里我们直接通过程序去打印内存空间中虚表里面的虚函数地址。
class Base1 { public: virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1 = 0; }; class Base2 { public: virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } private: int b2 = 2; }; class Derive : public Base1, public Base2 { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1 = 3; }; typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << "虚表地址:" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf("vTable[%d]:0X%p--------->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { Derive d; VFPTR* vTableb1 = (VFPTR*)(*(int*)&d); PrintVTable(vTableb1); VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1))); PrintVTable(vTableb2); return 0; }
小Tips:通过打印结果可以发现,对于多继承的派生类 Derive 来说,它的对象里面会有两张虚表,因为它继承了两个类,这两个类中一张继承自 Base1,另一张继承自 Base2,派生类自己的虚函数地址会存放在继承的第一个基类的虚表中。此外还有一个值得注意的地方:两个基类中都有 func1 函数,并且它们的返回值类型,函数名、参数都完全相同,派生类中对这个 func1 函数进行了重写,原本继承下来的虚表中存的都是他们自己内部 func1 函数的地址,派生类进行重写后,两张虚表中 func1 函数的地址就应该被覆盖成派生类中 func1 函数的地址,但是通过打印结果可以看出两张虚表中存的 func1 函数的地址并不相同,但是最终调用的却是同一个函数,都去调用了派生类中重写的 func1 函数,这是为什么呢?通过下面这段代码的反汇编来给大家解释原因。
int main() { Derive d; Base1* p1 = &d; p1->func1(); Base2* p2 = &d; p2->func1(); }
小Tips:通过反汇编我们可以看出,p1 是直接去调用的,p2 则进行了多层封装。p2 调用进行多层封装的主要目的就是为了执行 sub ecx , 8
,这里的 ecx
是一个寄存器,它存的是 this
指针的值,那为什么要对它减 8 呢?我们先来看看在减 8 之前 ecx
中存的是什么值。
小Tips:我们可以发现 ecx
本来存的是 p2
指针的值,那为什么要对这个值减 8 呢?因为 p2 本来是一个基类的指针,而 fucn1 函数中的隐藏形参 this 是一个派生类的指针。一个基类指针是不能赋值给派生类的指针,换句话说就是一个派生类的指针不能指向一个基类对象,原因是指针的类型决定了该指针可以访问到的内容,一个派生类指针应该可以访问到派生类中的所有成员,而当一个派生类指针指向一个基类对象的时候,由于基类对象中不可能有派生类中的成员,所以派生类指针再去访问这些成员的时候就会出错。这里的 8 本质上是一个 Base1 类对象的大小,所以这里减 8 的目的就是为了让 p2 中存 d 对象的首地址,这样 p2 就相当于指向了一个派生类(Derive)对象,此时再去调用 func1 函数就没有什么问题啦。所以总结一下,Derive 中只重写了一份 func1 函数,这里 sub ecx , 8
的目的就是为了修正 this
指针。p1 不用修正的原因是 p1 中原本存的就是 d 对象的首地址,去调用 func1 是没有任何问题的。其次补充一点,这里的 p1 和 p2 去调用 func1 函数都属于多态调用。(上面这种是 VS 下的解决办法,其他编译器的处理方法可能会有所不同)。
5.2 菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承和菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定的性能损耗。所以菱形继承、菱形虚拟继承的虚表我们就不需要研究的很清楚,因为始终很少使用。若果对这方面感兴趣的小伙伴这里我给大家推荐两篇文章:C++虚函数表解析、C++对象的内存布局
5.2.1 普通菱形继承
class A { public: virtual void func1() { cout << "A::func1()" << endl; } virtual void func2() { cout << "A::func2()" << endl; } protected: int _a = 1; }; class B : public A { public: virtual void func1() { cout << "B::func1()" << endl; } virtual void func3() { cout << "B::func3()" << endl; } protected: int _b = 2; }; class C : public A { public: virtual void func1() { cout << "C::func1()" << endl; } virtual void fun4() { cout << "C::func4()" << endl; } protected: int _c = 3; }; class D : public B, public C { public: virtual void func1() { cout << "D::func1()" << endl; } virtual void func3() { cout << "D::func3()" << endl; } virtual void fun5() { cout << "D::func5()" << endl; } protected: int _d = 4; }; typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << "虚表地址:" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf("vTable[%d]:0X%p--------->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { D d; B* p1 = &d; PrintVTable((VFPTR*)*(int*)p1); C* p2 = &d; PrintVTable((VFPTR*)*(int*)p2); }
小Tips:普通菱形继承的虚表和多继承是如出一辙的没有什么区别。
5.2.2 菱型虚拟继承
class A { public: virtual void func1() { cout << "A::func1()" << endl; } virtual void func2() { cout << "A::func2()" << endl; } //protected: int _a = 1; }; class B : virtual public A { public: virtual void func1() { cout << "B::func1()" << endl; } virtual void func3() { cout << "B::func3()" << endl; } //protected: int _b = 2; }; class C : virtual public A { public: virtual void func1() { cout << "C::func1()" << endl; } virtual void fun4() { cout << "C::func4()" << endl; } //protected: int _c = 3; }; class D : public B, public C { public: virtual void func1() { cout << "D::func1()" << endl; } virtual void func3() { cout << "D::func3()" << endl; } virtual void fun5() { cout << "D::func5()" << endl; } //protected: int _d = 4; }; typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << "虚表地址:" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf("vTable[%d]:0X%p--------->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } void Test() { D d; B* p1 = &d; PrintVTable((VFPTR*)*(int*)p1); C* p2 = &d; PrintVTable((VFPTR*)*(int*)p2); A* p3 = &d; PrintVTable((VFPTR*)*(int*)p3); } int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; Test(); }
小Tips:从打印结果和上图可以看出,在菱形虚拟继承体系中 A 类中的成员被独立出来了,不再是 B 类和 C 类中各存一份了,因此在 B类、C类、D类对象中各自有一份 A 类的虚函数表。这里有一个问题,就是如果 B 类和 C 类中同时重写了 A 类中的虚函数,那么 D 类中一定也要重写这个虚函数,如上面代码中的 func1 函数,因为如果 D 类中不进行重写的话,那 D 类对象中到底存 B 类中重写的那个还是存 C 类中重写的那个呢,此时就会产生歧义,只要 D 类中也对这个虚函数进行重写,就不会产生歧义了。其次,D 类中并没有自己的虚表,即对 D 类自己的虚函数来说,编译器会把这个函数的地址存入 D 类继承的第一个类的虚表中,这里就是存入 B 类虚表中。
补充:上一篇文章中提到,虚基表中存的是偏移量,目的是为了找到被分离出去的基类成员,这里也就是 A 类成员,但是当时通过内存窗口看到,这个偏移量存在虚基表的第二个字节中,那虚基表的第一个字节存的是什么呢?答案是:存的也是偏移量,存这个偏移量的目的是为了找到该类在内存中的首地址,还是以上面的代码为例,因为一个类如果有虚表,那么虚表地址都是被存储在这个类对象的最开始位置,虚基表中第一个字节存储的偏移量是用来找到该类对象在派生类对象中的首地址,虚基表中第二个字节存储的偏移量是用来找到基类(A类,爷爷类)的首地址。
六、抽象类
6.1 概念
在虚函数的后面写上 =0
,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
//抽象类(接口) class Car { public: virtual void Drive() const = 0; }; class Benz : public Car { public: virtual void Drive() const { cout << "Benz-舒适" << endl; } }; class Bmw : public Car { public: virtual void Drive() const { cout << "Bmw-操控" << endl; } }; void Advantages(const Car& car) { car.Drive(); } int main() { Benz be; Advantages(be); Bmw bm; Advantages(bm); }
6.2 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类的函数,可以使用函数,继承的函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
七、多态常见面试题
//下面这段代码的运行结果是什么? class A { public: virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; } virtual void test() { func(); } }; class B : public A { public: void func(int val = 0) { std::cout << "B->" << val << std::endl; } }; int main() { B* p = new B; p->test(); return 0; }
分析:这道题要想做对,我们必须了解以下几点。首先就是我们对继承的理解,B 类继承了 A 类,因此一个 B 类的指针 p 去调用 test 是没有问题的,B 类中把 A 类的 test 函数给继承了下来。在 test 函数中又去调用了 func 函数,这个 func 函数本质上是又 this 指针去调用的,而 func 函数是一个虚函数,并且子类对其进行了重写,那这里调用 func 函数是否是多态调用呢?是不是多态调用取决于这里调用 func 函数的是谁,前面说过这里的 func 本质上是 this 指针去调用,那这里的 this 指针究竟是什么类型呢?如果是基类(A类型),那么这里就符合多态调用,如果是派生了(B类型),那就不符合多态调用。所以这里的 this 究竟是什么类型呢?这就要考察大家对继承的理解了。先说答案,这里的 this 指针是 A* 类型。可能会有很多朋友觉得,B 类继承了 A 类,那么就要在 B 类中就会重新生成一份 test 函数,然后这里的 p 指针就去调用 B 类中字节生成的 test 函数,所以这里的 this 指针因该是 B* 类型,但事实并非如此,编译器并不会这样做。继承中派生类对象模型是按照下面的方式来生成的,对于成员变量来说,创建一个派生类(这里就是B类)对象,它分为两个部分,第一部分是父类,第二部分是自己,他会把继承自父类中的那些成员变量凑在一起当成一个父类对象,然后又把这个对象当成是派生类的一个成员变量,因此在派生类构造函数的初始化列表中要去调用父类的构造函数,在派生类的析构函数中要去调用父类的析构。这就是一个派生类对象在内存中的存储模型,对象的存储模型只和成员变量有关,和成员函数无关。所有编译好的函数都是放在代码段的,由于派生类 B 中并没有对 test 函数进行重写,所以 test 函数的代码并不会生成两份,从始至终这个 test 函数就只有一份,即基类 A 生成的,所以这里的 test 函数中的 this 指针是 A*。p 指针在调用 test 函数的时候,先进行语法检查,先在派生类 B 中去找 test 函数,没找到接着去父类 A 中去找,最后找到了,语法上没有任何问题,然后在链接阶段,这个 test 函数是父类的,编译器就拿着这个经过函数名修饰规则修饰产生的名字去找这个函数。前面说了这么多,就是想告诉大家这里的 this 指针是 A*,所以这里满足多态调用。这就意味着不同类型的对象去调用 test 函数会产生不同的效果,基类对象去调用 test 函数最终会去调用基类中的 func 函数,派生类对象去调用 test 函数最终会去调用派生类中的 func 函数。而这里是一个派生类的指针 p 去调用 test 函数,所以最终调用的是派生类中的 func 函数,此时就会有小伙伴产生疑问了,派生类中 func 函数的形参 val 的缺省值明明是 0 呀,为什么打印出来的是1?1 不是父类中 func 函数形参的缺省值嘛。这就涉及到本题的第二个“坑点”了:虚函数重写,重写的是实现(只重写了函数体),这就是为什么派生类中重写的虚函数可以不加 virtual。对于重写的虚函数,编译器会检查是否满足三同,即返回值类型、函数名、参数列表是否相同(参数列表相同指的是参数的个数相同、类型顺序相同)。只要符合三同编译器就不管了,派生类中重写的虚函数的整个壳子(即函数声明那一套)使用的是父类中的。所以,派生类中重写的虚函数 func,它的函数体中使用的 val,就应该是父类中 val 的缺省值,在派生类重写的虚函数 func 的形参列表给缺省值是没有任何意义的。
7.1 快问快答
● inline 函数可以是虚函数嘛?
答:可以,不过编译器会忽略 inline 属性,这个函数就不再是 inline,因为虚函数要放进虚函数表中。
● 静态成员可以是虚函数嘛?
答:不能,因为静态成员函数没有 this 指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
● 构造函数可以是虚函数嘛?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
● 析构函数可以是虚函数嘛?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。集体场景参考 2.4.2 小节。
● 对象访问普通函数块还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数更快。因为构成多态,运行时调用虚函数要到虚函数表中去查找。
● 虚函数表是在什么阶段生成的?存在哪?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)。
● 什么是抽象类?抽象类的作用?
答:什么是抽象类请参考 6.1 小节。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
八、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!