【C++多态奥秘:同形异义,编织面向对象的多彩世界】

2024-02-03 16:12

本文主要是介绍【C++多态奥秘:同形异义,编织面向对象的多彩世界】,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

【本节目标】

  • 1. 多态的概念

  • 2. 多态的定义及实现

  • 3. 抽象类

  • 4. 多态的原理

  • 5. 单继承和多继承关系中的虚函数表

  • 6. 继承和多态常见的面试问题

1. 多态的概念

1.1 概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。

举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人 买票时是优先买票。

再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的 活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5 毛....。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如 你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你 去使用支付宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。

2. 多态的定义及实现

2.1 多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象买票半价。

那么在继承中要构成多态还有两个条件:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

2.2 虚函数

虚函数:即被virtual修饰的类成员函数称为虚函数。

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

2.3 虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用*//*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
// 多态
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person ps;Student st;Func(ps);Func(st);//赋值兼容转换 - 切片return 0;
}

运行结果:

这样我们就实现了不同的对象有着不同行为。虚函数重写的两个例外:

1. 协变(基类与派生类虚函数返回值类型不同,要求必须是父子类关系的指针或者引用)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)

class A {};
class B : public A {};//基类与派生类虚函数返回值类型不同,要求必须是父子类关系的指针或者引用
class Person {
public:virtual A* f() { cout << "A::f()" << endl;return new A;}
};class Student : public Person {
public:virtual B* f() { cout << "B::f()" << endl;return new B; }
};int main()
{Person* p1 = new Person;Student* p2 = new Student;p1->f();//多态调用p2->f();//普通调用 - 不是基类的指针调用的Person* p3 = new Student;//赋值兼容转换 - 切片p3->f();//多态调用return 0;
}

运行结果:

上面我们的父类成功调到父类的f()函数,子类成功调到子类类的f()函数,如果我们上面的返回值类型不是父子类关系的指针或者引用,那么还符合多态嘛?我们试着将返回值改成父子类关系的对象。

class A {};
class B : public A {};//基类与派生类虚函数返回值类型不同,要求必须是父子类关系的指针或者引用
class Person {
public:virtual A f(){cout << "A::f()" << endl;return *new A;}
};class Student : public Person {
public:virtual B f(){cout << "B::f()" << endl;return *new B;}
};

我们来看一下运行结果:

此时程序就直接报错了,说明协变返回值类型不同,要求必须是父子类关系的指针或者引用。

2. 析构函数的重写(基类与派生类析构函数的名字不同)

我们之前的析构函数的顺序是先调用子类的析构函数,然后再调用父类的析构函数,通过下面的运行结果,确实是这样的。

但是我们下面的场景就出现了问题。

此时我们就发现Student指针赋值给Pesong指针的时候,没有调用到子类的析构函数,如果子类里面有指针,此时就会发生内存泄漏,此时我们就期望的是子类调用子类的析构,父类调用父类的析构函数,所以此时就要多态调用,但是此时的析构函数名就不同,怎么做到多态调用呢?此时就有一个规定:如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

此时就解决了各自调用析构的问题。如果当前的函数构成隐藏关系,就是普通调用,此时就看调用者的类型是谁,就去调用者内部调用函数。

如果我们保留父类的virtual,而省略子类的virtual呢?

我们会发现此时的也构成重写的关系,说明当父类的函数有virtual修饰,子类无论写不写virtual,都与父类构成重写关系,为什么呢?因为构成重写关系,此时子类会把父类的声明继承下来,继承下来后子类重写自己内部的函数功能,此时不加virtual,它依然是带有virtual的。重写的虚函数具有重写关系,如果普通调用,是具有隐藏的关系的。

2.4 C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数 名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有 得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。首先我们来实现一个类让它不能被继承,该如何做?

