【C++从练气到飞升】15---深入浅出多态

2024-08-24 20:28

本文主要是介绍【C++从练气到飞升】15---深入浅出多态,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

 🎈个人主页:库库的里昂
收录专栏:C++从练气到飞升
🎉鸟欲高飞先振翅,人求上进先读书🎉

目录

⛳️推荐

一、多态的概念

二、多态的定义及实现

2.1 多态的构成条件

2.2 虚函数

2.3 虚函数的重写

2.4 虚函数重写的两个例外

2.4.1 协变(基类与派生类虚函数返回值类型不同)

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

2.5 C++11 override 和 final

2.5.1 final:修饰虚函数,表示该虚函数不能再被重写

2.5.2 override

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

四、多态的原理

4.1 虚函数表

4.2 派生类对象中的虚函数表

4.2.1 编写程序去访问虚函数表

4.2.2 虚表存储位置的验证

4.3 多态的原理

4.3.1 为什么不能是派生类的指针或者引用?

4.3.2 为什么不能是父类的对象呢?

4.3.3 派生类中为什么要对父类的虚函数进行重写?

4.4 动态绑定与静态绑定

五、多继承关系的虚函数表

5.1 普通的多继承

5.2 菱形继承、菱形虚拟继承

5.2.1 普通菱形继承

5.2.2 菱型虚拟继承

六、抽象类

6.1 概念

6.2 接口继承和实现继承

七、多态常见面试题

7.1 快问快答


⛳️推荐

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站

一、多态的概念

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

举个栗子:比如买票这个行为,当普通人买票时,是全价;学生买票时,是半价;军人买票时是优先买票。再举个例子:想必大家都参与过支付宝的扫红包-支付-给奖励金的活动,那么大家想一想为什么有人扫的红包金额很大8块、10块,而有的人扫出来的红包金额都是1毛,5毛。其实这背后就是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、或者你没有经常的使用支付宝等等,那么你需要被鼓励使用支付宝,那么你扫码的金额就 = random % 99;如果你是经常使用支付宝支付或者支付宝账户中常年有钱,那么就不需要太鼓励你去使用支付宝,那么你的扫码金额就 = random % 1。总结一下:同样是扫码动作,不同的用户去扫得到不一样的红包,这也是一种多态行为。

二、多态的定义及实现

2.1 多态的构成条件

多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。比如 Student 继承了 Person。Person 对象买票全价,Student 对象买票半价。因此多态的前提是要在继承体系中,在继承中要构成多态还有两个条件:

  • 必须通过基类的指针或者引用调用虚函数

  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

class Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买全价票" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买半价票" << endl;}
};void Func(const Person& people)//基类的引用
{people.BuyTicket();
}int main()
{Person Jack;//普通人Func(Jack);Student Mike;//学生Func(Mike);return 0;
}

小Tips:多态调用看的是基类指针或引用指向的对象,基类的指针或引用如果指向一个基类对象,那就调用基类的成员函数,如果指向派生类对象就调用派生类的成员函数。

class Person
{
public:virtual void BuyTicket() const{cout << "买全价票" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() const{cout << "买半价票" << endl;}
};void Func(const Person people)
{people.BuyTicket();
}int main()
{Person Jack;//普通人Func(Jack);Student Mike;//学生Func(Mike);return 0;
}

小Tips:上面这段代码中 Func 函数的形参变成了一个普通的基类对象 people,在函数体中通过 people 去调用成员函数 BuyTicket,此时因为 people 不是基类的指针或引用,因此 people.BuyTicket(); 函数调用不满足多态调的条件,此时无论传进来的是基类对象还是派生类对象,调用的都是基类中的 BuyTicket,因为在不满足多态的条件下,调用成员函数取决于当前调用对象的类型,当前的 people 是一个基类对象,这就意味着它只能调用基类中的成员函数,所以我们不管是传基类对象 Jack 还是派生类对象 Mike,最终打印结果都是“买全价票”。传派生类对象 Mike 的时候,会发生切片,会用 Mike 对象中继承自基类的那部分成员变量去构造基类对象 people。如果Func 函数的形参 people 就是基类的指针或者引用,去掉基类中 BuyTicket 函数前面的 virtual,此时还是不满足多态的条件,无论传基类对象还是派生类对象,最终调用的都是基类中的 BuyTicke,因为 people 的类型是基类。总结:多态的两个构成条件缺一不可。

2.2 虚函数

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

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

小Tips:只能是类的成员函数才能变成虚函数,在全局函数前面是不能加 virtual 的。

2.3 虚函数的重写

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

class Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买全价票" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买半价票" << endl;}
};void Func(const Person& people)
{people.BuyTicket();
}int main()
{Person Jack;//普通人Func(Jack);Student Mike;//学生Func(Mike);return 0;
}

