当前位置: 首页 > news >正文

C++ 面向对象三大特性之一---多态

目录

一、多态的概念

二、多态的定义及实现

多态的构成条件

虚函数

虚函数的重写

例题

​编辑

协变

​编辑

析构函数的重写(重点)

C++11 override和final

override

final

如何禁止类被继承?

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

三、纯虚函数和抽象类

概念

使用:

应用场景

接口继承和实现继承

四、多态的原理

虚函数表

单继承中的虚函数表

虚函数和虚函数表的存储位置

​编辑

多态的原理

普通函数的调用

多态的原理

在类中同类型的对象共用一张虚表

静态绑定与动态绑定

五、一些关于多态的问答题


一、多态的概念

多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)运行时多态(动态多态),这里我们重点讲运行时多态编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,而把运行时的多态归为动态。
 
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如下面这幅图片买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>ω<)喵“,传狗对象过去,就是“汪汪”。

二、多态的定义及实现

多态的构成条件

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


同时在继承中要构成多态还需要满足以下两个关系:

  1. 必须通过基类的指针或引用进行调用虚函数
  2. 被调用的函数必须是基函数,且派生类必须对基类的虚函数进行了重写
#include <iostream>
using namespace std;class Person
{
public:virtual void BuyTicket(){cout << "买票全价" << endl;}
};class Student :public Person
{
public:virtual void BuyTicket(){cout << "买票半价" << endl;}
};void fun(Person& ps)
{ps.BuyTicket();
}int main()
{Person ps;Student st;fun(ps);fun(st);return 0;
}

上述代码就完美体现了C++的运行时多态(动态多态):

  • 基类 Person 定义了虚函数 BuyTicket ;
  • 派生类 Student 重写了这个虚函数
  • 通过基类引用( Person& )(构成多态的第一个条件) 调用函数时,会根据实际传入的对象( Person 或 Student ) (派生类对象可以隐式转换为基类的指针/引用),自动调用对应类的 BuyTicket ,最终输出“买票全价”和“买票半价”。

运行结果:

虚函数

什么是虚函数?

虚函数:被 virtual 关键字修饰的类成员函数是虚函数,注意这里函数必须是类的成员函数,对于普通函数则不行

比如说上面代码中Person类和Student类中的 BuyTicket 函数 , 函数返回值之前都加了关键字 virtual , 所以这两个函数都构成虚函数:

虚函数的重写

什么是虚函数的重写?

虚函数的重写(覆盖):派生类中有一个接口跟基类完全相同的虚函数(即派生类的虚函数和基类的虚函数的函数名,参数列表的类型,返回值完全相同),那么就称派生类的虚函数重写了基类的虚函数,构成了虚函数的重写

  • 虚函数的重写是语法层的概念,在原理层上又称覆盖。这里重写的是虚函数的实现,继承的是虚函数接口,如果把函数的声明比作壳,函数的实现比作核,那么派生类继承的是壳即声明,重写的是核即实现

在进行虚函数的重写时,派生类从基类继承下来的虚函数可以不加virtual进行修饰,此时派生类和基类的虚函数依旧构成重写,因为派生类的虚函数是从基类的虚函数继承下来的,所以这个从基类继承的虚函数在派生类中保持了虚函数的属性,所以派生类的虚函数可以不加virtual修饰,但基类的 virtual 必须写,从代码的规范性来讲,建议派生类从基类继承的虚函数前加上virtual进行修饰

例题

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

A: A->0      B: B->1     C: A->1       D: B->0      E: 编译出错       F: 以上都不正确

💡解答:
 
要解决这个问题,需理解 C++ 中虚函数和默认参数的特殊规则:
  1. 虚函数调用:运行时根据对象实际类型(动态绑定),调用对应类的虚函数。
  2. 默认参数值:编译时根据指针/引用的声明类型确定默认值(静态绑定)。
代码分析步骤:
 
1. 对象与指针类型: B* p = new B; ,指针 p 声明为 B* ,但指向的是 B 类型对象。
2. 调用  p->test() :
  • test()  是基类 A 的虚函数,且派生类 B 未重写 test() ,因此调用 A 的 test() 。
  •  A::test() 中调用 func() ,func() 是虚函数,需看对象实际类型对象是 B,所以调用  B::func() 。
3.  B::func() 的默认参数:   func() 的默认参数是编译时根据指针声明类型确定的。指针 p 声明为 B* ,但 test() 是 A 中的函数,所以编译时认为调用 func() 的上下文是 A 类,因此默认参数取 A::func 的 val = 1 ,而不是B::func 中参数 val = 0。
 
最终输出 : 调用   B::func(val = 1) ,输出: B->1 。
 
所以答案是 B。
  • 虚函数的“函数调用目标”是运行时动态绑定的,但“默认参数值”是编译时静态绑定的,由调用处的声明类型决定~

协变

什么是协变?

协变(Covariance)是虚函数重写规则的一部分(一种特殊且合法的重写情形)。

  • 要理解这一点,先回顾普通虚函数重写的规则:派生类重写基类虚函数时,函数的返回值、参数列表、const 修饰等必须与基类完全一致(“严格匹配”)。
  • 而协变是对“返回值”规则的放宽,允许派生类重写虚函数时,返回值为“派生类指针/引用”。