// 实现一个类,这个类不能被继承
// 1.父类的构造函数私有化,派生类实例化不出对象
class A
{
public:
protected:int _a;
private://构造函数私有A(int a):_a(a){}
};class B : public A
{
private:int _b;
};
int main()
{B bb;//此时派生类实例化不出对象//规定派生类的构造必须要去调用父类的构造才能初始化从父类继承下来的成员变量return 0;
}

// 实现一个类,这个类不能被继承
// 2.添加final关键字,C++11中final修饰的类为最终类,不能被继承
class A final
{
public:A(int a = 1):_a(a){}
protected:int _a;
};class B : public A
{
private:int _b;
};
int main()
{B bb;return 0;
}

同时还有一个作用final:修饰虚函数,表示该虚函数不能再被重写

override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

2.5 重载、覆盖(重写)、隐藏(重定义)的对比

3. 抽象类

3.1 概念

在虚函数的后面写上 = 0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,因为它会继承来自父类的纯虚函数,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

class Car
{
public:virtual void Drive() = 0;//纯虚函数
};
// 间接强制了子类重写虚函数
// 因为不重写的话,子类依旧是抽象类,不能实例化对象
class Benz :public Car
{
public:virtual void Drive(){cout << "Benz-舒适" << endl;}
};
class BMW :public Car
{
public:virtual void Drive(){cout << "BMW-操控" << endl;}
};
int main()
{// 抽象类,实例化不出对象// Car c; // error C2259: “Car”: 无法实例化抽象类Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}

此时也能实现多态调用。

3.2 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

4.多态的原理

4.1虚函数表

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;char _c = '0';//考虑内存对齐
};int main()
{cout << sizeof(Base) << endl;return 0;
}

运行结果:

通过观察测试我们发现b对象是12bytes,根据我们之前学习的内存对齐规则,由于char只有1个字节,但是不满足最大对齐数的整数倍,所以char存取1个字节,再浪费3个字节的空间,这里应该是8个字节的空间,为什么是12个字节呢?我们来看一下监视窗口。

我们发现实例化的bb对象还有一个__vfptr放在对象的前面(注意有些 平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代 表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析

// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;char _c = '0';
};
class Derive : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};
int main()
{Base b;Derive d;cout << sizeof(Base) << endl;cout << sizeof(Derive) << endl;return 0;
}

我们再来看一下sizeof的大小

此时Base类的大小没有变化,Derive的大小是16字节,我们来看一下监视窗口

Derive继承了父类的成员变量和成员方法,并且自己还多了一个_d变量,自然也就是16字节了,不知你们有木有发现,我们上面给了两个虚函数,此时__vfptr里面就有了两个内容,我们之前只写了一个虚函数,里面只有一个1个内容,说明虚函数都是要存放到虚函数表中。

通过观察和测试,我们发现了以下几点问题:

1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员和虚表指针,还有一部分是自己的成员。

2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。

4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr

5. 总结一下派生类的虚表生成:

a.先将基类中的虚表内容拷贝一份到派生类虚表中

b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

6. 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的?

还记得我们之前讲到的类和对象的知识嘛,对象中只存储成员变量,不存储成员函数,成员函数存放在公共代码区。

函数分为两个部分,分别是函数定义及其编译好的指令(汇编指令)。现在我们来看一下多态调用的情况。

多态调用,运行时,在指向对象虚表中找到函数的地址。对于普通调用,在编译链接时,通过符号表找到函数地址,本质是在编译时确定函数地址。

所以普通调用的函数是我们之前类和对象的模型,而对于多态,它是存在虚表存的是虚函数指针去找到函数的,所以它应该是下面的模型。

不同的对象的虚表不一样的,因为要实现多态,指向不同对象调用不同的虚函数。答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是 他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证?

那虚函数是存在那个区域的?堆、栈、静态区还是常量区呢?我们来验证一下

int main()
{Base b;Derive d;int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";printf("栈:%p\n", &i);printf("静态区:%p\n", &j);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);Base* p3 = &b;Derive* p4 = &d;//取出b、d对象的头4bytes,就是虚表的指针// 1.先取b、d的地址,强转成一个int * 的指针// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针printf("Base虚表地址:%p\n",*(int*)p1);printf("Derive虚表地址:%p\n",*(int*)p2);return 0;
}