小Tips:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

2.4 虚函数重写的两个例外

2.4.1 协变(基类与派生类虚函数返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类(也可以是其他继承体系中的基类)对象的指针或者引用,派生类虚函数返回派生类(也可以是其他继承体系中的派生类)对象的指针或者引用,这就称作协变。返回值类型必须同时是指针或者引用,不能一个是指针,一个是引用。

class A 
{};class B : public A 
{};class Person 
{
public:virtual A* f() { return new A; }
};class Student : public Person 
{
public:virtual B* f() { return new B; }
};
2.4.2 析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数的名字不同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor。

class Person 
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};class Student : public Person 
{
public:virtual ~Student() { cout << "~Student()" << endl;delete[] pi;pi = nullptr;}
protect:int* pi = new int[10];
};void Test()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;
}int main()
{Test();return 0;
}

小Tips:编译器之所以将所有类的析构函数都统一处理成 destructor,目的是为了让父子类的析构函数构成重写,只有派生类 Student 的析构函数重写了 Person 的析构函数,上面代码中 delete 对象,才能构成多态,才能保证 p1 和 p2 指向的对象正确的调用析构函数。假如子类中并没有重写父类的析构函数,那么 delete p2; 就会出问题,它就调不到派生类 Student 的析构函数。因为 delete 分两步,先去调用析构函数,再去调用 operator delete,而这里 p2 是一个基类 Person 的对象,最终 delete p2 就是变成:p2->destructor + operator delete(p2)。如果派生类 Student 没有重写基类 Person 的析构函数,那 p2->destructor 就不构成多态调用,就是普通的调用成员函数,此时会根据调用对象的类型去判断到底是调用基类中的成员函数还是调用派生类中的成员函数(具体规则是基类对象调用基类的成员函数,派生类对象调用派生类中的成员函数),这里的 p2 是一个基类对象的指针,所以 p2->destructor 调用的一定是基类的析构函数,但是当前 p2 指向一个派生类 Student 的对象,而我们希望调用派生类 Student 的析构函数去清理该派生类 Student 对象中的资源。 这种情况下,我们希望的是 p2 指向谁,就去调用谁的析构,这不就是多态嘛。所以我们要让基类的析构函数变成虚函数,然后派生类去重写虚函数,这样才能满足多态的条件,重写编译器已经帮我们实现了(编译器将析构函数统一处理成同名函数,且析构函数没有返回值和参数,完美的满足三通),我们只需要在基类析构函数的前面加上 virtual,让析构函数变成虚函数即可。这里建议大家在写代码的过程中,对于可能会被继承的类最好在它的析构函数前面加上 virtual,让它变成一个虚函数。

2.5 C++11 override 和 final

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

2.5.1 final:修饰虚函数,表示该虚函数不能再被重写

final:修饰虚函数,表示该虚函数不能再被重写

class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; }
};

小Tips:虚函数如果不能被重写是没有什么意义的。这里在补充一个知识点,一个类不想被继承该怎么做?C++98 中的方法:将该类的构造函数私有,私有在子类中是不可见的,而派生类的构造函数又必须调用父类的构造函数。但是这种做法会导致创建该类对象时也无法调用构造函数了,私有在类外面不可见但是在类里面是可见的,所以此时可以在该类里面写一个静态成员函数专门用来创建对象。在 C++11 引入 final 关键字后,对于一个类如果不想让它被继承,我们可以在该类的后面加上 final 关键字进行修饰。

2.5.2 override

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

class Car
{
public:virtual void Drive() {}
};
class Benz :public Car
{
public:virtual void Drive() override{ cout << "Benz-舒适" << endl; }
};

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