具体来说,当基类虚函数的返回值是基类的指针(或引用)时,派生类重写该虚函数时,返回值可以是派生类的指针(或引用)——这就是协变,属于合法的虚函数重写。

返回指针:

返回引用:

析构函数的重写(重点)

为什么要完成析构函数的重写?

当基类的析构函数添加virtual为虚函数时,此时派生类析构函数只要定义,无论是否加virtual关键字,都会与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,但是实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写。

下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中要释放资源。

class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};
class B : public A 
{
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,
// 才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
  1. 基类 A 的析构函数是虚函数,派生类 B 的析构函数会自动重写基类析构(编译器统一处理析构函数名为 destructor );
  2. delete p1 (指向 A 对象)会调用 ~A() ;
  3. delete p2 (基类指针指向 B 对象)会触发多态,先调用 ~B() (释放 _p 指向的数组),再调用 ~A() ,不会内存泄漏。

输出结果:

问题:

1. 析构函数构成多态的原理?

这段代码中,要让 delete p2 触发析构函数的多态(即调用  ~B()  而非仅  ~A() ),核心条件和普通虚函数多态一致,但需结合析构函数的特殊规则:

  1. 基类析构函数必须是虚函数 : 基类  A  的析构函数需加  virtual  关键字( virtual ~A() ),这是触发多态的前提——只有虚函数才能通过“基类指针/引用”动态匹配实际对象的函数版本。
  2. 派生类析构函数完成“隐式重写” : 派生类  B  的析构函数无需显式加  virtual ,也无需和基类析构函数同名(编译器会把所有析构函数统一处理为  destructor ),只要定义了析构函数,就会自动重写基类的虚析构函数(满足“函数名、参数、返回值一致”的重写规则)。
  3. 通过基类指针/引用操作派生类对象 : 必须用基类 A 的指针(如  A* p2 = new B )或引用指向派生类  B  的对象——这是多态的“触发场景”,只有基类指针/引用才能同时指向基类和派生类对象,进而动态匹配函数。

简单总结:基类虚析构 + 派生类析构隐式重写 + 基类指针指向派生类对象,三者缺一不可,才能让析构函数构成多态。


2. 为什么 delete p2 先调用 ~B() ?


这是虚函数多态+析构函数重写的效果:

  1. 基类 A 的析构函数被 virtual 修饰,成为虚析构函数;
  2. 派生类 B 的析构函数,会被编译器自动处理为“重写基类的虚析构函数”(编译器会把所有析构函数的名字统一处理为 destructor ,所以满足“函数名、参数、返回值一致”的重写规则);
  3. 当用基类指针 A* p2 指向派生类对象 new B 时, delete p2 会触发动态多态:运行时根据 p2 实际指向的对象类型( B ),调用对应的析构函数 ~B() 。

3. 为什么调用 ~B() 后会接着调用 ~A() ?
 
这是派生类析构函数的默认执行规则:

  • C++中,派生类的析构函数执行时,会自动调用基类的析构函数(顺序是“先执行派生类析构的自定义逻辑,再执行基类析构”)。

具体到代码中:

  1. 执行 ~B() :先输出日志,再 delete _p (释放 B 自己的资源);
  2. ~B() 执行完毕后,编译器会自动插入调用基类 A 的析构函数 ~A() 的代码;
  3. 最终执行 ~A() ,输出日志。

4. 为什么不会造成内存泄漏?
 
内存泄漏的核心是“动态分配的内存未被释放”,而这段代码的资源释放是完整的:

  • B 中的 _p 是 new int[10] 动态分配的,在 ~B() 中被 delete _p 释放;
  • p2 指向的 B 对象本身,在 delete p2 时被销毁(先执行 ~B() ,再执行 ~A() ,最后释放对象本身的内存);
  • 所有动态分配的资源( _p 指向的数组、 p2 指向的 B 对象)都被正确释放,因此没有内存泄漏。

简单总结流程:

  • delete p2  → 触发多态调用 ~B()  →  ~B() 释放 _p  → 自动调用 ~A()  → 释放 p2 指向的对象内存 → 所有资源释放完成。

5. 那什么时候会造成内存泄漏?

当 ~A() 不加 virtual 时, delete p2 一定会造成内存泄漏,原因如下:

  1. 析构函数无法触发多态 : 当基类 A 的析构函数没有 virtual 时,它就不是虚函数,无法触发动态多态。此时 delete p2 (基类指针 A* )会按照指针的“声明类型”( A )静态绑定析构函数——只调用 ~A() ,不会调用 ~B() 。
  2. 派生类的资源未被释放 : B 类中 _p 是通过 new int[10] 动态分配的内存,而释放 _p 的逻辑写在 ~B() 中。由于 ~B() 没有被调用, _p 指向的数组内存永远不会被 delete ,这部分内存就会成为“泄漏的资源”。

总结

  • ~A() 不加 virtual  →  delete p2 仅调用 ~A()  →  ~B() 未执行 →  _p 指向的数组内存无法释放 → 发生内存泄漏。

注意:这个问题面试中经常考察,一定要结合类似上面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

当基类指针(或引用)指向派生类对象,且通过该基类指针(或引用)销毁对象(即执行 delete 操作)时:

  1. 若基类析构函数未声明为 virtual ,则析构函数调用将采用静态绑定——编译器仅根据指针(或引用)的声明类型(基类类型) 匹配析构函数,仅执行基类的析构函数,派生类的析构函数不会被调用;
  2. 派生类中若包含动态分配的资源(如示例中 B 类的 _p 指针指向的堆内存),其资源释放逻辑通常封装在派生类析构函数中。若派生类析构函数未执行,将导致该部分资源无法释放,引发内存泄漏;
  3. 若基类析构函数声明为 virtual ,则析构函数成为虚函数,满足C++虚函数重写规则编译器对析构函数名称做统一处理,派生类析构函数自动重写基类虚析构函数)。此时析构函数调用将采用动态绑定——运行时根据指针(或引用)实际指向的对象类型(派生类类型) ,先执行派生类析构函数(释放派生类资源),再自动执行基类析构函数(释放基类资源),确保对象完整销毁,避免资源泄漏。

综上,为保证通过基类指针(或引用)管理派生类对象时,能通过多态机制触发完整的析构流程(派生类→基类),符合C++面向对象中“资源正确释放”的设计原则,基类析构函数建议声明为虚函数。

C++11 override和final

从上面的内容可以看出,C++对于重写的检查比较严格,那么有时候因为疏忽导致派生类可能没有办法完成虚函数的重写,那么在C++11中提供了两个关键字 override 和 final 来帮助用户检查是否进行了重写。

override

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

  1. override要放在要被检查的派生类的虚函数的声明的最后

  • 使用override关键字检查派生类的虚函数是否进行了重写,由于此时修改了派生类中的虚函数的函数参数,使其与基类的虚函数不构成重写条件,那么此时派类的虚函数在基类中就没有对应的虚函数,也就没有进行虚函数的重写,那么此时编译报错

final

  1. final修饰虚函数,表示该虚函数不能被重写
  2. final同时还可以修饰类,表示该类不能被继承

注意点:

override:

  • 只能写在派生类的虚函数后,作用是强制检查当前函数是否真的重写了基类的虚函数(若不满足重写规则,编译器直接报错)。它是派生类的“重写校验工具”,基类虚函数不能加  override 。

final:有两种用法,对应不同位置:

  • 1. 写在基类的虚函数后:限制该虚函数不能被任何派生类重写(派生类若尝试重写,编译器报错);
  • 2. 写在类名后(基类/派生类均可):限制该类不能被继承(无论基类还是派生类,加了  final  就不能当父类)。

如何禁止类被继承?

在一些特殊的场景下,我们不想要我们的类被继承,那么应该如何做呢?

C++98的做法:

使被继承的类的构造函数私有化

属于间接禁止继承:子类构造时必须调用基类构造,但基类构造私有,子类无法访问,所以继承后也没法创建子类对象——相当于“继承了也没用”,间接阻断了继承的意义。

class A
{
private:A(){}
};
class B :public A
{
public:B(){}
};int main()
{B b;return 0;
}

运行结果如下:

报错的原因是 : 子类B无法调用父类A的私有构造函数,导致子类对象无法创建。

  1. 父类A的构造函数 A() 被声明为 private ,仅允许A类内部访问。
  2. 子类B继承A时,创建B的对象 b 会先自动调用父类A的构造函数。
  3. 但B作为子类,没有权限访问A的私有成员(包括私有构造函数),编译直接报错。

只要是用 public / protected 继承(非私有继承),创建子类对象时,编译器会自动、强制地先调用父类构造函数,完成父类部分初始化后,再执行子类构造函数——这个顺序由语法硬性规定,程序员无法修改。

上面的代码:当我们把A的构造设为private,虽然''A类被禁止继承''了 , 但是意外导致“主函数无法创建A对象”——所以导致报错,所以我们在解决时要使A类禁止被继承的同时还要保证主函数能创建A对象。这时候就有了下面的改进代码:

优化方案:

优化后的方案:主动用private构造精准实现“禁止继承”,再用静态函数补上“主函数能创建A对象”的能力——这是有明确目标的设计,既达成了“禁继承”,又保留了A类自身的可用性。

输出结果:

  1. 把 A 的构造函数设为 private ,让子类(比如B)无法访问,从而禁止A被继承(子类B无法实例化)。
  2. 给 A 加 static A getA() 函数,让外部能通过这个静态接口获取A的对象(避免A自身无法使用)。因为静态函数属于类本身,有权访问类的私有成员(包括私有构造 A() ),能合法返回A的对象
  3. 原代码中的B类若尝试继承A,编译会直接报错(因为B的构造无法调用A的私有构造),达成“禁继承”的目的。

那如果将被继承的类的析构函数私有化呢 ? (这里探究的只是私有化基类的析构函数 , 和前面通过私有化构造函数进行间接禁止类的继承要区分开) 

class A
{
private:~A(){}
};class B :public A
{
public:~B(){}
};int main()
{B b;return 0;
}

这段代码会编译报错,核心原因是:基类A的析构函数是private(私有)的,子类B无法访问,导致B的析构函数无法正常调用基类A的析构函数。
 