然后我们来看一下结果:

此时我们发现虚表的地址距离常量区的距离最近,说明虚表应该是存储在常量区的。我们而也可以验证一下。

从上面的监视窗口我们也可以看到,相同类型的对象的虚表的地址是一样的,也能证明存放在常量区。那虚函数表是在什么阶段生成的呢?在编译的时候就生成好了,那对象当中的虚表指针呢?是在构造函数的初始化列表的时候给的,并且在第一个元素初始化列表之前生成。

多态调用是如何做到指向父类调用父类,指向子类调用子类呢?

当指向父类的时候,会去父类的虚表去找对应的虚函数,那么此时调用的就是父类func1,当我们指向一个子类的时候,此时也看到的也是一个父类,对于用户来说都是去父类的虚表中去找到虚函数的。只不过看到的父类可能是一个原生的父类(父类的指针指向父类的对象),也可能是子类中经过切片之后的父类(父类的指针指向子类,就要赋值兼容转换),原生的父类里面找到的是父类的func1,如果是子类中经过切片之后的父类(此时的是子类中切片下来的父类,只不过此时的虚表被子类重写的虚函数覆盖了),那么此时找到的是虚表里面是被子类虚函数覆盖的指针,此时就会调用子类的虚函数,从而达到指向谁调用谁。

同时我们看到反汇编代码也不是和之前一样直接call函数,多态调用这里是先通过一大堆move指令,目的是为了取到这个对象的前四个字节,也就是__vfptr,然后再在虚表中拿到要调用的虚函数指针,最后把这个指针放到了eax里面,然后去调用func1函数。

所以我们这里的多态就和之前有区别了,之前我们的函数都是在编译的时候确定函数的地址,而我们的多态调用不是编译的时候确定函数的地址,而是在运行时去指向对象的虚表中找到虚函数的地址,进行调用,所以指向父类调用父类虚函数,指向子类调用子类虚函数。

普通调用都是根据调用对象的类型,和指向的对象没有任何关系,它在编译时通过调用者的类型确定函数地址。

4.2多态的原理

上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的 Person::BuyTicket,传Student调用的是Student::BuyTicket

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}

void Func(Person* p)
{p->BuyTicket();
}
int main()
{Person mike;Func(&mike);mike.BuyTicket();return 0;
}
// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p)
{...p->BuyTicket();// p中存的是mike对象的指针,将p移动到eax中001940DE  mov         eax, dword ptr[p]// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx001940E1  mov         edx, dword ptr[eax]// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax00B823EE  mov         eax, dword ptr[edx]// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。001940EA  call        eax00头1940EC  cmp         esi, esp
}
int main()
{...// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址mike.BuyTicket();00195182  lea         ecx, [mike]00195185  call        Person::BuyTicket(01914F6h)...
}

4.3 动态绑定与静态绑定

5.单继承和多继承关系的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类 的虚表模型前面我们已经看过了,没什么需要特别研究的

5.1 单继承中的虚函数表

class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};
class Derive :public Base {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }
private:int b;
};
int main()
{Base b;Derive d;return 0;
}

观察下面的内容,我们发现虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr,并且func1被重写后,虚函数表中的地址被子类的覆盖了。

同时观察下图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这 两个函数,也可以认为是他的一个小bug。

但是我们可以通过内存窗口是可以看到的

那么我们如何查看d的虚表呢?下面我们再使用代码打印出虚表中的函数。

//typedef void(*)() VFPTR;//error
typedef void(*VFPTR)();// 打印虚表,本质打印指针(虚函数指针)数组
void PrintVTable(VFPTR vTable[])
{// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数cout << "虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("[%d]:%p\n",i, vTable[i]);}cout << endl;
}
int main()
{Derive d;VFPTR* vTabled = (VFPTR*)(*(int*)&d);PrintVTable(vTabled);return 0;
}

