深入理解C++多态机制:虚函数、虚表与对象内存模型解析

多态的概念

多态,简单来说就是"同一行为,不同对象产生不同结果"。

比如买火车票:

  • 普通人:全价
  • 学生:半价
  • 军人:优先购票

本质上就是:同一个接口,不同实现

多态的定义

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

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

  1. 必须通过父类的指针或者引用去调用虚函数。
  2. 被调用的函数必须是虚函数,且子类必须对父类的虚函数进行重写。
复制代码
class Person
{
public:
	virtual void buyTicket()
	{
		std::cout << "全价买票" << std::endl;
	}
};

class Student :public Person
{
public:
	virtual void buyTicket()
	{
		std::cout << "半价买票" << std::endl;
	}
};

void func(Person& p)
{
	p.buyTicket();
}

int main()
{
	Person p;
	Student s;

	func(p);
	func(s);
	return 0;
}

虚函数

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

class Person

{

public:

virtual void buyTicket()

{

std::cout << "全价买票" << std::endl;

}

};

这里虽然和虚拟继承那里用了相同的关键字,但是这两个时没有关系的,虚拟继承中是为了解决数据冗余和二义性,这是是为了完成虚函数的重写,从而实现多态。

虚函数的重写

虚函数的重写(覆盖):子类中有一个和父类完全相同的虚函数(子类虚函数和父类虚函数的返回值类型,函数名,以及参数完全相同),这样就是子类的虚函数重写了父类的虚函数。

注意:子类在重写虚函数时,可以在虚函数前不加virtual关键字,这样也构成重写,因为继承之后父类的虚函数在子类中依旧保持着虚函数的属性。

虚函数重写的两个例外:

  • 协变(就是父类和子类的返回值可以不同),但必须是父子类关系的引用或者指针。

样例1:

复制代码
class Person
{
public:
	virtual Person* buyTicket()
	{
		std::cout << "全价买票" << std::endl;
		return nullptr;
	}
};

class Student :public Person
{
public:
	virtual Student* buyTicket()
	{
		std::cout << "半价买票" << std::endl;
		return nullptr;
	}
};

void func(Person& p)
{
	p.buyTicket();
}

int main()
{
	Person p;
	Student s;

	func(p);
	func(s);
	return 0;
}

样例2:

复制代码
class A
{
};

class B : public A
{

};

class Person
{
public:
	virtual A* buyTicket()
	{
		std::cout << "全价买票" << std::endl;
		return nullptr;
	}
};

class Student :public Person
{
public:
	virtual B* buyTicket()
	{
		std::cout << "半价买票" << std::endl;
		return nullptr;
	}
};

void func(Person& p)
{
	p.buyTicket();
}

int main()
{
	Person p;
	Student s;

	func(p);
	func(s);
	return 0;
}
  • 析构函数的重写

    class Person
    {
    public:
    ~Person()
    {
    std::cout << "~Person()" << std::endl;
    }
    public:
    virtual void buyTicket()
    {
    std::cout << "全价买票" << std::endl;
    }
    };

    class Student :public Person
    {
    public:
    ~Student()
    {
    std::cout << "~Student()" << std::endl;
    }
    public:
    virtual void buyTicket()
    {
    std::cout << "半价买票" << std::endl;
    }
    };

    void func(Person& p)
    {
    p.buyTicket();
    }

    int main()
    {
    Person* p = new Person;
    delete p;

    复制代码
      p = new Student;
      delete p;
    
      return 0;

    }

从这里我们就可以看到运行结果并不是我们想要的,Student对象在进行析构的时候应该先析构子类,再析构父类,但是这里仅仅析构父类,所以是不正确的,这是因为delete p; 会被编译器当成

p->~Person(); ------> p->destructor()

编译器只看 指针类型, 它根本不会去管你实际指向的是 Student,所以就直接去调用Person的析构函数,这叫 静态绑定(静态多态)。

而当我增加virtual之后,编译器就会做如下三件事情:

  1. 给类加一个 虚函数表(vtable)
  2. 对象里加一个 _vfptr(指向vtable)
  3. delete p 变成:

运行时查表调用(动态绑定)

整个执行流程就是如下:

  • 通过 p 找到对象里的 _vfptr
  • Student 的虚函数表
  • 找到 ~Student()

这部分内容我们接下来会继续通过内存窗口查看整个流程,这里简单提一提。

情况 是否 virtual 调用方式 调用结果
普通析构 编译期绑定 只调用 ~Person()
虚析构 运行期多态 ~Student()~Person()

重载,隐藏,重写的区别

抽象类

