【C++】多态详解

作者阿润菜菜
📖专栏C++



什么是多态

多态是同一个行为具有多个不同表现形式或形态的能力。
多态是在继承的基础之上实现的,我们说继承是类设计层次的代码复用的一种手段,而多态则是在此基础上实现的多种形态,完成某一件事,可以由于对象的不同产生不同的完成结果,我们称这种现象为多态。 多态可以让程序具有良好的扩展性和通用性,也可以实现动态绑定和后期绑定。

多态的实现方式有两种:静态多态和动态多态。静态多态是在编译时就确定了调用的函数,如函数重载和运算符重载。动态多态是在运行时才确定调用的函数,如虚函数和纯虚函数。

一个简单的例子是,假设有一个基类Shape,它有一个虚函数area(),用来计算图形的面积。然后有两个派生类Rectangle和Circle,它们分别重写了area()函数,用来计算矩形和圆的面积。如果我们定义一个Shape类型的指针,它可以指向任何一个派生类的对象,并调用它们的area()函数,这就是多态的体现。

多态调用的实现 — 硬性条件

1 多态的条件 — 两种例外

要构成多态有两个条件 - - - 缺一不可:

  1. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写 — 要求三同(函数名、参数、返回值)
  2. 必须是父类的指针或引用去调用

虚函数:即被virtual修饰的类成员函数称为虚函数
在这里插入图片描述
虚函数重写还有两个例外:协变和析构函数重写

2 虚函数重写的特殊情况

  1. 子类中继承基类的虚函数可以不加virtual关键字,虽然可以不加,但还是建议大家加上,代码风格更为规范一些。
    在这里插入图片描述
    现象是什么?此处子类不写virtual关键字也可以,因为它继承了父类的接口(函数声明保持了一致),只是重写函数的实现 — 属于例外一

  2. 协变也是虚函数重写的特殊情况,三同中返回值可以不同,但是要求返回值必须是一个父子类关系的指针或引用,自己的父类或其他的父类都可以。实际并不常见,大家只要了解一下这个语法就够了。

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;}
};
  1. 虚函数的返回值除本身的父子类继承关系中的类型外,还可以是其他继承关系中的父子类指针或引用,例如下面虚函数的返回值分别是A *和B *,这也是协变的另一种场景。
class A
{

};
class B :public A
{

};

class Person
{
public:

	virtual A* BuyTicket() { cout << "Person:买票-全价" << endl; return nullptr; }
};
class Student : public Person
{
public:
	// 重写/覆盖:
	//父类和子类的函数,同为虚函数,满足三同(函数名返回值参数都相同)
	// 隐藏/重定义:
	//父类和子类的函数,只要函数名相同就构成隐藏。
	//隐藏可以看作是重写的子集。
	B* BuyTicket() { cout << "Student:买票-半价" << endl; return nullptr; }
};
class Soldier : public Person
{
public:
	B* BuyTicket() { cout << "Soldier:买票-优先" << endl; return nullptr; }
};
//协变真不常用,只有某些特殊情景下可能用到协变
  1. 析构函数的重写(基类与派生类析构函数的名字不同)
    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
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;
}

3 多态调用和普通调用区别

首先上结论:

  1. 不满足多态 — 看调用者的类型,调用这个类型的成员函数 — 普通调用
  2. 满足多态(卡死多态条件) — 看指向的对象的类型,调用这个类型的成员函数 — 多态调用

什么时候体现隐藏? — 子类调用时 调用同名函数
代码示例:

void Func1(Person p)
{
	p.BuyTicket();//和对象类型有关,不符合多态调用,这里只会调用父类Person的BuyTicket()
}
void Func2(Person& p)
{
	p.BuyTicket();
}
void Func3(Person* p)
{
	p->BuyTicket();
}
int main()
{
	Student st;
	Person pe;
	Soldier so;
	//Func1(pe);//引用就是引用的子类中父类的那一部分,指针指向的就是子类中父类的那一部分。
	//Func1(st);
	//Func1(so);
	Func2(pe);
	Func2(st);
	Func2(so);
	/*Func3(&pe);
	Func3(&st);
	Func3(&so);*/
	// 普通调用:跟调用对象的类型有关,
	// 多态调用:指针或引用指向的对象有关,指向子类调用子类的虚函数,指向父类调用父类的虚函数。
  //          假设你用传值引用,则形参对象永远都是基类对象,就算传的是派生类对象也不行,因为发生切片赋值,那么在调用函数
  //          的时候,永远调用的都是基类里面的函数,无法实现多态调用。
  //          而如果是指针或引用,他表面上看是基类指针,但他实际指向的对象是派生类对象,那么根据指针或引用指向的是派生类
  //          就可以实现派生类或基类函数的调用了,而不是像普通调用一样,永远指向的都是基类对象,只能调用基类函数。
  //
  //         

	return 0;
}