我们来看一下运行结果

对于Base类,我们也是可以看到的

但是现在我们只能看到这个指针,我们是不是可以拿这个指针去调用一下函数。

//typedef void(*)() VFPTR;//error
typedef void(*VFPTR)();// 打印虚表,本质打印指针(虚函数指针)数组
//void PrintVTable(VFPTR* vTable)
void PrintVTable(VFPTR vTable[])
{// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数cout << "虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("[%d]:%p->",i, vTable[i]);VFPTR f = vTable[i];f();//或者(*f)();调用}cout << endl;
}
int main()
{Base b;Derive  d;//取对象前四个字节,也就是虚表指针,虚表指针里面存放虚函数的地址VFPTR* vTabled = (VFPTR*)(*(int*)&d);PrintVTable(vTabled);VFPTR* vTableb = (VFPTR*)(*(int*)&b);PrintVTable(vTableb);return 0;
}

我们再来看一下运行结果:

5.2 多继承中的虚函数表

class Base1 {
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};
class Base2 {
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};
class Derive : public Base1, public Base2 {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};int main()
{Derive d;cout << sizeof(d) << endl;return 0;
}

我们先来算一下sizeof子类对象的大小,看看是多少?

大小呢是20个字节,那它到底存储了什么呢?首先三个整型变量,然后再一个虚表,那不是16个字节嘛?我们来看看监视窗口。

我们发现这里又两张虚表,分别来自两个父类,所以这里就是20个字节了。这里两张虚表也是合理的,因为它毕竟来自两个父类嘛!我们再来看一下切片是什么个事儿

指针的大小决定了指针解引用一次性能看多少个字节,ptr1只能看到&d后的Base个大小字节,ptr2这里发生了偏移,正好是ptr1的大小的8个字节,所以这里ptr1和ptr2不相等,它们有各自的虚表,所以derive重写func1的时候要覆盖两张虚表。那这里又很奇怪,我们的func3函数咋又不见了呢?

class Base1 {
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};
class Base2 {
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};
class Derive : public Base1, public Base2 {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{cout << " 虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("[%d]:%p->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}int main()
{Derive d;Base1* ptr1 = &d;PrintVTable((VFPTR*)(*(int*)ptr1));Base2* ptr2 = &d;PrintVTable((VFPTR*)(*(int*)ptr2));return 0;
}

我们来看一下运行结果:

观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

5.3. 菱形继承、菱形虚拟继承

我们先来看一下菱形继承中如果存在虚函数,对象的大小是多少?

class A
{
public:virtual void func1(){}public:int _a;
};class B : public A
{
public:int _b;
};class C : public A
{
public:int _c;
};class D : public B, public C
{
public:int _d;
};int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;cout << sizeof(d) << endl;return 0;
}

对象d的对象为28个字节,那到底有什么呢?

我们来看一下内存窗口

我们再来看一下菱形虚拟继承

class A
{
public:virtual void func1(){}public:int _a;
};
class B : virtual public A
{
public:public:int _b;
};
class C : virtual public A
{
public:public:int _c;
};
class D : public B, public C
{
public:int _d;
};int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;cout << sizeof(d) << endl;return 0;
}

我们先来看一下对象的大小是多少?

我们来看一下内存窗口,此时就只有A类有虚函数。

此时我们也可以通过内存窗口看到,也只有A具有虚表。这里要注意一下,对于B和C类的第一个是虚基表,里面存放的是偏移量,可以通过偏移量去找到_a,而对于A类中的第一个是虚表,里面存放的是虚函数的指针,可以通过这个指针去找到虚函数。

class A
{
public:virtual void func1(){}
public:int _a;
};
class B : virtual public A
{
public:virtual void func1(){}
public:int _b;
};
class C : virtual public A
{
public:virtual void func1(){}
public:int _c;
};
class D : public B, public C
{
public:int _d;
};

当我们让B类和C类重写func1的时候,然后我们编译一下程序。