具体逻辑:

  1. 当创建子类对象 B b 时,会先调用基类A的构造函数,再调用子类B的构造函数
  2. 当 b 生命周期结束时,会先调用子类B的析构函数,再自动调用基类A的析构函数;
  3. 但A的析构函数是 private ,子类B没有访问权限,编译器直接判定“无法调用基类析构”,从而报错。

输出结果:

优化方案:

要实现“基类析构私有化 + 子类能正常继承析构”,核心是用 “友元(friend)”打破访问权限,同时严格遵循“先构后析”规则。改思路只有1种:让子类成为基类的友元,允许子类析构函数访问基类私有析构。

class A 
{// 关键:声明子类B是A的友元,让B的析构能访问A的私有析构friend class B;
private:~A() {} // 基类析构仍私有化
};class B : public A 
{
public:~B() {} // 子类析构正常公有,能访问A的析构
};int main() 
{B b; // 正常创建、正常析构,无报错return 0;
}

输出结果:

为什么只能这么改?

  • 基类析构私有化的核心矛盾是“子类析构无法访问”,而友元是C++唯一能让外部(子类)访问私有成员的方式。
  • 若不用友元,无论怎么改(比如子类析构私有化),最终都会卡在“子类析构调用基类析构时权限不足”,导致编译失败。

补充:

基类私有化析构函数的核心逻辑是要确保“先构造的后析构,后构造的先析构”,这是C++保证对象资源安全释放的核心规则,和“盖房子先打地基(A)再砌墙(B),拆房子先拆墙(B)再挖地基(A)”完全一致。
 
1. 构造顺序(A先B后)的本质:依赖关系
子类B继承了基类A,意味着B的成员可能依赖A的成员(比如B用了A的变量/函数)。

  1. 必须先把基类A构造好(初始化A的成员、资源),子类B才能安全地使用A的东西来构造自己。
  2. 若反过来先构造B,B用到A的成员时,A还没初始化,直接会出问题。

2. 析构顺序(B先A后)的本质:资源安全
析构的目的是释放资源,必须保证“谁后创建的资源,谁先释放”,避免资源泄漏或非法访问。

  1. 子类B的资源可能依赖基类A的资源(比如B的指针指向A分配的内存)。
  2. 若先析构A,A的资源被释放了,再析构B时,B可能还在访问A已释放的资源,直接崩溃;
  3. 所以先析构B(释放B自己的资源,不再依赖A),再析构A(释放A的资源),绝对安全。

简单说:构造是“从底层到上层”搭架子,析构是“从上层到底层”拆架子,顺序完全相反,只为保证依赖合法、资源安全。

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

1. 重载(Overload)

  • 作用域:同一类/同一作用域内
  • 关键特征:函数名相同,参数列表不同(类型/个数/顺序),返回值可同可不同
  • 本质:同一作用域下的“同名不同功能”函数,编译期根据参数匹配

2. 重写(覆盖,Override)

  • 作用域:继承体系的父类+子类(不同作用域)
  • 关键特征:函数名、参数列表、返回值完全相同(仅“协变”例外,即返回值是父/子类指针/引用),且父类函数必须是虚函数
  • 本质:子类重定义父类虚函数,运行期根据对象实际类型动态调用(多态的核心)

3. 隐藏(Hide)

  • 作用域:继承体系的父类+子类(不同作用域)
  • 关键特征:函数名相同,但不满足重写条件(比如父类不是虚函数、参数/返回值不同);父/子类同名成员变量也属于隐藏
  • 本质:子类同名元素“遮蔽”父类元素,编译期默认调用子类的版本

三、纯虚函数和抽象类

概念

  1. 在虚函数的后面加上=0,则这个函数为纯虚函数
  2. 包含纯虚函数的类称为抽象类(也叫做接口类),
  3. 抽象类不能实例化出对象,
  4. 派生类继承抽象类后也不能实例化出对象,
  5. 只有重写纯虚函数,派生类才能实例化出对象,但是重写后基类仍不能实例化出对象
  6. 纯虚函数规范了派生类必须重写纯虚函数,同时纯虚函数也更能体现接口继承

使用:

class A
{
public:virtual void fun1() = 0;virtual void fun2() = 0;
};class B :public A
{
};int main()
{A a;B b;return 0;
}

输出结果 : 报错

  1. 抽象类不能实例化出对象
  2. 派生类继承抽象类后也不能实例化出对象

重写纯虚函数后:

class A
{
public:virtual void fun1() = 0;virtual void fun2() = 0;
};class B :public A
{
public:virtual void fun1(){cout << "fun1()" << endl;}virtual void fun2(){cout << "fun2()" << endl;}
};int main()
{B b;b.fun1();b.fun2();return 0;
}

运行结果 : 运行正常

  1. 类 A 是抽象类(含纯虚函数),但子类 B 完整实现重写了所有纯虚函数,所以 B 是“具体类”,可以正常创建对象;
  2. 调用 b.fun1() / b.fun2() 时,直接执行 B 中重写的函数逻辑,符合C++多态的基础规则

但需要注意的是 : 此时类A是抽象类(包含纯虚函数),仍然无法直接创建对象。即使派生类重写了所有的纯虚函数,纯虚函数( virtual void fun1() = 0; )的作用就是将类标记为“抽象类”,强制要求子类实现这些函数,同时禁止抽象类自身实例化——无论你怎么写( A a; 或 new A; ),编译器都会直接报错“无法实例化抽象类”。