4 C++11中的 override 和 final关键字

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

用法:

override 用于保证在派生类中声明的重载函数,与基类的虚函数有相同的签名,否则编译器会报错。 这样可以避免因为函数签名不匹配而导致的虚函数覆写失败的问题。

final 用于阻止类的进一步派生和虚函数的进一步重写。 这样可以保护类的设计不被破坏或修改。
override 和 final 的位置应该放在 const , volatile 等其他关键字后边,但是应该在纯虚标记,也就是" =0 "的前边。另外, override final 和 final override 没有区别,只是后者读起来可能更顺一些。

final 还可以直接用在类上,紧跟着类名,表示这个类禁止任何其他类继承它,无论是 public 继承还是 private 继承。

override 的例子:

struct Base {
    virtual void doSomething(int i) const {
        std::cout << "This is from Base with " << i << std::endl;
    }
};

struct Derived : Base {
    virtual void doSomething(int i) override { // 如果不加 override,编译器不会报错,但是虚函数覆写失败
        std::cout << "This is from Derived with " << i << std::endl;
    }
};

void letDoSomething(Base& base) {
    base.doSomething(419);
}

int main() {
    Derived d;
    letDoSomething(d); // 输出结果: "This is from Derived with 419"
}

final 的例子:

class Base {
public:
    void doSomething() const {
        std::cout << "I did something" << dontChangeMe() << std::endl;
    }
private:
    virtual int dontChangeMe() const = 0;
};

class ChildOfBase : public Base {
private:
    int dontChangeMe() const final { // 如果不加 final,子类可以继续覆写这个函数
        return 419;
    }
};

class BadChildOfChild : public ChildOfBase {
    int dontChangeMe() const override; // ERROR,编译器报错,不能覆写被 final 修饰的函数
};

class DontDeriveFromMe final { // 如果不加 final,其他类可以继承这个类
    // ...
};

class Failure : public DontDeriveFromMe { // ERROR,编译器报错,不能继承被 final 修饰的类
    // ...
};

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

画了个思维导图:
请添加图片描述
它们有以下区别:

  • 重载是指在同一作用域中,同名函数的形式参数(指参数的个数、类型或者顺序)不同时,构成函数重载。重载发生在编译时,根据参数类型和个数确定调用哪个函数。
  • 覆盖(重写)是指派生类中存在重新定义的虚函数,其函数名、参数列、返回值类型必须同父类中的相对应被覆盖的函数严格一致,覆盖函数和被覆盖函数只有函数体不同,当派生类对象调用该同名函数时会自动调用子类中的覆盖版本。覆盖发生在运行时,根据对象的实际类型确定调用哪个函数。
  • 隐藏(重定义)是指不同作用域中定义的同名函数构成函数隐藏(不要求函数返回值和函数参数类型相同)。隐藏发生在编译时,根据对象的静态类型确定调用哪个函数。

  • 重载的例子:在同一个类中,定义了两个名为max的函数,一个接受两个int参数,一个接受两个double参数,根据不同的参数类型,调用不同的函数。
int max(int a,int b){
  return a >b?a:b;
};
double max(double a,double b){
  return a >b?a:b;
}
  • 覆盖(重写)的例子:在基类A中,定义了一个虚函数show,返回一个A类型的引用,在派生类B中,定义了一个同名同参数的虚函数show,返回一个B类型的引用(这是协变返回类型的情况),当用基类指针指向派生类对象时,调用show函数会根据对象的实际类型,执行派生类中的show函数。