我们发现B类和C类重写func1的时候,程序就报错了,为什么呢?因为此时B类和C类共享一份A类,那么现在B类和C类都去重写,此时该重写谁的呢?此时编译器也分不清,于是就报错了。此时就有两种解决办法,可以让B类和C类其中一个去重写虚函数func1,或者让D类重写虚函数func1。

class A
{
public:virtual void func1(){}
public:int _a;
};
class B : virtual public A
{
public:virtual void func1(){}
public:int _b;
};
class C : virtual public A
{
public:virtual void func1(){}
public:int _c;
};
class D : public B, public C
{
public:virtual void func1(){}
public:int _d;
};

因为最终我们要使用的就是D类,直接让D类覆盖重写就好了,并且此时B类和C类也都重写了虚函数,定义B类和C类对象都可以单独使用自己的虚函数了。如果B类和C类都增加字节的虚函数呢?

class A
{
public:virtual void func1(){}
public:int _a;
};
class B : virtual public A
{
public:virtual void func1(){}virtual void func2(){}
public:int _b;
};
class C : virtual public A
{
public:virtual void func1(){}virtual void func3(){}
public:int _c;
};
class D : public B, public C
{
public:virtual void func1(){}
public:int _d;
};

那么此时会往A类里面存放嘛?当然不可以,因为A是B类和C类共享的,而A只有一份,那么此时写入A类就不会知道存谁了。

此时的B类和C类种又存储了各自的虚函数指针,而不是存放到A类中。之前我们留了一个历史遗留问题,就是虚基表中的一个位置存放的是什么?第二个位置我们都知道存放的是偏移量,通过这个偏移量可以找到_a,那第一个位置是啥呢?这里的ffffffcf的值其实就是-4,所以这个位置就是虚基表和虚函数表的偏移量。

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的 模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看 了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的宝宝,可以去看下面 的两篇链接文章。

1. C++ 虚函数表解析

2. C++ 对象的内存布局

6. 继承和多态常见的面试问题

6.1 概念查考

1. 下面哪种面向对象的方法可以让你变得富有( )

A: 继承           B: 封装           C: 多态          D: 抽象

2. ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关, 而对方法的调用则可以关联于具体的对象。

A: 继承          B: 模板          C: 对象的自身引用          D: 动态绑定

3. 面向对象设计中的继承和组合,下面说法错误的是?()

A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复 用,也称为白盒复用                                                                                                                                B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动 态复用,也称为黑盒复用                                                                                                                        C:优先使用继承,而不是组合,是面向对象设计的第二原则                                                           D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封 装性的表现

4. 以下关于纯虚函数的说法,正确的是( )

A:声明纯虚函数的类不能实例化对象                           B:声明纯虚函数的类是虚基类              C:子类必须实现基类的纯虚函数                                  D:纯虚函数必须是空函数

5. 关于虚函数的描述正确的是( )

A:派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B:内联函数不能是虚函数           C:派生类必须重新定义基类的虚函数                          D:虚函数可以是一个static型的函数

6. 关于虚表说法正确的是( )

A:一个类只能有一张虚表                                                                                                                B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表          C:虚表是在运行期间动态生成的                                                                                                       D:一个类的不同对象共享该类的虚表

7. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )

A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址                                    B:A类对象和B类对象前4个字节存储的都是虚基表的地址                                                          C:A类对象和B类对象前4个字节存储的虚表地址相同                                                                  D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表

8. 下面程序输出结果是什么? ()

#include<iostream>
using namespace std;
class A {
public:A(const char* s) { cout << s << endl; }~A() {}
};
class B :virtual public A
{
public:B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:D(const char* s1, const char* s2, const char* s3, const char* s4) :B(s1, s2), C(s1, s3), A(s1){cout << s4 << endl;}
};
int main() {D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}

A:class A class B class C class D                 B:class D class B class C class A

C:class D class C class B class A                 D:class A class C class B class D

9. 多继承中指针偏移问题?下面说法正确的是( )

class Base1 { public:  int _b1; };
class Base2 { public:  int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}