四、多态的原理

4.1 虚函数表

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

小Tips:通过上面的打印结果和调试,我们发现一个 Base 对象是 8 bytes,除了 _b 成员,还多了一个 _vfptr 放在对象成员变量的前面(注意有些平台可能会放到对象成的最后面,这个跟平台有关系)。_vfptr 本质上是一个指针,这个指针我们叫做虚函数表指针(v 代表 virtual,f 代表 function)。一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(虚函数本质上是存在代码段的),虚函数表也简称虚表。

4.2 派生类对象中的虚函数表

上面我们看了一个普通类对象中的虚表,下面我们再来看看派生类中的虚表又是怎样的。

// 针对上面的代码我们做出以下改造
// 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;
};
class Derive : public Base
{
public:virtual void Func1(){}
private:int _d = 2;
};
int main()
{Base b;Derive d;return 0;
}

通过监视窗口我们发现了以下几个问题:

  • 派生类对象 d 中也有一个虚表,这个虚表是作为基类成员的一部分被继承下来的。总的来说,d 对象由两部分构成,一部分是父类继承下来的成员,d 对象中虚表指针就是就是这部分成员中的一个。另一部分则是自己的成员。

  • 基类 b 对象和派生类 d 对象的虚表是不一样的,上面的代码中 Func1 完成了重写,所以 d 的虚表中存的是重写后的 Derive::Func1,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层面的叫法,覆盖是原理层面的叫法。

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

  • 虚函数表本质上是一个存虚函数地址的函数指针数组,一般情况下这个数组最后面放了一个 nullptr。
  • 总结一下派生类虚表的生成:
  1. 先将基类中的虚表内容拷贝一份到派生类虚表中。

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

  3. 派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(在 VS 监视窗口显示的虚表中是看不见的,下面将通过程序带大家来验证)

  • 这里还有一个比较容易混淆的问题:虚函数存在哪?虚表存在哪?很多小伙伴会觉得:虚函数存在虚表,虚表存在对象中,注意这种回答是错的。这里再次强调:虚表存的是虚函数的地址,不是虚函数,虚函数和普通的成员函数一样,都是存在代码段的,只是它的地址又存到了虚表中。另外,对象中存的不是虚表,存的是虚表的地址。那虚表是存在哪儿呢?通过验证,在 VS 下虚表是存在代码段的。Linux g++ 下大家可以自己去验证。

  • 同一个程序中,同一类型的对象共用一个虚表。

4.2.1 编写程序去访问虚函数表

上面提到派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。但是在 VS 的监视窗口中是看不到,以下面的代码为例:

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;
}

小Tips:监视窗口中展现的派生类对象的虚函数表中并没有派生类自己的虚函数 func4。但是我们从内存窗口可以看到第四个地址,我们可以大胆的猜测这个就是派生类自己的虚函数 func4 的地址,但是口说无凭,下面我们来写一段代码验证一下我们的猜想。