class A{};
class B:public A{};
class Base{
public:
  virtual A& show(){
    cout <<"In Base"<<endl;
    return *(new A);
  }
};
class Derived:public Base {
public:
  //返回值协变,构成虚函数重写
  B& show(){
    cout <<"In Derived"<<endl;
    return *(new B);
  }
};
  • 隐藏(重定义)的例子:在基类A中,定义了一个无参的func函数,在派生类B中,定义了一个同名但有一个int参数的func函数,这样就隐藏了基类中的func函数,当用基类指针指向派生类对象时,调用func函数会根据指针的静态类型,执行基类中的func函数。
class A{
public:
  void func(){
    cout <<"member function of A"<<endl;
  }
};
class B:public A{
public:
  void func(int i){
    cout <<"member function of B"<<endl;
  }
};

抽象类

1 认识抽象类及其作用

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象

//抽象类
class Car//抽象类---不能实例化出对象
{
public:
	//纯虚函数所在类为抽象类
	virtual void Drive() = 0 //一般纯虚函数不写实现,写了也没啥用,因为其所在类无法实例化出对象
	{
		cout << "endl;" << endl; 
	} ;
};
class Benz :public Car
{
public:
 //如果不重写纯虚函数,则自然继承下来之后,派生类也会变为抽象类,自然也不能实例化出对象。
 //只有对纯虚函数进行重写之后,函数就不算纯虚函数了,派生类就不再是抽象类,就可以实例化出对象。抽象类强制子类重写纯虚函数
	virtual void Drive()//重写纯虚函数
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};
int main()
{
	BMW b;//如果BMW没有重写纯虚函数,则继承下来的纯虚函数就是原生的,那么BMW就是抽象类,抽象类是不能实例化对象的。
	//抽象类从某种程度上说,就是强迫子类重写纯虚函数。
	//override是检查虚函数是否重写,抽象类是强迫派生类重写纯虚函数,否则派生类无法实例化出对象。
	
}

抽象类的作用就是强制其派生类重写纯虚函数,比如Car他不是车的品牌,而Bmw和BenZ这些才是车的真正品牌,那么Car其实就是一个抽象类,他的作用就是强制Bmw和BenZ这样的类去重写纯虚函数。
另外抽象类也可以体现出来接口继承,重写的是虚函数的实现,继承的是虚函数的接口。

2 什么是接口继承和实现继承?

1. 虚函数的继承是接口继承,目的就是让子类重写虚函数,重写的是虚函数的实现,因为在继承时继承的是虚函数的接口。
2. 普通函数的继承是实现继承,将普通函数直接照搬到派生类里面,没有重写这样的情况发生。

此处构成多态了吗? — 没有接口继承 只是普通函数调用
在这里插入图片描述

多态的原理

1 虚函数表的虚函数覆盖

sizeof(Base)是多少?

class Base
{
public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
private:
 int _b = 1;
};

当一个类出现虚函数时,这个类实例化出的对象模型就会发生改变,即在监视窗口我们可以观察到,他的类成员除了成员变量外,还回多出一个虚表指针,这个虚表指针指向一个数组,数组里面存放的就是类中的虚函数的地址!我们叫这个指针数组为虚函数表,简称虚表,指向虚表的指针简称虚表指针。
在这里插入图片描述

在这里插入图片描述

注意不要把虚表和虚基表搞混啊

虚表和虚基表是C++中两种不同的表,用来实现多态和虚继承的机制。
虚表是存放虚函数地址的表,每个有虚函数的类都有一个虚表,每个该类的对象都有一个虚表指针,指向该类的虚表。当派生类继承基类时,会继承基类的虚表,并根据自己的情况修改或添加虚函数地址³。当通过基类指针调用虚函数时,会根据指针所指对象的实际类型,动态地查找虚表中对应的函数地址,从而实现多态。
虚基表是存放虚基类偏移量的表,每个有虚基类的派生类都有一个虚基表,每个该类的对象都有一个虚基表指针,指向该类的虚基表。当派生类继承基类时,如果使用了虚继承(virtual),则会在派生类中保留一个唯一的虚基类成员,避免数据冗余和二义性。当通过派生类访问虚基类成员时,会根据虚基表中记录的偏移量,找到正确的虚基类成员地址。

虚函数的地址会进入虚表 — 虚表的本质 : 函数指针数组 — 子类的虚表是把父类的虚表拷贝过来,并对重写的虚函数指针位置完成覆盖