在虚函数之后写上=0,这个函数就是纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。子类继承之后也不能实例化出对象,只能重写纯虚函数之后,子类才能实例化出对象。纯虚函数规范了子类必须重写,纯虚函数更体现出了接口继承。

复制代码
class Animal
{
public:
	virtual void Sound() = 0;
};

int main()
{
	Animal a;
	return 0;
}

可以看到抽象类是无法进行实例化的。

复制代码
class Animal
{
public:
	virtual void Sound() = 0;
};
class Cat :public Animal
{
};

int main()
{
	Cat c;
	return 0;
}

如果子类没有重写纯虚函数,依旧无法进行实例化。

复制代码
class Animal
{
public:
	virtual void Sound() = 0;
};
class Cat :public Animal
{
public:
	virtual void Sound()
	{
		std::cout << "Cat-喵" << std::endl;
	}
};

int main()
{
	Cat c;
	c.Sound();
	return 0;
}

可以看到,当我们对纯虚函数进行重写之后,子类就可以成功实例化出对象。

接口继承和实现继承

普通函数的继承是一种实现继承,子类继承了父类的函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,达成多态,继承的是接口。

多态的原理

虚函数表

复制代码
class Cat
{
public:
	virtual void Sound()
	{
		std::cout << "Cat-喵" << std::endl;
	}

private:
	int age = 1;
};

int main()
{
	Cat c;
	std::cout << sizeof(c) << std::endl;
	return 0;
}

大家先来看一看,可以猜一下c的所占的内存大小是多少,了解过C++的类和对象的同学都知道,C++类的大小只与成员变量有关,成员函数放在代码段,所以结合内存对齐规则,在32位平台下,这个c所占的内存大小就是4。我们来运行一下这段程序来看看结果。

运行的结果竟然是8,这是怎么回事呢?我们通过监视窗口来看一看

除了age成员变量,在它之前还有一个_vfptr指针,这个指针就是虚函数表指针。在这个地址处存放的就是虚函数表,虚函数表中的内容就是整个类中所有的虚函数地址。一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要放到虚函数表中,虚函数表也叫做虚表。那这个类的子类的内存对象模型会变为什么样子的,我们通过代码来试一试。

复制代码
class Animal
{
public:
	virtual void Sound()
	{
		std::cout << "animal" << std::endl;

	}
	virtual void eat()
	{
		std::cout << "food" << std::endl;
	}
protected:
	int tel = 1;
};

class Cat:public Animal
{
public:
	virtual void Sound()
	{
		std::cout << "Cat-喵" << std::endl;
	}
	virtual void func()
	{
		std::cout << "func()" << std::endl;
	}
private:
	int age = 12;
};

int main()
{
	Animal a;
	Cat c;
	std::cout << sizeof(c) << std::endl;
	return 0;
}

通过测试和观察,我们可以得出如下结论:

  1. 子类对象和父类对象虚表是不一样的,我们可以发现Sound完成了重写,所以c的虚表中存放的是重写的Cat::Sound,所以虚函数的重写也叫覆盖,覆盖也就是对应虚表中的虚函数的覆盖。
  2. 另外eat也是被继承下来的虚函数,所以也被放到了虚表中,且可以发现我们并没有对eat这个虚函数进行重写,所以在c中的虚表中eat的地址与父类中的地址是一样的。
  3. 虚函数表其实也就是一个存虚函数指针的指针数组,一般情况在这个数组的最后会放一个nullpty。
  4. 子类虚表的生成:a.先将父类中的虚表内容拷贝一份到子类虚表中 b.如果子类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数 c.子类自己新增加的虚函数按其在派生类中的声明次序增加到子类虚表的最后。

了解了这些内容,现在我们就来回过头看看C++是如何实现多态的。是如何使得普通人买票就是全价,学生买票就是半价的操作。

多态的原理

复制代码
class Person
{
public:
	virtual void buyTicket()
	{
		std::cout << "全价买票" << std::endl;
	}
protected:
	int a = 1;
};

class Student :public Person
{
public:
	virtual void buyTicket()
	{
		std::cout << "半价买票" << std::endl;
	}
protected:
	int b = 2;
};

void func(Person& p)
{
	p.buyTicket();
}

int main()
{
	Person p;
	Student s;

	func(p);
	func(s);
	return 0;
}

现在相信大家对多态形成的两个条件已经是有所直观感受了

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

函数必须是虚函数,因为不是虚函数就进不了虚函数表,这样我们就找不到对应的虚函数的地址,就无法正确执行,那么现在其实我就好奇了,一定是要父类的指针或者引用去调用吗,如果使用父类的对象就不能成功吗?我们试试看。