应用场景

虽然抽象类不可以定义对象,但是它可以定义指针或引用,它作为基类被派生类继承,那么派生类对基类的纯虚函数进行重写,将派生类对象传给抽象类的指针或引用进行调用,同样可以符合多态,不同的派生类对象进行调用呈现的状态不同

class A
{
public:virtual void fun1() = 0;
};class B :public A
{
public:virtual void fun1(){cout << "B_fun1()" << endl;}
};class C :public A
{
public:virtual void fun1(){cout << "C_fun1()" << endl;}
};void fun(A& a)
{a.fun1();
}
void fun(A* a) 
{a->fun1();
}int main()
{B b;C c;fun(b);  // 传B的引用,调用B::fun1()fun(c);  // 传C的引用,调用C::fun1()fun(&b); // 传B的地址,调用B::fun1()fun(&c); // 传C的地址,调用C::fun1()return 0;
}

1. 抽象类与纯虚函数的作用

  • A 是抽象类, fun1() 是纯虚函数——它的核心目的是定义“接口规范”,强制子类( B 、 C )必须实现该函数,同时允许用 A 的引用/指针接收任意子类对象。

2. 派生类对象传给抽象类引用的本质

  • fun(A& a) 中的 a 是抽象类 A 的引用,但实际传入的是 B / C 的对象——C++会自动将子类对象向上转型为父类引用,这是多态的基础。后面的传地址也是同样的道理

运行时,程序会根据 a 实际指向的对象类型( B 或 C ),调用对应的 fun1()即 B::fun1()或 C::fun1() ) , 这就是“运行时多态”

输出结果:

接口继承和实现继承

  1. 普通函数继承是一种实现继承,派生类继承了基类的函数,继承的函数可以使用,继承的是实现
  2. 虚函数的继承是一种接口继承,派生类继承基类虚函数的接口,目的是重写虚函数实现,进而达成多态,继承的是接口

所以如果不是为了实现多态,不要把函数定义为虚函数

四、多态的原理

虚函数表

在引进虚函数表前请先计算一下下面类A实例化出的对象a的大小

下面进行运行的机器环境是vs2022的x86即32位环境下进行讲解的,在该平台下,指针的大小为4字节,并且vs2022的编译器会在虚函数表的最后放0,即放nullptr指针,这个可以作为在vs2022进行打印虚表的依据(具体打印虚表后文会进行讲解)

class A
{
public:virtual void fun1()      //虚函数{cout << "fun1" << endl;}private:int _a;
};int main()
{A a;cout << sizeof(a) << endl;return 0;
}

输出结果:

  1. 通过运行结果我们知道a对象的大小为8个字节
  2. 这里为什么运行结果是8字节呢?,不是类实例化出的对象的大小只跟成员变量有关吗?a对象的中的成员变量只有一个_a,为什么不是4字节呢?接下来请继续向下阅读

那么接下来我们打开监视窗口查看一下对象_a实际情况

  1. 对象a中有一个_vfptr一个_a,对于_a是int类型的这个我们可以确定,对于_vfptr观察后面对应的类型是void**的二级指针,并且点开之后其中的类型是一个void*的指针,那么这个_vfptr就是一个指向存放地址的数组的指针
  2. 同时我们的fun1是虚函数,并且有一个void *的地址被放在了这个_vfptr指向的空间中,对于这个地址我们并不确定是否是虚函数fun1的,同时我们也不确定是不是普通的函数的地址也会被放在这个里面,所以接下来我们再继续增加几个成员函数进行更详细的测试

观察下面代码中的类A,增加一个虚函数fun2和一个普通函数fun3,那么进行运行代码,观察类a的大小是否仍然为8,同时打开内存窗口观察是否虚函数fun2会被放入函数指针数组中,是否普通函数fun3会被放入函数指针数组中 ?

class A
{
public:virtual void fun1(){cout << "fun1" << endl;}virtual void fun2(){cout << "fun2" << endl;}void fun3(){cout << "fun3" << endl;}private:int _a;
};int main()
{A a;cout << sizeof(a) << endl;return 0;
}

输出结果:

  1. 计算出来的结果仍然是8字节保持不变

那么接下来我们打开监视窗口详细查看一下对象a的详细情况

  1. 那么可以很明显的观察到a对象中还是有一个指针_vfptr,一个变量_a
  2. 并且_vfptr指针指向的空间中存放了两个函数指针,这两个指针分别指向了虚函数fun1和虚函数fun2,并且还有一点,这里的普通函数fun3的地址没有被放入_vfptr指向的空间中,那么就是证明这个_vfptr指针指向的空间中放置指针的a对象中的虚函数的地址
  3. 我们将这个_vfptr指针称作虚函数表指针,在x86环境32位下这个_vfptr虚函数表指针的字节大小为4个字节,如果是在x64环境64位下,这个_vfptr虚函数表指针的字节大小为8个字节,这个虚函数表指针指向的空间,我们称作虚函数表,虚函数表又称作虚表,这个虚函数表中存放虚函数的地址

单继承中的虚函数表