另外虚函数的重写其实是语法的叫法,覆盖才是底层的叫法,可以看到在重写func1过后,d对象里面的虚表的func1函数地址发生了改变,这就是我们所说的虚函数的覆盖。

测试代码:

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 _ch;
};
class Derive : public Base
{
public:
	virtual void Func1()//对func1进行重写
	{
		cout << "Derive::Func1()" << endl;
	}
	void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	//当类里面有虚函数之后,类对象的存储模型和以前不一样了,类对象会多一个虚表指针。
	cout << sizeof(Base) << endl;
	Base b;
	Derive d;


	//普通调用 -- 编译时绑定,静态绑定/决议
	Base* ptr = &b;
	ptr->Func3();
	ptr = &d;
	ptr->Func3();

	//多态调用 -- 运行时绑定,动态绑定/决议
	ptr = &b;
	ptr->Func1();
	ptr = &d;
	ptr->Func1(); 
	
	Base b1;
	Base b2;
	return 0;
}
  1. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  2. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生
    类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

2 动态绑定和静态绑定

在知道虚函数表的存在之后,就可以理解多态的原理了,我们可以通过底层的汇编看一下多态调用和普通调用有什么区别,通过底层可以看到,如果是普通调用,汇编代码非常简单,仅仅call了一下函数的地址就完成了函数调用。如果是多态调用,可以看到汇编代码较为繁琐,最后还call了一下eax寄存器的内容,很是繁琐嘛。

通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
在这里插入图片描述
多态是指不同类型的对象对同一消息做出不同的响应。在C++中,多态主要有两种形式:静态多态和动态多态。

静态多态是指在编译期间就确定了函数调用的目标,主要通过函数重载和运算符重载实现。静态多态不需要虚表,而是直接根据函数签名匹配合适的函数。

动态多态是指在运行期间才确定了函数调用的目标,主要通过虚函数和继承实现。动态多态需要虚表,因为编译器无法知道基类指针或引用所指向的实际对象类型,只能通过虚表指针来动态查找虚函数地址。

3 虚表存在哪里?

一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。
注意上面的回答的错的
但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在于代码段的。

我们可以通过对比地址的方式来确定虚表的位置,从代码运行结果就可以看出,虚表地址和代码段的地址较为相近,所以虚表位置极大可能性就是在代码段,另一方面去理解的话,虚函数本质不就是类成员函数吗?当时在学习类和对象的时候,类成员函数不就是放在公共代码段等待对象进行调用吗?那这里又有什么区别呢?只不过虚表里面存的是虚函数的地址。

//写一个程序验证虚表是存在哪里的?
int main()
{
	int a = 0;
	cout << "栈:" << &a << endl;

	int* p1 = new int;
	cout << "堆:" << p1 << endl;//自动识别类型,打印变量里面存储的值,p1存的是堆空间的地址

	const char* str = "hello world";
	cout << "常量区/代码段:" << (void*)str << endl;

	static int b = 0;
	cout << "静态区/数据段:" << &b << endl;

	Base be;
	Derive de;
	cout << "虚表:" << (void*)*((int*)(&be)) << endl;//拿到虚表的地址进行打印。对比虚表地址和上面那些空间的地址,看虚表在哪
	cout << "虚表:" << (void*)*((int*)(&de)) << endl;
	//拿到虚函数类的成员前4个字节的内容,内容就是虚表的地址,通过对比,来判断虚表的位置。

	Base b1;
	Base b2;
	//虚表是共享的,道理和类成员函数一样,放在公共代码段,等待对象调用。
}

虚表是类的静态成员,存放在代码段中,不随对象的创建而改变。每个有虚函数的类都有一个虚表,每个该类的对象都有一个虚表指针,指向该类的虚表。因此,同一个类的不同对象的虚表指针都是相同的,只有不同类的对象的虚表指针才可能不同。

所以虚表是共享的,一个类无论实例化出多少对象,对象里面存的虚表指针都是一样的。需要说清楚的一个概念是,虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针.
在这里插入图片描述

虚表是什么阶段生成的?---- 构造函数的初始化列表
虚表是在编译阶段生成的,而虚表指针是在构造函数的初始化列表中赋值的。

虚表是由编译器根据类的定义和继承关系,生成的一个静态数据结构,存放在代码段中,不随对象的创建而改变。因此,虚表是在编译阶段就确定了的,不需要等到运行时才生成。