这其实是因为Person = s,会调用Person的拷贝构造函数,会构造一个全新的Person对象,这样,就只会将属于Person的那一部分数据拿出来进行拷贝,并且虚函数表指针并不是普通成员,它是由编译器进行维护的,因此新对象只会指向父类虚表。

并且由同一个类创建的所有对象的虚表都是一样的地址,我们可以通过监视窗口来证明一下。

但是有一点就是如果子类不重写虚函数,子类和父类的虚表的地址都是不一样的。

复制代码
class Person
{
public:
	virtual void buyTicket()
	{
		std::cout << "全价买票" << std::endl;
	}
protected:
	int a = 1;
};

class Student :public Person
{
public:
protected:
	int b = 2;
};

void func(Person p)
{
	p.buyTicket();
}

int main()
{
	Person p;
	Student s;
	return 0;
}

单继承和多继承下的虚函数表

单继承中的虚函数表

复制代码
typedef void(*VFunc)();

void PrintVTable(VFunc* VTable)
{
	for (int i = 0; VTable[i] != 0; i++)
	{
		printf("[%d] : %p -> ",i, VTable[i]);
		VTable[i]();
	}

	std::cout << std::endl;
}


class A
{
public:
	virtual void func1()
	{
		std::cout << "A::func1()" << std::endl;
	}
	virtual void func2()
	{
		std::cout << "A::func2()" << std::endl;
	}
public:
	int _a = 1;
};

class B : public A
{
public:
	virtual void func1()
	{
		std::cout << "B::func1()" << std::endl;
	}
	virtual void func3()
	{
		std::cout << "B::func3()" << std::endl;
	}
public:
	int _b = 2 ;

};

int main()
{
	A a;
	PrintVTable((VFunc*)(*(int*)&a));

	B b;
	PrintVTable((VFunc*)(*(int*)&b));

	return 0;
}

与此同时,我们实现一个打印虚表的函数,让我们可以直接了当的看到虚函数表中的内容。

思路:取出a、b对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数 指针的指针数组,这个数组最后面放了一个nullptr

  1. 先取a的地址,强转成一个int*的指针
  2. 再解引用取值,就取到了a对象头4字节的值,这个值就是指向虚表的指针
  3. 再强转成VFunc*,因为虚表就是一个存VFunc类型(虚函数指针类型)的数组
  4. 虚表指针传递给PrintVTable进行打印虚表

多继承下的虚函数表

复制代码
class A
{
public:
	virtual void func1()
	{
		std::cout << "A::func1()" << std::endl;
	}
	virtual void func2()
	{
		std::cout << "A::func2()" << std::endl;
	}
public:
	int _a = 1;
};

class B 
{
public:
	virtual void func1()
	{
		std::cout << "B::func1()" << std::endl;
	}
	virtual void func3()
	{
		std::cout << "B::func3()" << std::endl;
	}
public:
	int _b = 2 ;

};

class C : public A, public B
{
	virtual void func4()
	{
		std::cout << "C::func4()" << std::endl;
	}
public:

	int _c = 4;
}; 

int main()
{
	C c;
	PrintVTable((VFunc*)(*(int*)&c));

	B& b = c;
	PrintVTable((VFunc*)(*(int*)&b));


	return 0;
}

根据结果来看,我们可以知道,子类中不是从父类继承或者重写的虚函数(也就是自己的虚函数)会被放在第一个继承的父类的虚函数表中。

菱形继承

复制代码
class A
{
public:
	virtual void func1()
	{
		std::cout << "A::func1()" << std::endl;
	}
public:
	int _a = 1;
};
class B :public A
{
public:
	virtual void func2()
	{
		std::cout << "B::func2()" << std::endl;
	}
public:
	int _b = 2;
};
class C :public A
{
public:
	virtual void func3()
	{
		std::cout << "C::func3()" << std::endl;
	}
public:
	int _c = 3;
};
class D : public B, public C
{
public:
	virtual void func4()
	{
		std::cout << "D::func4()" << std::endl;
	}
public:
	int _d = 4;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	return 0;
}

这段代码对应的是一个普通的菱形继承结构 ,由于 BC 都继承自 A,而 D 又同时继承 BC,因此在 D 对象中会存在两份独立的 A 子对象,并不会像虚继承那样进行共享。

从内存布局来看,D 对象主要分为三部分:首先是 B 子对象,其中包含一个虚函数表指针、从 A 继承来的成员 _a,以及自身成员 _b;接着是 C 子对象,同样包含自己的虚函数表指针,以及另一份独立的 _a 和成员 _c;最后是 D 自身的成员 _d。也就是说,A::_a 在内存中实际上存在两份。这部分内容在上一篇博客中有详细的讲解。