那么接下来再对上述代码进行改编,观察并探索一下继承关系中的基类和派生类的虚函数表是什么样子的?

class A
{
public:virtual void fun1(){cout << "fun1" << endl;}virtual void fun2(){cout << "fun2" << endl;}void fun3(){cout << "fun3" << endl;}private:int _a;
};class B :public A
{
public:virtual void fun1(){cout << "B_fun1" << endl;}
private:int _b;
};int main()
{A a;B b;return 0;
}

运行结果如下:

  1. 观察监视窗口 : 派生类对象b中也有虚函数表指针,对象b有两部分组成,一部分是基类继承下来的那一部分成员,其中虚表指针存在于从基类继承下来的那一部分中,还有一部分是派生类对象自己的成员
  2. 基类对象a和派生类对象b的虚表是不一样的派生类对象b完成了对虚函数fun1的重写,所以b对象的虚表中存储的是B::fun1(void),所以虚函数的重写也叫做覆盖,覆盖指的是虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理的叫法
  3. 另外由于虚函数fun2也被继承下来了,所以也被放进了派生类的虚表中,并且虚函数fun2没有重写的所以仍然为基类虚函数A::fun2(void),普通函数fun3也被继承下来了,但是由于fun3不是虚函数,所以不会放进虚表
  4. 虚函数表的本质是一个存放虚函数指针的数组,并且在vs2022的默认情况下都会在数组的末尾放置0,即nullptr指针,后文我们即将要讲的虚表的打印就要使用到这个nullptr指针作为临界条件的判断

派生类虚表的生成规则:

  • (1)先将基类的虚函数表拷贝一份放到派生类的虚函数表中
  • (2)如果派生类对基类的虚函数进行了重写,那么就将派生类重写的虚函数覆盖到派生类的虚函数表上的对应基类的虚函数上
  • (3)派生类新增加的虚函数,按照其在派生类中声明的次序依次增加到派生类继承的第一个虚函数表后面

虚函数和虚函数表的存储位置

虚函数和虚函数表针对初学者是很“诡异”的,因为带上了这个虚,所以让很多人都无法揣测它们的位置,但是对于虚函数其实和普通的函数存放的位置无异,都是在代码段中,但是对于这个虚表(虚函数表),其究竟是在栈,堆,数据段(静态区),还是代码段(常量区)呢?

class A
{
public:virtual void fun(){cout << "fun()" << endl;}
};int main()
{int a = 0;         //普通的变量存储在栈上int* ptr = new int;//动态开辟是在堆上static int b = 0;  //静态变量存储在数据段(静态区)const char* str = "hello cpp";//常量字符串是在代码段(常量区)cout << "栈的地址:" << &a << endl;cout << "堆的地址:" << ptr << endl;cout << "数据段的地址:" << &b << endl;cout << "代码段的地址:" << (void*)str << endl;//这里对于str字符串是指向首个字符串的地址但是如果直接使用cout的形式进行打印//那么会被cout识别为字符串进行打印,这时候我们进行强制类型转换为void*的指针//这时候使用cout进行打印那么打印的就为常量字符串的地址了A c;cout << "虚表的地址为:" << (void*)*((int*)&c) << endl;//虚表即为虚函数表,虚函数表的地址就是在对象c的虚函数表指针,虚函数表指针在//vs中是指针,x86的环境32位为4个字节,整形恰好为4个字节,由于虚函数表指针//和对象c中的其它成员变量是依次存储的,并且虚函数表指针(虚表指针)存储到对//象c的存储空间的最开始的位置上,由于虚表指针为4个字节,那么我们取出对象c的//地址将其强转为int*的类型,那么就可以借助整形指针去访问4个字节的空间,我们//知道指针的类型影响的是指针访问的范围,如果是char*的指针访问的就为1个字节,//如果为int*的指针那么访问空间的大小范围就为4个字节,那么就可以借助整形指针//访问对象c的前4个字节的,即此时int*的指针就指向的是对象c的前4个字节,即int*//的指针就为对象c的前4个字节的地址,此时进行对整形指针进行解引用就可以拿到//对象c的前四个字节存放的虚表指针的数据,由于是采用整形指针解引用访问的虚表//指针的数据,所以这个数据就不是指针类型的了,而是int类型的,如果此时直接对//这个int类型的数据进行打印,那么我们打印的是10进制的数据和地址的16进制不符//,那么我们将其强转为void*的指针类型即可,此时进行打印就为无符号指针的类型//即打印出了虚表指针return 0;
}

运行结果如下:

  1. 那么我们进行观察比对,打印的地址为16进制的,进行计算的时候不要忘记进行转换为10进制的,那么经过计算比对,发现虚表的地址和代码段的地址举例最近,并且相差10几个字节,和数据段相差几千个字节
  2. 那么我们就可以得出虚表存储在代码段的可能性最大,所以经验证虚表存储在代码段

多态的原理

对于函数的调用,普通对象看的是类型,基类的指针或引用符合多态那么看的是指向的对象

普通函数的调用

在引进多态的原理前,我们先分析一下普通函数的调用

void fun()
{cout << "fun()" << endl;
}int main()
{fun();return 0;
}

调试,查看反汇编如下,对于普通函数的调用,由于不符合多态,那么编译器在编译的时候就已经确定了要调用函数的地址了