虚表指针是每个有虚函数的类的对象中,存放的一个指向该类的虚表的指针,用于实现动态绑定。因此,虚表指针是随着对象的创建而分配内存空间,并在构造函数中初始化的。通常,编译器会在构造函数的初始化列表中,给虚表指针赋值为该类的虚表地址。

例如,下面是一个简单的类定义:

class Base {
public:
    Base() { cout << "Base constructor" << endl; }
    virtual void f() { cout << "Base::f" << endl; }
};

class Derived : public Base {
public:
    Derived() { cout << "Derived constructor" << endl; }
    virtual void f() { cout << "Derived::f" << endl; }
};

在这个例子中,Base类和Derived类都有一个虚函数f。那么,当创建一个Derived类的对象时,会调用两个构造函数:Base()和Derived()。这两个构造函数的初始化列表大致如下:

Base::Base() : vfptr(&Base::$vftable@) {
    cout << "Base constructor" << endl;
}

Derived::Derived() : Base(), vfptr(&Derived::$vftable@) {
    cout << "Derived constructor" << endl;
}

可以看到,在Base()构造函数中,会给vfptr赋值为&Base::KaTeX parse error: Expected 'EOF', got '&' at position 64: …造函数,然后给vfptr赋值为&̲Derived::vftable@,即Derived类的虚表地址。这样就保证了对象的虚表指针指向了正确的虚表。

4 对于单继承中的虚表

单继承的虚表是指在单继承关系中,派生类的虚函数表的结构和内容。

在单继承中,派生类的虚函数表通常包含以下内容:

  • 一个指向类元数据的指针,用于存储类的名称、大小、基类等信息。
  • 一个或多个指向虚函数的指针,按照声明顺序排列。如果派生类没有覆盖基类的虚函数,则直接继承基类的虚函数地址;如果派生类覆盖了基类的虚函数,则用派生类的虚函数地址替换基类的虚函数地址;如果派生类有自己独有的虚函数,则追加到虚函数表的末尾。
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;
};

在这里插入图片描述

观察图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?我们使用代码打印出虚表中的函数看看

写个程序把虚表打印出来看看

怎么取虚表的指针? — 我们知道对象的前四个字节就是虚表指针,所以直接对象强转int* 那么就获取到了
代码:

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
 cout << " 虚表地址>" << vTable << endl;
 for (int i = 0; vTable[i] != nullptr; ++i)
 {
 printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
 VFPTR f = vTable[i];
 f();
比特就业课
5.2 多继承中的虚函数表
 }
 cout << endl;
}
int main()
{
 Base b;
 Derive d;
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数
//指针的指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最
//后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
 VFPTR* vTableb = (VFPTR*)(*(int*)&b);
 PrintVTable(vTableb);
 VFPTR* vTabled = (VFPTR*)(*(int*)&d);
 PrintVTable(vTabled);
 return 0;
 }

此代码局限性:只能32位下跑,因为int* 32位机器下是4字节

在这里插入图片描述

派生类的虚表生成就是先将基类虚表内容拷贝到派生类的虚表里面,如果派生类发生了虚函数的重写,则将重写后的虚函数地址覆盖到对应位置的虚函数上,最后如果派生类又自己的虚函数,则将会按照虚函数的声明次序,依次放到虚表的尾部后面,vs编译器下会帮我们在虚表的最后一个位置放置一个nullptr。

5 多继承中的虚表

1 多继承之后的派生类有两张虚表,那派生类自己的虚函数会放在哪里呢?先说结论,派生类自己的虚函数会放在继承后的第一张虚表里面,第一张虚表是哪个类和继承的类的先后关系有关,先继承哪个类,则第一张虚表就是这个类的虚表内容拷贝过来的,如果有重写,则发生虚函数覆盖即可

2 但是,当我们要打印派生类中第二张虚表时,传的指针肯定就不是第一张虚表的地址了,而是第二张虚表的地址,这个时候要解决,有两种办法。
我们可以通过指针+ - 整数挪动字节的方式来让指针偏移到第二张虚表的位置,但是挪动之前要将指针先强转成char *然后再加上基类Base1实例化出的对象的大小,否则默认的+ -整数挪动的是派生类对象的大小,则会发生越界访问。
这种方式较为繁琐,另一种方式就是利用切片赋值,我们直接让Base2类型指针指向派生类对象即可,这样默认的Base2类型指针指向的就是派生类中Base2的基类成员,这个时候只要取得指针的前4个字节,就可以取到第二张虚表的地址了。

