一、多态的概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某种行为,当不同的对象去完成时会产生出不同的状态。
我们可以举一个例子:
比如买票这种行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
再举一个例子:
会最近为了争夺在线支付市场,支付宝年底经常做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5毛....。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。
二、多态的定义与实现
2.1 虚函数
虚函数:即被 virtual 修饰的类成员函数称为虚函数。
class Person
{
public:virtual void BuyTicket(){std::cout << "买票-全价" << std::endl;}
}
虚函数(virtual function)是面向对象编程(OOP)中的一个重要概念,特别是在C++等支持多态的语言中。虚函数允许在子类中重写(覆盖)父类中的函数,达到动态绑定或运行时多态的效果。下面是虚函数的一些关键要点:
2.1.1 虚函数的基本定义
虚函数是在基类中声明的函数,其声明前使用关键字 virtual。虚函数的作用是允许在派生类中重新定义该函数,并且在运行时,程序会根据对象的实际类型来决定调用哪一个版本的函数,而不是根据指针或者引用的类型来决定。
2.1.2 虚函数的语法
在基类中声明一个虚函数的基本语法如下:
class Base {
public:virtual void show() {std::cout << "Base class show()" << std::endl;}
};
2.1.3 虚函数的作用
虚函数的主要作用是实现多态性,即允许通过基类指针或引用来调用派生类的重写函数。
class Derived : public Base {
public:void show() override {std::cout << "Derived class show()" << std::endl;}
};
如果你使用基类指针或引用指向派生类对象,调用虚函数时,实际执行的是派生类中的版本,而不是基类中的版本:
Base* basePtr;
Derived derivedObj;basePtr = &derivedObj;
basePtr->show(); // 输出:Derived class show()
在这个例子中,show()
函数的调用会根据basePtr
指向的实际对象(derivedObj
)来决定使用派生类中的show()
函数,而不是基类中的show()
。
2.1.4 虚函数与静态绑定/动态绑定
- 静态绑定:即编译时决定调用哪个函数,通常发生在没有使用虚函数的情况下。
- 动态绑定:即运行时决定调用哪个函数,这就是虚函数的作用所在。
在虚函数的情况下,程序在运行时通过动态绑定来决定实际调用的是基类的还是派生类的函数。
2.1.5 虚函数的析构函数
通常,基类的析构函数也应该声明为虚函数。这是因为,如果通过基类指针删除派生类对象,虚析构函数可以确保派生类的析构函数被调用,从而避免资源泄漏。
class Base {
public:virtual ~Base() {std::cout << "Base class destructor" << std::endl;}
};class Derived : public Base {
public:~Derived() override {std::cout << "Derived class destructor" << std::endl;}
};
如果没有虚析构函数,删除派生类对象时可能只会调用基类的析构函数,导致派生类部分没有正确释放,造成资源泄漏。
2.1.6 纯虚函数
当一个类中的虚函数没有具体的实现时,称其为纯虚函数。纯虚函数在基类中只声明,并且在函数声明的末尾加上= 0
。包含纯虚函数的类叫做抽象类,抽象类不能直接实例化。
class Base {
public:virtual void show() = 0; // 纯虚函数
};
Base
类是一个抽象类,不能直接实例化。必须通过继承并提供show()
函数的实现才能创建派生类对象。
2.1.7 虚函数的性能开销
使用虚函数会有一定的性能开销,因为系统需要在运行时查找函数的实际实现。这通常通过虚函数表(VTable)来实现。每个类中包含一个虚函数表,表中存储了指向虚函数实现的指针。调用虚函数时,程序会查找虚函数表并调用相应的函数。因此,虚函数调用比普通函数调用稍慢,但这种开销通常可以忽略不计,除非在非常性能敏感的代码中。
2.1.8 总结
虚函数是面向对象编程中实现运行时多态性的重要工具。它使得通过基类指针或引用调用派生类重写的方法成为可能,从而提高了程序的灵活性和扩展性。在实际应用中,虚函数常用于需要多态行为的场景,比如图形库中的形状对象(如Circle
、Rectangle
等)都可以通过基类指针调用draw()
函数,而不需要关心具体是哪一种形状。
2.2 多态的构成条件
多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。比如 Student 继承了 Person,Person 对象买票全价,Student 对象买票半价。
那么在继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
2.3 虚函数的重写
在面向对象编程中,虚函数重写是指在派生类中重新定义和实现基类中的虚函数。通过重写,派生类可以改变或者扩展基类提供的功能。虚函数重写是实现运行时多态的核心机制。
2.3.1 虚函数重写的条件
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类和基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
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;
}
注意: 在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用),但是如果基类虚函数没有写 virtual,则不会构成多态。
建议:两个虚函数都加上 virtual
2.3.2 虚函数重写的两个例外
2.3.2.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.3.2.2 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类的析构函数只要定义,无论是否加上 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类函数的名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor。
下面,这一份代码就是另外一种情况,基类的析构函数没有进行加上 virtual,这就没有构成多态,当去析构子类的时候,只会调用父类的析构函数,造成内存泄露。
class Person {
public:virtual ~Person() {cout << "~Person()" << endl;}
};class Student : public Person {
public:virtual ~Student() { cout << "~Student()" << endl; }
};// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}
2.3.3 普通调用和多态调用
普通调用(静态绑定调用)和多态调用(动态绑定调用)是 C++ 中函数调用的两种基本方式,他们有着本质的区别,尤其体现在运行时函数选择的方式上。下面,我们来详细解释他们的概念和区别:
2.3.3.1 普通调用(静态绑定调用)
普通调用是指在编译时就确定了调用的函数,编译器通过函数的名字、参数和作用域来确定该调用。普通调用发生在编译时,也称为静态绑定或早绑定。
特点:
- 编译时确定调用函数。
- 适用于非虚函数。
- 函数调用和具体的函数实现是静态绑定的,编译器可以直接决定调用哪个函数。
class Base {
public:void display() { // 非虚函数std::cout << "Base display" << std::endl;}
};class Derived : public Base {
public:void display() { // 非虚函数std::cout << "Derived display" << std::endl;}
};int main() {Base b;Derived d;b.display(); // 普通调用,调用的是 Base 的 displayd.display(); // 普通调用,调用的是 Derived 的 displayreturn 0;
}
在上述代码中,display()
是普通的非虚函数调用。当b.display()
和d.display()
被调用时,编译器在编译时根据对象的类型(Base
或 Derived
)确定调用哪个函数,这种调用方式称为静态绑定。
2.3.3.2 多态调用(动态绑定调用)
多态调用是指在程序运行时,基于实际对象的类型来确定调用哪个函数,而不是编译时就决定。这种机制依赖于虚函数和继承关系,通常会使用虚函数来实现,称为动态绑定或晚绑定。
特点:
- 运行时决定调用的函数。
- 适用于虚函数(需要在基类中声明为
virtual
)。- 通过对象的实际类型来决定调用哪个函数,而不是通过声明时的类型来决定。
class Base {
public:virtual void display() { // 虚函数std::cout << "Base display" << std::endl;}
};class Derived : public Base {
public:void display() override { // 重写基类虚函数std::cout << "Derived display" << std::endl;}
};int main() {Base* b = new Derived(); // 基类指针指向派生类对象b->display(); // 多态调用,调用的是 Derived 的 displaydelete b;return 0;
}
在这个例子中,Base
类中的display()
函数是虚函数。在main
函数中,基类指针b
指向了Derived
类的对象。调用b->display()
时,由于函数是虚函数,编译器在运行时会根据对象的实际类型(Derived
)来决定调用Derived
类中的display()
函数,而不是基类Base
中的display()
函数。这种调用方式就是多态调用,它依赖于运行时的动态绑定。
2.3.3.3 区别
2.3.3.4 怎么判断是普通调用还是多态调用
判断是普通调用还是多态调用的关键在于是否涉及到继承和方法重写(或者接口实现)。具体来说,可以通过以下几个方面来判断:
类的继承结构
- 普通调用:通常是在同一个类中直接调用方法,没有涉及继承关系。
- 多态调用:通常会涉及到父类和子类的继承关系,在父类中定义了方法,在子类中重写(Override)了该方法。
方法重写
- 普通调用:在调用方法时,如果没有涉及子类重写父类的方法,调用的就是父类定义的普通方法。
- 多态调用:如果存在方法重写,且在父类引用指向子类对象时,实际调用的是子类重写的方法,而不是父类的版本。
引用类型
- 普通调用:调用的方法是根据引用类型(例如
A
类的引用)来确定的。 - 多态调用:调用的方法是根据实际对象的类型(即运行时的类型)来决定的,而不是引用类型。例如,当使用父类引用指向子类对象时,运行时会调用子类中的方法。
是否使用 virtual 和 override
- 普通调用:没有使用
virtual
(在父类方法上),或没有使用override
(在子类中)。 - 多态调用:在父类方法上使用了
virtual
,并在子类中使用了override
。
方法调用时的实际对象模型
- 普通调用:通常在调用方法时,方法调用根据编译时确定的引用类型来决定。
- 多态调用:即使引用类型是父类类型,实际调用的也是子类的重写方法,这是基于对象的实际类型(运行时类型)来决定的。
当子类调用子类的虚函数时,这依然属于普通调用,因为这种情况是在同一个类中进行的,且没有涉及到父类和子类之间的多态特性。
2.4 C++11 的 override 和 final
实现一个类,这个类不能被继承。
- 方法一:父类构造函数私有化,派送类实例不出来对象。
- 方法二:C++11 中的 final 修饰的类为最终类,不能被继承。
class A
{
private:A() {}
}// 方法二
class A final
{}
2.4.1 final
final 修饰虚函数,表示该旭函数不能再被重写
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() {cout << "Benz-舒适" << endl;}
};
2.4.2 override
override 检查派生类虚函数是否重写了基类某一个虚函数,如果美欧重写编译报错
class Car{
public:virtual void Drive(){}
};class Benz :public Car {
public:virtual void Drive() override {cout << "Benz-舒适" << endl;}
};
2.5 重载、覆盖(重写)、隐藏(重定义)的对比
2.5.1 重载
重载发生在同一个类中,当一个类中存在多个方法名称相同但参数列表不同的方法时。重载是基于方法签名(方法名称、参数类型、参数个数等)来区分的。
特点:
- 发生在同一个类中。
- 方法名相同,但参数不同(参数个数或类型不同),返回类型可以相同也可以不同(但通常只看参数区分)。
- 编译时决定调用哪个方法。
- 重载方法并不涉及继承和多态。
class MyClass {void print(int a) {System.out.println("Printing integer: " + a);}void print(double a) {System.out.println("Printing double: " + a);}void print(int a, double b) {System.out.println("Printing int and double: " + a + " and " + b);}
}
2.5.2 覆盖(重写)
覆盖发生在子类中,当子类继承父类的方法并重新定义该方法时。重写是基于方法签名相同来实现的。
特点:
- 发生在继承关系中,即父类和子类之间。
- 方法名、返回类型、参数列表相同,仅仅是子类重新定义了父类方法的实现。
- 动态绑定,方法的调用是在运行时决定的,依赖于对象的实际类型。
- 在子类中使用
@Override
注解标记(Java 中),可以避免方法签名不匹配时出现错误。
#include <iostream>
using namespace std;class Animal {
public:virtual void sound() { // 虚函数,允许子类重写cout << "Animal makes a sound" << endl;}
};class Dog : public Animal {
public:void sound() override { // 重写父类的 sound 方法cout << "Dog barks" << endl;}
};int main() {Animal* animal = new Dog();animal->sound(); // 调用的是 Dog 类中的 sound() 方法delete animal;return 0;
}
2.5.3 隐藏(重定义)
隐藏是指子类重新定义了父类中已经存在的方法、字段或属性,但这种重新定义并不属于覆盖(重写)的范畴。对于字段来说,隐藏是通过子类声明一个同名的字段来实现的;对于方法来说,它指的是子类声明一个与父类方法名称相同但签名不同的方法。
特点:
- 字段隐藏:子类中声明一个与父类字段名称相同的字段,这样父类的字段在子类中就被隐藏了。
- 方法隐藏:子类中声明一个与父类方法名称相同的方法,且方法签名不同,实际上并没有实现覆盖(重写)。这种情况下,父类的方法不会被动态调用,而是根据引用的类型决定使用哪个方法。
- 编译时决定:方法和字段的隐藏会根据引用的类型(而非实际对象类型)来决定使用哪个成员。
2.5.3.1 字段隐藏
#include <iostream>
using namespace std;class Animal {
public:int numLegs = 4; // 父类字段
};class Dog : public Animal {
public:int numLegs = 3; // 子类字段,隐藏父类字段
};int main() {Dog d;cout << "Dog has " << d.numLegs << " legs." << endl; // 输出 3Animal a;cout << "Animal has " << a.numLegs << " legs." << endl; // 输出 4return 0;
}
2.5.3.2 方法隐藏
#include <iostream>
using namespace std;class Animal {
public:void sound() {cout << "Animal makes a sound" << endl;}
};class Dog : public Animal {
public:void sound(int times) { // 方法隐藏,参数不同for (int i = 0; i < times; i++) {cout << "Dog barks" << endl;}}
};int main() {Dog d;d.sound(3); // 调用 Dog 类的 sound(int) 方法Animal a;a.sound(); // 调用 Animal 类的 sound() 方法return 0;
}
总结:
- 重载:同一类中多个方法名相同但参数不同,编译时决定。
- 覆盖(重写):子类重新定义父类的方法,方法签名相同,运行时动态绑定,支持多态。
- 隐藏(重定义):子类重新定义了父类的方法或字段,方法签名不同或字段名相同,编译时决定。
三、抽象类
3.1 概念
在虚函数的后面写上 = 0, 则这个函数称为纯虚函数。包含纯虚函数的类叫做抽象类(也叫做接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出接口继承。
3.2 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口,所以如果不实现多态,不要把函数定义为虚函数。
四、多态的原理
4.1 虚函数表
我们可以通过一道题目来引出虚函数表:
答案为:8
// 这里常考一道笔试题:sizeof(Base)是多少? class Base { public:virtual void Func1(){cout << "Func1()" << endl;} private:int _b = 1; };
通过观察测试我们发现 b 对象是 8 bytes,除了 _b 成员,还多了一个 _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;
};class Derive : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}
通过观察和测试,我们发现了以下几点问题:
- 派生类对象 d 中也有一个虚表指针,d 对象由两个部分构成,一部分是父类继承下来的成员,虚表指针也就是存在父类的和自己重写覆盖的,另一部分是自己的成员。
- 基类 b 对象和派生类 d 对象虚表是不一样的,这里我们发现 Func1 完成了重写,所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外 Func2 继承下来后是虚函数,所以放进了虚表,Func3 也继承下来了,但是不是虚函数,所以不会放进虚表中。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况下这个数据最后放了一个 nullptr。
- 总结一下派生类的虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中;
- 如果派生类重写了基类中的某一个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
- 派生类自己新增加的虚函数按其在派生类中的声明义序增加到派生类虚表的最后
- 这里还有一个容易混淆的问题:虚函数存在哪里?虚表存在哪里?注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段中,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现 vs 下是存在代码段的,Linux中是什么呢??
检验虚函数表在 vs 中的位置:
class Base
{
public:Base():_b(2){//cout << "Base()" << endl;}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(){cout << "Derive::Func1()" << endl;}virtual void Func3(){cout << "Derive::Func3()" << endl;}
private:int _d = 2;
};int main()
{Base b;Base b1;Base b2;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;printf("Base虚表地址:%p\n", *(int*)p3);printf("Base虚表地址:%p\n", *(int*)p4);return 0;
}
检验虚函数表在 Linux 中的位置:
#include <iostream>
using namespace std;class Base
{
public:Base(): _b(2){// cout << "Base()" << endl;}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(){cout << "Derive::Func1()" << endl;}virtual void Func3(){cout << "Derive::Func3()" << endl;}private:int _d = 2;
};int main()
{Base b;Base b1;Base b2;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;printf("Base虚表地址:%p\n", *(int *)p3);printf("Base虚表地址:%p\n", *(int *)p4);return 0;
}
4.2 多态的原理
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;
}
- 观察下图的红色箭头我们看到,p 是指向 mike 对象时,p->BuyTicket 在 mike 的虚表中找到虚函数是 Person::BuyTicket
- 观察下图的蓝色箭头我们看到,p 是指向 johnson 对象时,p->BuyTicket 在 jonhson 的虚表中找到虚函数是 Student::BuyTicket
- 这样就实现了不同对象去完成同一行为时,展示出不同的形态
- 反过来思考我们要达多态,有两个条件:一个是虚函数覆盖,一个是对象的指针或者引用调用虚函数
4.3 动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态
4.4 虚函数表和虚表指针是什么时候初始化的
虚函数表(VTable)和虚函数表指针(VPtr)的初始化通常与对象的构造过程相关,尤其是在对象的类型包含虚函数时。理解这两个概念对于深入理解 C++ 的面向对象机制以及多态的实现方式是非常重要的。以下是关于它们初始化时机的详细说明。
4.4.1 虚函数表
虚函数表是一个内部的数据结构,用于存储类的虚函数地址。每个含有虚函数的类(即包含至少一个虚函数的类)都有一个与之关联的虚函数表。
-
在类加载时(编译时):虚函数表本身是由编译器在程序加载时生成的,并且是与类本身关联的。虚函数表本质上是一个数组,其中的每个元素是指向虚函数的指针。每个类有且仅有一个虚函数表,它存储了该类的虚函数的地址。
-
在程序启动时初始化:虚函数表通常在程序的启动阶段被加载到内存中(例如,由操作系统的加载器或运行时环境)。每个类的虚函数表在程序启动时就已经初始化,实际上是在静态存储区域中维护的,并且对于所有的对象实例都是共享的。
4.4.2 虚函数表指针
虚函数表指针(VPtr)是每个对象实例中隐式存在的指针,指向该对象所属类的虚函数表。它的初始化时机紧密地与对象的构造过程有关。
-
在对象构造时初始化:虚函数表指针是由编译器自动生成的,并且会在对象的构造函数中被初始化。每当一个对象实例被创建时,虚函数表指针会被初始化为指向该对象所属类的虚函数表。
-
在构造函数中初始化:虚函数表指针的初始化通常发生在对象构造过程中。在 C++ 中,构造函数的执行顺序是:首先执行基类的构造函数,然后执行派生类的构造函数(如果有)。在基类的构造函数执行时,虚函数表指针已经指向基类的虚函数表(即使在派生类的构造函数执行之前,虚函数表指针已经是有效的)。如果对象是派生类的实例,虚函数表指针会在构造过程中被更新为指向派生类的虚函数表(具体取决于当前构造函数执行到的部分)。
-
如果是动态分配的对象(通过
new
创建),虚函数表指针会在分配内存后、构造函数执行之前初始化,确保对象的虚函数表指针正确地指向相应的虚函数表。
-
4.4.3 总结初始化时机
- 虚函数表(VTable):在程序加载时初始化,由编译器生成。它是与类相关的静态数据结构。
- 虚函数表指针(VPtr):在对象的构造函数中初始化。对于每个对象实例,虚函数表指针会在对象构造期间被设置为指向相应类的虚函数表。对于基类的构造函数,虚函数表指针指向基类的虚函数表;对于派生类的构造函数,虚函数表指针会在构造过程中更新为指向派生类的虚函数表。
#include <iostream>class Base {
public:Base() {std::cout << "Base constructor\n";// 虚函数表指针已经指向 Base 的虚函数表}virtual void foo() {std::cout << "Base foo\n";}
};class Derived : public Base {
public:Derived() {std::cout << "Derived constructor\n";// 虚函数表指针会在此时指向 Derived 的虚函数表}void foo() override {std::cout << "Derived foo\n";}
};int main() {Base* b = new Derived();b->foo(); // 会调用 Derived::foo,展示了虚函数的多态性delete b;return 0;
}
执行流程
- 程序启动时,虚函数表会被生成并加载到内存中。
Base
类和Derived
类分别拥有自己的虚函数表。 - 当
Derived
对象被创建时,Base
类的构造函数会首先执行。在此时,虚函数表指针(vptr
)会被设置为指向Base
类的虚函数表。 - 当
Derived
类的构造函数执行时,虚函数表指针会被更新为指向Derived
类的虚函数表。 - 调用
foo()
时,因为vptr
已经指向Derived
类的虚函数表,最终调用的是Derived
类的foo()
函数。
因此,虚函数表和虚函数表指针的初始化是与对象的构造过程密切相关的,虚函数表是静态的,早期就初始化;虚函数表指针是动态的,随着对象的构造过程逐步设置。
五、单继承和多继承关系的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型没有什么特别研究的了。
5.1 单继承中的虚函数表
在单继承中,一个派生类从一个基类继承并可能重写一些虚函数。虚函数表的结构比较简单:每个类有一个虚函数表(VTable),该表包含指向该类的虚函数实现的指针。每个对象实例有一个虚函数表指针(VPtr),指向当前对象所属类的虚函数表。
#include <iostream>
using namespace std;class Base {
public:virtual void foo() {cout << "Base foo\n";}
};class Derived : public Base {
public:void foo() override {cout << "Derived foo\n";}
};int main() {Base* b = new Derived();b->foo(); // 输出 "Derived foo"delete b;return 0;
}
虚函数表结构:
- Base 类有一个虚函数表(VTable),它存储指向
Base::foo
的指针。 - Derived 类有一个虚函数表(VTable),它存储指向
Derived::foo
的指针。 - 对于
Derived
对象,虚函数表指针(VPtr)指向Derived
的虚函数表,调用foo()
时将调用Derived::foo
。
初始化过程:
- 在
Base
类的构造函数中,虚函数表指针指向Base
类的虚函数表。 - 在
Derived
类的构造函数中,虚函数表指针会被更新为指向Derived
类的虚函数表。
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;
}
通过观察下图的监视窗口中我们发现看不见 func3 和 func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug,那么我们应该如何查看d的虚表呢??下面我们使用代码打印出虚表中的函数。
代码打印虚表
typedef void(*VF_PTR)();// 打印虚表,本质打印指针(虚函数指针)数组
//void PrintVFT(VF_PTR vft[], int n)
//void PrintVFT(VF_PTR vft[])
void PrintVFT(VF_PTR* vft)
{for (size_t i = 0; vft[i] != nullptr; i++){printf("[%d]:%p->", i, vft[i]);VF_PTR f = vft[i];f();//(*f)();}cout << endl << endl;
}
5.2 多继承中的虚函数表
5.2.1 多继承中的虚函数表介绍
多继承涉及一个类从多个基类继承。在这种情况下,每个基类都有一个自己的虚函数表,因此在对象中需要存储多个虚函数表指针(VPtr)。每个基类的虚函数表指针将指向对应基类的虚函数表,派生类的虚函数表指针指向派生类的虚函数表。
#include <iostream>
using namespace std;class Base1 {
public:virtual void foo() {cout << "Base1 foo\n";}
};class Base2 {
public:virtual void bar() {cout << "Base2 bar\n";}
};class Derived : public Base1, public Base2 {
public:void foo() override {cout << "Derived foo\n";}void bar() override {cout << "Derived bar\n";}
};int main() {Derived d;d.foo(); // 输出 "Derived foo"d.bar(); // 输出 "Derived bar"return 0;
}
虚函数表结构:
- Base1 类有一个虚函数表(VTable1),存储指向
Base1::foo
的指针。 - Base2 类有一个虚函数表(VTable2),存储指向
Base2::bar
的指针。 - Derived 类有一个虚函数表(VTable3),它存储指向
Derived::foo
和Derived::bar
的指针。 Derived
对象中会有三个虚函数表指针(VPtrs),分别指向:Base1
的虚函数表(VTable1)Base2
的虚函数表(VTable2)Derived
的虚函数表(VTable3)
初始化过程:
- 当
Derived
类对象被创建时,虚函数表指针将指向Base1
、Base2
和Derived
类的虚函数表。基类的构造函数(Base1
和Base2
)会负责初始化这些虚函数表指针。 - 在构造过程中,
Derived
的虚函数表指针会被更新为指向Derived
类的虚函数表。
多继承的虚函数表布局:
- Base1 类:有一个虚函数表指针,指向
Base1
的虚函数表。 - Base2 类:有一个虚函数表指针,指向
Base2
的虚函数表。 - Derived 类:有一个虚函数表指针,指向
Derived
的虚函数表。
5.2.2 多继承中虚函数表的具体细节
多继承时,由于每个基类都有自己的虚函数表,编译器需要在对象中为每个基类维护独立的虚函数表指针。如果派生类重写了某个基类的虚函数,编译器会更新相应的虚函数表指针,确保调用的是正确的虚函数。
- 当
Derived
类对象创建时:- Base1 的虚函数表指针指向
Base1
的虚函数表。 - Base2 的虚函数表指针指向
Base2
的虚函数表。 - Derived 的虚函数表指针指向
Derived
的虚函数表,包含Derived
类重写的虚函数。
- Base1 的虚函数表指针指向
5.2.3 虚函数表指针的布局
对于多继承来说,虚函数表指针的存储顺序通常是由继承的顺序决定的。例如,如果一个类 D
继承了 B1
和 B2
,那么 D
的对象通常会首先存储一个指向 B1
的虚函数表指针,然后存储指向 B2
的虚函数表指针,最后是指向 D
自身虚函数表的指针。
在内存布局上,一个对象的虚函数表指针数组的顺序与继承的顺序有关。例如,Derived
对象的内存布局可能如下所示:
+------------------+ +-------------------+ +-------------------+
| VPtr to Base1 | ---> | VPtr to Base2 | ---> | VPtr to Derived |
+------------------+ +-------------------+ +-------------------+
5.2.4 总结
- 单继承:每个类有一个虚函数表,虚函数表指针在对象构造过程中初始化并指向当前类的虚函数表。
- 多继承:每个基类有独立的虚函数表,每个基类有一个虚函数表指针,派生类的虚函数表指针会指向每个基类的虚函数表,同时也会指向自己的虚函数表。
- 内存布局:多继承会在对象中存储多个虚函数表指针,按继承顺序布局。
虚函数表机制的设计使得 C++ 在处理继承和多态时能够有效地实现动态绑定,尽管多继承的情况下需要处理多个虚函数表指针的复杂情况。
为什么在多继承中,子类有自己的虚函数指针,不是使用父类的虚函数指针??
在C++的多继承中,子类会有自己的虚函数指针,而不是直接使用父类的虚函数指针,这是由于以下几个关键原因:
- 多继承中的虚函数指针独立性
在多继承的情况下,子类不仅继承了多个父类的属性和方法,还可能重写父类的虚函数。为了能够正确地支持动态多态和虚函数的调用,编译器必须为每个基类维护独立的虚函数表指针(VPtr)。这意味着每个父类的虚函数表(VTable)仍然存在,并且在派生类中每个父类都有一个指向其对应虚函数表的指针。这样,每个父类的虚函数都可以独立地被调用,而不会与其他父类的虚函数表发生冲突。
- 子类可能重写父类的虚函数
在多继承中,子类可能会重写某些基类的虚函数。如果子类直接使用父类的虚函数指针,那么在运行时就无法正确地调用到子类重写后的虚函数。
举个例子,假设有两个基类
Base1
和Base2
,并且它们各自有一个虚函数foo()
,同时子类Derived
重写了这两个虚函数。如果子类的虚函数表指针只是简单地继承父类的指针,那么当通过父类指针调用foo()
时,可能会调用到父类的版本,而不是子类的版本。为了确保动态多态正确性,编译器需要给每个父类维护独立的虚函数表指针。
- 虚函数表指针的布局
在多继承中,编译器会为每个基类创建独立的虚函数表,并为每个类对象创建多个虚函数表指针。在派生类对象中,每个基类的虚函数表指针指向对应基类的虚函数表,派生类的虚函数表指针指向派生类自己的虚函数表。
这种做法确保了即使在多继承的情况下,每个基类的虚函数能够正确地进行调用和重写。具体来说,派生类的虚函数表会包含指向它自己的虚函数实现的指针,而每个基类的虚函数表会包含指向基类的虚函数实现的指针。如果基类中的虚函数被子类重写了,那么虚函数表中的指针会指向子类的重写版本。
- 子类和父类的虚函数表的不同
即使父类和子类都定义了虚函数,子类的虚函数表通常会与父类的虚函数表不同。子类可能会在虚函数表中替换父类的虚函数指针,指向子类自己的实现。
- 避免虚函数调用冲突
如果没有子类自己的虚函数表指针,而是直接使用父类的虚函数指针,那么会有潜在的冲突。例如,如果父类有相同名称但不同实现的虚函数,或者在派生类中有重写的虚函数,直接使用父类的虚函数表指针就无法确保正确的动态绑定。每个类自己的虚函数表指针确保了正确的多态性,避免了父类指针或虚函数表指针间的冲突。
在多继承中,子类有自己的虚函数指针,而不是简单地使用父类的虚函数指针,主要是为了确保:
- 动态多态能够正确地调用到子类重写的虚函数。
- 每个基类的虚函数表指针可以独立维护,避免了不同基类虚函数的冲突。
- 确保每个基类在多继承中的虚函数都能够正确地调用,不受到其他基类的影响。
多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。