多态的原理

以下面继承存在关系,派生类重写了基类的虚函数,并且符合三同(协变除外)(函数名相同,参数类型相同,返回值相同),使用基类的指针或引用进行调用,即符合多态的代码进行讲解

class Person
{
public:virtual void BuyTicket(){cout << "买票全价" << endl;}
};class Student :public Person
{
public:virtual void BuyTicket(){cout << "买票半价" << endl;}
};void fun(Person& ps)
{ps.BuyTicket();
}int main()
{Person ps;Student st;fun(ps);fun(st);return 0;
}

调试,查看反汇编如下

  1. eax中在这个场景下存储虚函数表中的虚函数的指针
  2. 运行时,到基类指向的对象的虚函数表中找到对应虚函数的地址进行调用
  • (1)例如基类的指针或引用指向基类的对象,那么就是去基类对象的虚函数表中找到对应的虚函数的地址去进行调用
  • (2)例如基类的指针或引用指向的是派生类的对象,那么就是去派生类对象的虚函数表中去找到对应的虚函数的地址去进行调用

为什么是基类的指针或引用进行调用?

  1. 我们知道派生类对象的指针或引用可以赋值(此时发生了赋值兼容,切片,并且切片不产生临时对象)给派生类的指针或引用,此时的基类的指针或引用就指向派生类对象
  2. 同时基类的指针或引用还有可能指向基类对象
  3. 那么综上,当使用基类的指针或引用的时候,那么这个基类的指针或引用就有可能指向基类对象或派生类对象,同时如果基类中有虚函数,并且派生类对这个虚函数进行了重写,那么此时基类对象中的虚表中存储的就是基类的虚函数的指针,派生类对象中的虚表中存储的就是派生类进行重写后覆盖的虚函数的指针
  4. 当进行调用的基类的指针或引用指向基类对象的时候,那么就会去基类的虚表中去找到对应虚函数的指针进行调用,此时就调用了基类的虚函数
  5. 当进行调用的基类的指针或引用指向的是派生类对象的时候,那么就会去派生类对象的虚表中寻找派生类重写进行覆盖的虚函数的地址,此时就调用了派生类的虚函数,那么这样就实现了多态

为什么不能是派生类的指针或引用进行调用?

派生类的指针或引用可以指向派生类对象,但是派生类的指针或引用不能指向基类对象,所以尽管派生类对基类的虚函数进行了重写,那么进行调用的时候,由于多态调用的时候看的是指向的对象,那么派生类的指针或引用这辈子都不能指向基类对象,也就无法完成运行时,去基类的虚函数表中找到对应虚函数的地址去进行调用,所以不行

为什么不能是基类的对象进行调用?

因为虽然派生类对象可以赋值给基类对象,但是虚函数表不会进行赋值,即派生类对象的虚表不会赋值给基类对象的虚表,基类对象的虚表仍为原虚表保持不变,即派生类对象中重写的虚函数的地址不会赋值到基类的虚表中,基类对象的虚表中仍然为基类的虚函数的地址,那么进行多态调用的时候,原理就是根据指向对象的虚表中的虚函数的地址进行调用,此时使用基类对象,这辈子它的虚表中存储的虚函数的地址都不为派生类中对基类进行重写的虚函数的地址,所以也自然就没有办法使用基类的对象进行调用去实现多态

在类中同类型的对象共用一张虚表

class A
{
public:virtual void fun1(){cout << "A_fun1()" << endl;}virtual void fun2(){cout << "B_fun2()" << endl;}private:int _a;
};int main()
{A a1;A a2;return 0;
}

运行结果如下

  1. a1和a2的类型相同都为A类,此时这张虚表对于A类实例化出的全部对象都是通用的,a1对象和a2对象的虚表指针相同,虚表的内容也完全相同

静态绑定与动态绑定

  1. 静态绑定又称前期绑定(早绑定),在程序编译期间就确定了程序行为,又称静态多态,比如函数重载
  2. 动态绑定又称晚期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定具体的行为,去调用具体的函数,也称为动态多态,比如多态

五、一些关于多态的问答题

 1. 什么是多态?

  • 多态分为静态多态和动态多态
  • 静态多态是在编译时就确认调用地址,如函数重载。动态多态是在运行时根据实际指向的对象去找到对应的函数进行调用,如多态,其要符合虚函数的重写和基类的指针和引用进行调用才能实现多态

2. 什么是重载,重写(覆盖),重定义(隐藏)?

  • 重载是函数重载:在同一个作用域下,函数名相同,参数不同的函数就构成重载
  • 重写(覆盖):分别在基类和派生类的不同作用域下,派生类继承基类,基类中有虚函数,派生类和基类的虚函数符合三同(协变除外),即函数名相同,返回值相同(斜边除外),参数列表相同,在这样的条件下,派生类对虚函数的实现进行编写的情况是重写(覆盖)
  • 重定义(隐藏):分别在基类和派生类的不同作用域下,派生类继承基类,派生类和基类中的函数只需要函数名相同即构成隐藏
  • 派生类和基类中的同名函数不构成重写(覆盖)就构成重定义(隐藏)