代码:

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个虚函数地址 :0X%x,->", 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;
 }

在这里插入图片描述
这里隐含存在着一个非常非常重要的现象,Base1和Base2的虚函数被派生类重写,但是为什么重写之后第一张和第二张虚表里面的func1虚函数地址不一样呢?发生重写是要进行覆盖的啊,重写肯定只重写了一次,那应该两张表里面重写后的func1虚函数的地址一样才对啊,这是怎么回事?

实际上内存里面只有一份被重写的虚函数func1,重写之后Base1虚表里的func1正好被覆盖,但是Base2里面的虚表的func1也要被覆盖啊,所以如果是Base2类型指针多态调用func1时,它会先调用一个被封装好的函数,这个函数内部会先做指针的偏移,将指针的地址进行-8,让指针指向第一张虚表的func1虚函数,然后调用这份真正被重写的虚函数,所以第二张虚表里面看起来被重写的func1并不是内存里真正的func1,而是被封装后的func1,间接调用内存里只有一份的虚函数func1.

多态常见的面试问题

注意不要背八股文!要用理解、分析的思维方式来学习,要用有效的记忆策略来巩固,要用积极的心态来面对~

  1. 什么是多态?
    多态可以细分为静态多态和动态多态,静态多态例如函数重载,通过所传参数类型的不同确定调用的具体函数,动态多态就是利用虚函数的重写使得基类指针调用虚函数时,能够达到指针指向谁就调用谁的虚函数,从而实现动态多态。

  2. 什么是重载、重写(覆盖)、重定义(隐藏)?
    重载指的是同一作用域内出现的相同函数名,参数类型个数顺序不同的同名函数。
    重写指的是在继承体系中,派生类继承基类的虚函数,并且虚函数的函数名参数列表返回值均与基类相同,重写的是虚函数的实现,这样的虚函数就称之为重写。重写有特殊情况,协变就是一种特殊情况,允许虚函数的返回值不同,但只能为继承体系中父子类类型的指针,子类虚函数也可以不加virtual关键字,对于析构函数来说也算一种特殊情况,函数名虽然不同,但编译器会将析构函数特殊处理为destructor函数名,以此来符合虚函数重写的条件。
    重定义的要求较为宽松,指的是在继承体系中,基类和派生类中出现同名函数的情况,只要函数名相同就构成隐藏,在调用时,若不指定基类类域,默认访问的同名函数是派生类类域,编译器的就近原则,找近的对于编译器来说比较轻松嘛。

  3. 多态的实现原理?
    多态的实现主要通过虚函数的重写和虚函数表来实现,当基类指针指向不同类型时,发生多态调用后会去基类指针指向的类型里面的虚表去找对应的虚函数进行调用,所以多态实现就是依靠虚表和虚函数重写来实现的,基类和派生类都有自己的虚表,多态调用会去虚表找对应的虚函数。

  4. inline函数可以是虚函数吗?
    这里需要分情况,如果是普通调用,则虚函数和普通函数并没有任何区别,因为即使你虚函数重写了,但如果调用指针或引用并非基类类型,那么你还是和普通函数没有区别,并不会去虚表里面找虚函数,而是直接静态绑定,在编译后的符号表里面就确定了调用函数的地址,那么此时就可以是内联函数,如果代码体较小编译器一般会在调用的地方展开,如果代码体较大为了防止代码膨胀,编译器一般会忽略内联请求。
    但如果发生多态调用,比如用基类指针调用重写的虚函数,此时情况就不一样了,编译器此时会忽略内联属性,直接去基类指针指向对象里面的虚函数表去找对应的虚函数,然后进行调用,这个时候就不会在调用的地方进行展开,而是开辟函数栈帧走正常的函数调用的路子,完成虚函数的调用。

  5. 静态成员可以是虚函数吗?
    当然是不可以的,从类和对象的本质上来说,无论你是用对象去调用成员函数,还是成员函数之间的调用,或是用类类型指针去去调用成员函数,本质都是通过隐含的this指针来进行调用的,所以前面说的所有成员函数都是非静态成员函数。
    而静态成员函数没有this指针,他都可以通过指明类域进行访问,由此可见,他并不属于某个对象,而是属于整个类域。所以如果虚函数是静态成员函数的话,那就废了,多态调用不了了就,虚函数直接没有意义了,所以虚函数一定不可以是静态成员,编译器也不允许你这样做,只要你这样做编译器就会报错。

  6. 构造函数可以是虚函数吗?
    构造函数不可以是虚函数,在程序编译期间虚表内容实际早已被初始化好,但虚表指针并未初始化是个野指针,在构造函数初始化列表部分会完成虚表指针的初始化,给虚表指针分配一个属于当前进程管理的有效地址。所以构造函数不能是虚函数,因为此时虚表指针都为初始化好,你根本找不到虚表都,怎么把构造函数放进去?这就相当于,你明天要去和女朋友喝咖啡,但是你连女朋友都没有,你怎么跟女朋友喝咖啡啊?

  7. 析构函数可以是虚函数吗?
    析构函数强烈建议无脑搞成虚函数,如果是一般场景下,函数栈帧销毁,对象跟着被自动析构,则不会出任何问题。但如果是基类类型指针指向new出来的基类和派生类对象时,此时分别通过基类指针调用析构函数完成对象所含资源的清理,如果派生类对象里没有自己资源的申请,则不会出事,但只要派生类自己申请了资源,就会发生内存泄露。
    其本质就是因为如果析构函数不是虚函数,则一定不会发生多态调用,但我们期望在通过基类指针或引用调用析构函数时的行为是多态行为,而不是普通行为,所以就必须将析构函数搞成虚函数,为的就是能够满足多态调用的条件之一,防止潜在内存泄露问题的发生。

  8. 对象访问普通函数快还是虚函数更快?
    这里主要还是和调用的种类有关,和函数是否是普通函数或是虚函数并无关系,如果是普通调用,则普通函数和虚函数一样块,如果是多态调用,调用虚函数需要去虚表里面去找,所以普通函数更快一些,虚函数较慢。
    当调用对象是基类类型指针或引用时,如果调用函数是普通函数,那也算普通调用。如果调用对象是基类对象或派生类对象时,即使调用的函数是虚函数,那也算普通调用。
    只有多态调用时,虚函数才和普通函数有差别,虚函数走虚表的路子,普通函数走编译期间进符号表的路子。
    如果是普通调用,虚函数和普通函数并没有大的差别,唯一较小的差别就可能是普通函数不进虚表,虚函数进一下虚表而已,但在调用结果上,虚函数和普通函数并无差别,都是静态时决议,即在程序编译后就可以通过符号表找到对应的函数地址进行调用。

  9. 虚函数表是在什么阶段生成的,存在哪的?
    虚函数表在程序编译期间生成,虚表指针在构造函数的初始化列表阶段进行初始化,vs上面虚表和虚函数都存在于代码段,g++上面虚表存在于数据段,细说的话那就在.rodata段,虚函数和vs一样都存在于代码段里面。

  10. C++菱形继承的问题?虚继承的原理?
    从内存角度和语法角度来看,菱形继承分别带来了数据冗余和二义性的问题,虚拟继承能够解决菱形继承的原理即通过虚基表的方式进行解决,内存中只存一份虚基类成员,腰部派生类访问时通过自身成员模型中的虚基表来进行访问,虚基表中存放着腰部类到虚基类成员的偏移量,如果腰部类想要访问虚基类成员时,则通过自身成员变量中的虚基表来进行虚基类成员的访问,这便解决了数据冗余的问题,在访问冗余数据时,也不会出现二义性了,无论你怎么访问,访问的都是内存中仅存一份的虚基类成员。
    一般来说,虚基类成员都放在逻辑对象模型中成员的最下面,如果是在内存里面的话,他的位置应该是对象成员中的最高地址处。

  11. 什么是抽象类?抽象类的作用?
    纯虚函数所在的类称之为抽象类,抽象类会强制其派生类重写纯虚函数,因为如果不重写纯虚函数,派生类也无法实例化出对象,那就失去了其存在的意义。另外抽象类也可以体现出虚函数的接口继承,虚函数的重写,重写的是虚函数的实现。

  • 20
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 20
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 20
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吉始

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值