A:p1 == p2 == p3        B:p1 < p2 < p3        C:p1 == p3 != p2       D:p1 != p2 != p3

 10. 以下程序输出结果是什么()

class A
{
public:// func虚函数重写(对于参数只看类型)virtual void func(int val = 1){std::cout << "A->" << val << std::endl;}virtual void test(){// 注意:继承是子类可以使用父类的public函数// 并不是将父类的代码拷贝到子类func(); //this->func(),this是A*类型,p(子类指针)->test();这里发生切片B*->A*//也就是A* a = new B;始终是指向B类对象,那么就是多态调用,指向B类型对象}
};
class B : public A
{
public://重写:返回值、函数名、参数(参数看类型)//重写加不加virtual,是使用父类的func声明,而不是使用自己的void func(int val = 0) //这里实际上是virtual void func(int val = 1) {std::cout << "B->" << val << std::endl;//指向B就调用B}
};int main()
{B* p = new B;//B是一个子类的指针指向子类的对象// 子类函数没有test,怎么调到的呢?继承父类的test,调用父类的testp->test();//由于子类没有test,会调用父类的的p->test();此时是单纯的继承A* p1 = new B;p1->func();//多态调用 - 指向B类调用B类的funcA* p2 = new A;p2->func();//多态调用 - 指向A类调用A类的func/*多态的条件:1.必须通过基类的指针或者引用调用虚函数2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写*/p->func();//p是派生类的指针,不是基类的指针,所以普通调用,就直接调用自己的函数B bb;//bb是派生类的对象,不是基类的指针或者引用,普通调用,就直接调用自己的函数bb.func();return 0;
}

我们来看一下运行结果:

6.2 问答题

1. 什么是多态?答:从静态多态(函数重载,编译链接的时候去符号表中寻找函数的地址)和动态多态(运行时到指向对象的虚表中去找函数)回答。

2. 什么是重载、重写(覆盖)、重定义(隐藏)?答:参考本节课件内容

3. 多态的实现原理?答:参考本节课件内容

4. inline函数(没有函数地址)可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。

class Base
{
public:inline virtual void Func1(){cout << "Base::Func1()" << endl;}virtual  void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};class Derive : public Base
{
public:inline virtual void func1(){cout << "Derive::Func1()" << endl;}
private:int d1;
};int main()
{// 多态调用Base* p = new Derive;p->Func1();// 普通调用Base b;b.Func1();return 0;
}

此时程序没有任何报错,那么怎么看待这个问题呢?内联函数一定是展开,多态调用一定是在虚表中取到虚函数的地址进行调用,我们颗以看一下反汇编中直接展开还是call

当我们是个普通调用的时候呢?

我们这里看的不够模明显,我们来改一下程序。