3. 多态实现的原理?

  • 静态多态中的函数重载:函数名修饰规则,传入不同的参数根据函数名修饰规则去对应匹配参数匹配的函数进行调用
  • 动态多态中的多态:虚函数表,对于基类的虚函数表中存放虚函数的地址,如果派生类对基类的虚函数进行了重写,那么对应派生类的重写的虚函数会被覆盖到派生类中基类的虚函数表中,在运行时,根据基类的指针或引用实际指向的对象的虚函数表中寻找对应的虚函数的地址进行调用

4. inline函数可以是虚函数吗?

  • 可以,不过此时编译器会忽略inline属性,这个函数不再是内敛inline,不会在调用的地方进行展开了,而是虚函数,因为虚函数要放到虚表中,inline的函数没有地址,所以也就不能放到虚表中,所以编译器为了虚函数可以放到虚表中会将inline的属性进行忽略

5. 静态成员可以是虚函数吗?

  • 不可以,静态成员没有this指针,虚函数被放到虚表中,在对象中虚表通过虚函数指针进行访问呢,要访问对象中的虚函数指针又需要this指针,所以如果是静态成员由于没有this指针无法找到虚表中进行调用,所以也就无法实现出多态,所以无法是虚函数

6. 构造函数可以是虚函数吗?

  • 不可以,因为对象中的虚函数表指针是在初始化列表初始化的,如果构造函数是虚函数,那么调用虚函数需要先有对象,使用对象中的虚函数表指针找到虚表中的地址才能进行调用,构造函数的作用是构造初始化对象,连对象都没有也就没有虚函数表指针,所以自然不可以

7. 析构函数可以是虚函数吗,什么场景下析构函数是虚函数?

  • 可以,假设有两个类A和B,B继承了A,A* a = new B,delete a;并且这种场景可能实际场景会使用,所以可能的话,尽量将析构函数定义为虚函数

8. 对象访问普通函数快还是访问虚函数快

  • 如果是这个对象是普通对象,那么编译器检查不构成多态,会直接call地址进行调用,所以访问是一样快的
  • 如果这个对象是基类的指针或引用,那么普通对象快,因为构成多态,在运行的时候会去虚函数表中去查找对应虚函数的地址进行调用

9. 虚函数表是在什么阶段生成的,存放到哪里?

  • 虚函数表是在编译阶段生成,因为编译阶段虚函数的地址都已经确定,所以编译器会根据虚函数的地址直接生成虚函数表,虚函数表存放到代码段(常量区),这个小编已经带领大家在上文中验证过了

10. 菱形继承的问题,虚继承的原理是什么?

  • 菱形继承的问题是数据冗余和二义性问题,虚继承是将具有两个及以上基类的派生类对象中基类的相同的成员独立成一份公共成员,放到派生类对象存储空间的开头或结尾位置(在vs中是放置到派生类对象存储空间的结尾位置),在基类的原位置放置一个指针,这个指针叫做虚基表指针,这个指针指向虚基表,虚基表是用来找虚基类的,表中存储虚基表指针到公共成员的地址的偏移量,根据这个偏移量就可以找打公共成员

11. 什么是抽象类,抽象类的作用是什么?

  • 包含纯虚函数的类就叫做抽象类,抽象类的作用是强制继承抽象类的派生类重写虚函数,同样抽象类体现出了接口继承的关系
http://www.dtcms.com/a/585774.html

相关文章:

  • 合肥企业网站建设网站图片怎样做seo优化
  • 短剧小程序 2025 核心痛点分析:内容、技术与合规的三重困境
  • 河南省住房和城乡建设厅网站查证网站前台右侧怎么做二维码
  • 从原理到实操:ddraw.dll是什么?为何游戏启动时频繁提示“找不到ddraw.dll”?解决思路全解析
  • 计算机网络自顶向下方法39——网络层 中间盒 互联网架构原则(IP沙漏 端到端原则)
  • 广州有做虚拟货币网站视频创作用什么软件
  • wap网站和app开发正邦集团招聘
  • RV1126 NO.43:OPENCV形态学基础之二:腐蚀
  • 算法学习 24 使用集合解决问题
  • Java基础——集合进阶3
  • Ascend C 编程模型揭秘:深入理解核函数与任务并行机制
  • 算法题——002
  • 佛山微信网站开发易语言网站开发教程
  • 从零搭建PyTorch计算机视觉模型
  • 培训课程网站建设淮阳 网站建设
  • 服务器为何成为网络攻击的“重灾区“?
  • Linux rcu机制
  • ES 总结
  • j集团公司的网站建设石家庄百度推广优化排名
  • k8s-node-NotReady后如何保证服务可用
  • 5-GGML:看一下CUDA加法算子!
  • 做网站优化需要做哪些事项wordpress圆圈特效
  • 濮阳网站建设费用网站怎样做外链
  • Docker 部署 Java 项目实践
  • Git push/pull 避坑指南:什么时候加 origin?什么时候不用加?
  • Ubuntu22.04系统中各文件目录的作用
  • 49_AI智能体核心业务之使用Flask蓝图模块化AI智能体服务:构建可维护的RESTful API
  • 网站建设教程数据库网站开发兼职成都
  • 网站 空间 下载行情网免费网站大全
  • 深度学习实战(基于pytroch)系列(五)线性回归的pytorch实现