typedef void (*VFPTR) ();//VFPTR是一个函数指针//vf是一个函数指针数组,vf就是指向虚表
//虚表本质上就是一个函数指针数组
void PrintVfptr(VFPTR* vf)
{for (int i = 0; vf[i] != nullptr; i++){printf("vfptr[%d]:%p----->", i, vf[i]);VFPTR f = vf[i];//函数指针和函数名是一样的,可以去调用该函数f();}printf("\n");
}int main()
{Person Mike;int vfp1 = *(int*)&Mike;PrintVfptr((VFPTR*)vfp1);Student Jack;int vfp2 = *(int*)&Jack;PrintVfptr((VFPTR*)vfp2);return 0;
}

小Tips:通过上图可以看出我们程序打印出来的地址和监视窗口中显示的地址是一样的,并且成功的调用了派生类中的虚函数 func4,上图显示的结果完美的验证了我们的猜想。这里也说明了一个问题,VS 的监视窗口是存在 Bug 的,以后我们在调试代码过程中也不能完全相信监视窗口展现给我们的内容,比起监视窗口我们更应该相信内存窗口展现给我们的内容。这里也侧面反映了一个问题,只要我们能拿到函数的地址就能去调用该函数,正常情况下,我们只能通过派生类对象去调用虚函数 func4,这里我们直接拿到了这个函数的地址去调用,这里的问题在于函数的隐藏形参 this 指针接收不到实参,因为不是派生类对象去调用该函数。函数中如果去访问了成员变量,那么我们这种调用方式就会出问题。

4.2.2 虚表存储位置的验证
//虚表存储位置的验证class Person
{
public:virtual void func1() const{cout << "virtual void Person::fun1()" << endl;}
//protected:int _a = 1;
};class Student : public Person
{
public:virtual void func1() const{cout << "virtual void Student::fun1()" << endl;}
//protected:int _b = 2;
};int main()
{Person Mike;Student Jack;//栈区int a = 10;printf("栈区:%p\n", &a);//堆区int* pa = new int(9);printf("堆区:%p\n", pa);//静态区(数据段)static int sa = 8;printf("静态区(数据段):%p\n", &sa);//常量区(代码段)const char* pc = "hello word!";printf("常量区(代码段):%p\n", pc);//虚表printf("基类的虚表:%p\n", (void*)*(int*)&Mike);printf("派生类的虚表:%p\n", (void*)*(int*)&Jack);
}

小Tips:上面取虚表地址是通过强制类型转化来实现的,通过上面的监视窗口我们可以看出,虚表的地址永远是存储在对象的前四个字节,所以这里我们先取到对象的地址,然后将其强转为 int* 类型,为什么要强转为 int* 呢?因为,一个 int 型的大小就是四个字节,而指针的类型决定了该指针能够访问到内存空间的大小,一个 int* 的指针就能够访问到四个字节,再对 int* 解引用,这样就能访问到内存空间中前四个字节的数据,这样就能取道虚表的地址啦。通过打印结果我们可以看出,虚表的地址和常量区(代码段)的地址最为接近,因此我们可以大胆的猜测,虚表就是存储在常量区(代码段)的。

4.3 多态的原理

上面说了这么多,那多态的原理究竟是什么呢?

小Tips:此时再来分析下上面这个图,当 people 指向基类对象 Jack 时,people.BuyTicket() 在 Jack 的虚表中找到的虚函数是 Person::BuyTicket();当 people 指向派生类对象 Mike 时,people.BuyTicket() 在 Mike 的虚表中找到的虚函数是 Student::BuyTicket()。这样就实现了不同对象去完成同一行为时,展现出不同的形态。其次,通过对汇编代码的分析,可以发现,满足多态的函数调用,不是在编译时确定的,是在运行起来以后到对象中取的。而不满足多态的函数调用则是在编译时就确定好了。

小Tips:通过上面两张图可以看出,在满足多态的条件下,无论传递的是基类对象还是派生类对象,最终转化成汇编代码都是一样的。最终的函数调用是在代码运行起来后去对象里面取的。

小Tips:普通函数调用在编译时就确定好了直接去 call 那个函数。call 的这个函数和调用该函数对象的类型有关,这里调用 BuyTicket 的对象是一个 Person 类型,这就决定了调用的 BuyTicket 函数一定是基类中的。

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 小节。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。

这篇关于【C++从练气到飞升】15---深入浅出多态的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



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

相关文章

Ilya-AI分享的他在OpenAI学习到的15个提示工程技巧

Ilya(不是本人,claude AI)在社交媒体上分享了他在OpenAI学习到的15个Prompt撰写技巧。 以下是详细的内容: 提示精确化:在编写提示时,力求表达清晰准确。清楚地阐述任务需求和概念定义至关重要。例:不用"分析文本",而用"判断这段话的情感倾向:积极、消极还是中性"。 快速迭代:善于快速连续调整提示。熟练的提示工程师能够灵活地进行多轮优化。例:从"总结文章"到"用

这15个Vue指令,让你的项目开发爽到爆

1. V-Hotkey 仓库地址: github.com/Dafrok/v-ho… Demo: 戳这里 https://dafrok.github.io/v-hotkey 安装: npm install --save v-hotkey 这个指令可以给组件绑定一个或多个快捷键。你想要通过按下 Escape 键后隐藏某个组件,按住 Control 和回车键再显示它吗?小菜一碟: <template

【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 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

【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模拟实现