图中可以看到有两张虚函数表,分别属于 BC 子对象。每张虚表中不仅包含各自新增的虚函数(如 func2func3),还包含从 A 继承来的 func1,以及在 D 中新增的 func4

由于存在两份 A,访问 _a 时会产生二义性,因此必须显式指定路径,例如 d.B::_ad.C::_a。这两个 _a 是完全独立的变量,互不影响,这正是普通菱形继承区别于虚继承的核心特征。

菱形虚拟继承

复制代码
class A
{
public:
	virtual void func1()
	{
		std::cout << "A::func1()" << std::endl;
	}
public:
	int _a = 1;
};
class B :virtual public A
{
public:
	virtual void func2()
	{
		std::cout << "B::func2()" << std::endl;
	}
public:
	int _b = 2;
};
class C :virtual public A
{
public:
	virtual void func3()
	{
		std::cout << "C::func3()" << std::endl;
	}
public:
	int _c = 3;
};
class D : public B, public C
{
public:
	virtual void func4()
	{
		std::cout << "D::func4()" << std::endl;
	}
public:
	int _d = 4;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	return 0;
}

图中展示的是一个典型的菱形虚继承结构对象 D 的内存布局

类之间的关系如下:

  • BC 虚继承A
  • D 同时继承 BC

因此在最终的 D 对象中:

  • 虚基类 A 只保留一份
  • BC 各自仍然保留自己的子对象结构

从图中可以看到,D 对象大致被分为四个部分:

  1. B子对象
    • 包含:虚函数表指针(vptr)、虚基表指针(vbptr)、成员 _b
  2. C子对象
    • 同样包含:vptr、vbptr、成员 _c
  3. D自身成员
    • _d
  4. 虚基类 A
    • 位于对象的最后,仅保留一份
    • 包含 _a 和自己的虚表指针

图中多个位置标出了虚函数表地址,例如:

  • B 子对象对应一张虚表
  • C 子对象对应一张虚表
  • A 也有自己的虚表

这些虚表中存放的是:虚函数的地址

例如:

  • B 的虚表中包含 func2 和被重写后的 func4
  • C 的虚表中包含 func3func4
  • A 的虚表中包含 func1

BC 子对象中,都存在一个特殊指针:虚基表指针

它指向一张"虚基表",表中存放的是:到虚基类 A 的偏移量

图中有这样一段数据:

fc ff ff ff → 转换为十进制是 -4

这个值表示:

👉 从当前子对象出发,如何通过偏移找到虚基类 A

简单理解就是:

  • 当前在 BC
  • 想访问 A
  • 需要通过虚基表查表,得到偏移
  • 再通过这个偏移定位到 A

这是因为:

👉 虚函数调用过程中,可能需要"回调到完整对象(D)再重新定位"

这个 -4 往往表示:

👉 从当前虚表相关位置,回退到某个基准位置(通常是子对象起点或完整对象)

总而言之就是:

用于在多继承 + 虚继承下,做"this 指针调整",通过这样的方式就可以找到虚基类A。


写到这里,其实多态就不再只是课本里的一个"概念"了,而是一整套可以被拆开、看透、甚至调试验证的底层机制。

很多人学C++,卡的不是语法,而是这些"看不见的东西"------虚表、vptr、动态绑定。一旦把这些东西搞明白,你会发现,多态根本不神秘,反而很"直白"。

说白了就是一句话:编译器帮你提前准备好表,运行时帮你查表干活。

相关推荐
leaves falling2 小时前
C++ 继承详解:从入门到深入
开发语言·c++
minji...2 小时前
Linux 网络基础(一)认识协议,网络协议,网络协议分层框架搭建,网络传输基本流程,跨网络的数据传输
linux·运维·服务器·网络·c++·网络协议
吃着火锅x唱着歌2 小时前
深度探索C++对象模型 学习笔记 第四章 Function语意学(1)
c++·笔记·学习
王老师青少年编程2 小时前
csp信奥赛C++高频考点专项训练之贪心算法 --【排序贪心】:纪念品分组
c++·算法·贪心·csp·信奥赛·排序贪心·纪念品分组
tankeven2 小时前
C++ 学习杂记03:std::string 类
c++
H_BB2 小时前
动态规划详解
c++·算法·动态规划
C语言小火车2 小时前
嵌入式实习面试问题:那个动态内存是怎么样分配的?
c语言·开发语言·c++·嵌入式硬件·面试
John_ToDebug2 小时前
Chromium 源码剖析:base::NoDestructor——更安全的静态单例解决方案
开发语言·c++·chrome
tankeven2 小时前
C++ 学习杂记02:C++模板编程
c++