class Base
{
public:Base(){}inline virtual int Func1(){int a = 0;int b = 0;b += a;return b;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};class Derive : public Base
{
public:inline virtual int func1(){int a = 10;int b = 1;b += a;return b;}
private:int d1;
};int main()
{// 多态调用Base* p = new Derive;p->Func1();// 普通调用Base b;b.Func1();return 0;
}

此时我们再来看一下多态调用和普通调用的反汇编有没有展开。

5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析 构函数定义成虚函数。参考本节课件内容

8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针 对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函 数表中去查找。

9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况 下存在代码段(常量区)的。

10. C++菱形继承的问题?虚继承的原理?答:参考继承课件。注意这里不要把虚函数表和虚基 表搞混了。 11. 什么是抽象类?抽象类的作用?答:参考(3.抽象类)。抽象类强制重写了虚函数,另外抽 象类体现出了接口继承关系。

这篇关于【C++多态奥秘:同形异义,编织面向对象的多彩世界】的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/674702

相关文章

【C++ Primer Plus习题】13.4

大家好,这里是国中之林! ❥前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。有兴趣的可以点点进去看看← 问题: 解答: main.cpp #include <iostream>#include "port.h"int main() {Port p1;Port p2("Abc", "Bcc", 30);std::cout <<

C++包装器

包装器 在 C++ 中,“包装器”通常指的是一种设计模式或编程技巧,用于封装其他代码或对象,使其更易于使用、管理或扩展。包装器的概念在编程中非常普遍,可以用于函数、类、库等多个方面。下面是几个常见的 “包装器” 类型: 1. 函数包装器 函数包装器用于封装一个或多个函数,使其接口更统一或更便于调用。例如,std::function 是一个通用的函数包装器,它可以存储任意可调用对象(函数、函数

C++11第三弹:lambda表达式 | 新的类功能 | 模板的可变参数

🌈个人主页: 南桥几晴秋 🌈C++专栏: 南桥谈C++ 🌈C语言专栏: C语言学习系列 🌈Linux学习专栏: 南桥谈Linux 🌈数据结构学习专栏: 数据结构杂谈 🌈数据库学习专栏: 南桥谈MySQL 🌈Qt学习专栏: 南桥谈Qt 🌈菜鸡代码练习: 练习随想记录 🌈git学习: 南桥谈Git 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

揭秘世界上那些同时横跨两大洲的国家

我们在《世界人口过亿的一级行政区分布》盘点全球是那些人口过亿的一级行政区。 现在我们介绍五个横跨两州的国家,并整理七大洲和这些国家的KML矢量数据分析分享给大家,如果你需要这些数据,请在文末查看领取方式。 世界上横跨两大洲的国家 地球被分为七个大洲分别是亚洲、欧洲、北美洲、南美洲、非洲、大洋洲和南极洲。 七大洲示意图 其中,南极洲是无人居住的大陆,而其他六个大洲则孕育了众多国家和

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

06 C++Lambda表达式

lambda表达式的定义 没有显式模版形参的lambda表达式 [捕获] 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 有显式模版形参的lambda表达式 [捕获] <模版形参> 模版约束 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 含义 捕获:包含零个或者多个捕获符的逗号分隔列表 模板形参:用于泛型lambda提供个模板形参的名

6.1.数据结构-c/c++堆详解下篇(堆排序,TopK问题)

上篇:6.1.数据结构-c/c++模拟实现堆上篇(向下,上调整算法,建堆,增删数据)-CSDN博客 本章重点 1.使用堆来完成堆排序 2.使用堆解决TopK问题 目录 一.堆排序 1.1 思路 1.2 代码 1.3 简单测试 二.TopK问题 2.1 思路(求最小): 2.2 C语言代码(手写堆) 2.3 C++代码(使用优先级队列 priority_queue)

【C++高阶】C++类型转换全攻略:深入理解并高效应用

📝个人主页🌹:Eternity._ ⏩收录专栏⏪:C++ “ 登神长阶 ” 🤡往期回顾🤡:C++ 智能指针 🌹🌹期待您的关注 🌹🌹 ❀C++的类型转换 📒1. C语言中的类型转换📚2. C++强制类型转换⛰️static_cast🌞reinterpret_cast⭐const_cast🍁dynamic_cast 📜3. C++强制类型转换的原因📝

C++——stack、queue的实现及deque的介绍

目录 1.stack与queue的实现 1.1stack的实现  1.2 queue的实现 2.重温vector、list、stack、queue的介绍 2.1 STL标准库中stack和queue的底层结构  3.deque的简单介绍 3.1为什么选择deque作为stack和queue的底层默认容器  3.2 STL中对stack与queue的模拟实现 ①stack模拟实现

c++的初始化列表与const成员

初始化列表与const成员 const成员 使用const修饰的类、结构、联合的成员变量,在类对象创建完成前一定要初始化。 不能在构造函数中初始化const成员,因为执行构造函数时,类对象已经创建完成,只有类对象创建完成才能调用成员函数,构造函数虽然特殊但也是成员函数。 在定义const成员时进行初始化,该语法只有在C11语法标准下才支持。 初始化列表 在构造函数小括号后面,主要用于给