C++ 多态完全指南:从虚函数重写到虚函数表原理

一篇文章吃透动态多态------概念、重写规则、抽象类,以及底层的虚函数表(vtable)机制。所有示例代码整理自学习笔记,可直接编译运行。

cpp 复制代码
void func(Person* p)
{
	p->BuyTicket();   
}

什么是多态

多态(Polymorphism)是面向对象三大特性(封装、继承、多态)中最难、也最核心的一个。用一句话概括:同一个调用接口,在不同类型的对象上表现出不同的行为。更直白一点------同样是 p->BuyTicket(),当 p 指向不同的对象时,执行的是完全不同的函数。

多态通常分为两类:

  • 静态多态(编译期绑定):函数重载、运算符重载、模板。编译阶段就已经确定调用的是哪个函数。
  • 动态多态(运行期绑定):继承 + 虚函数。运行到调用点的那一刻,根据对象的真实类型决定执行哪个版本。
维度 静态多态 动态多态
实现手段 函数重载 / 运算符重载 / 模板 继承 + 虚函数重写
绑定时机 编译期 运行期(查虚函数表)
典型场景 同一个函数名处理不同类型的参数 基类指针 / 引用调用派生类实现

本文讲的动态多态:虚函数如何重写、抽象类如何使用,以及这一切背后的虚函数表(vtable)原理。

虚函数与动态多态

第一个例子:买票

先看一个最经典的场景------不同身份的人买票,票价不同:

C++复制

cpp 复制代码
6<iostream>
using namespace std;

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 p;
	Student s;

	func(&p);   
	func(&s);   

	return 0;
}

关键在于 func 只接收一个 Person*,函数体里也只有一句 p->BuyTicket()。可传入 Person 对象和传入 Student 对象,输出完全不同------函数调用在运行时"看对象下菜",这就是动态多态。

派生类可以不写 virtual。基类中 BuyTicket 已是虚函数,派生类重写后它依然是虚函数------"虚"会沿着继承链一直传下去。

由此可以总结出多态的两个必要条件:

  1. 派生类重写了基类的虚函数;
  2. 通过基类的指针或引用调用该虚函数。

传值不行(对象切片)。如果把 Student 对象按值传给形参 Person p,派生类特有的部分会被"切掉",调用到的仍然是 Person::BuyTicket。指针和引用传递的是地址,对象真实类型的信息才能保留下来。

引用同样触发多态:动物叫声

多态不只限于指针,基类引用同样可以:

cpp 复制代码
class Animal
{
public:
	virtual void talk() const {} 
};

class Cat : public Animal
{
public:
	virtual void talk() const
	{
		cout << "喵喵" << endl;
	}
};

class Dog : public Animal
{
public:
	virtual void talk() const
	{
		cout << "汪汪" << endl;
	}
};

void letsHear(const Animal& animal)
{
	animal.talk();   
}

int main()
{
	Cat cat;
	Dog dog;

	letsHear(cat);   
	letsHear(dog);   

	return 0;
}

引用在底层实现上就是指针的"别名"(传的同样是地址),所以 const Animal& 一样能触发动态多态。

重写时函数签名必须完全一致,包括 const。基类是 talk() const,派生类也必须写 talk() const;否则不构成重写,只会变成"隐藏"。

应用场景:一套接口,处理多种身份

多态最大的价值在于扩展性。把例子扩展成三种身份:

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket() { cout << "买票 - 全价" << endl; }
protected:
	string _name;
};

class Student : public Person
{
public:
	virtual void BuyTicket() { cout << "买票 - 半价" << endl; }
protected:
	string _id;
};

class Soldier : public Person
{
public:
	virtual void BuyTicket() { cout << "买票 - 优先" << endl; }
protected:
	string _codename;
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person p1;
	Student s1;
	Soldier sl1;

	Func(&p1);   
	Func(&s1);   
	Func(&sl1);  

	return 0;
}

以后要新增一种身份,只需要继承 Person 并重写 BuyTicket,Func 一行代码都不用改。这就是面向对象设计里常说的**"对扩展开放"**------多态让"统一的调用代码"和"各自的具体实现"彻底解耦。

重写(override)的规则与细节

重写、重载、隐藏,别搞混

"重写"是理解多态的关键词。它要求派生类中的函数与基类虚函数满足**"三同"** :函数名相同、参数列表相同、返回值相同(返回值有一个例外叫协变,见 3.3)。满足三同、且基类函数是虚函数,才构成重写。

与之容易混淆的是"重载"和"隐藏":

概念 作用域 参数要求 与 virtual 的关系
重载 overload 同一个作用域 参数列表必须不同 无关,普通函数即可
重写 override 基类 ↔ 派生类 参数列表必须相同 基类函数必须是虚函数
隐藏 hiding 基类 ↔ 派生类 同名但参数不同(或不满足重写条件) 是否虚函数均可

隐藏是最容易踩的坑:派生类写了一个与基类同名、但参数不同的函数,编译器不会报错,只是把基类版本"藏"了起来,多态自然也不会触发。

经典坑:虚函数的缺省参数是静态绑定的

虚函数的函数体 是动态绑定的(运行期决定),但它的缺省参数是静态绑定的(编译期决定)!看这个经典例子:

cpp 复制代码
#include<iostream>
using namespace std;

class A
{
public:
	virtual void func(int val = 1) { cout << "A->" << val << endl; }
	virtual void test() { func(); }       
};

class B : public A
{
public:
	void func(int val = 0) { cout << "B->" << val << endl; }
};

int main()
{
	B* p = new B;

	p->test();  
	p->func();   

	return 0;
}
cpp 复制代码
p->test():test() 是 A::test,函数体里的 func() 通过 this 动态绑定,调到 B::func;
但缺省参数在编译期就已确定------调用点写在一个 A 的作用域里,
编译器看到的是 A::func 的声明,所以默认参数取 A 的 1,输出 B->1。

p->func():p 的静态类型是 B*,编译器直接按 B::func 的声明取缺省参数 0,输出 B->0。

函数体看"对象是谁"(动态),缺省参数看"指针类型是谁"(静态)。重写虚函数时,请保持与基类一致的缺省参数值,避免写出"看似多态、实则错乱"的代码。

协变:返回值不同的重写特例

一般情况下重写要求返回值相同,但有一个特例叫协变(covariance) :基类虚函数返回基类对象的指针或引用 ,派生类重写时可以返回派生类对象的指针或引用,此时仍然构成重写。

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

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

void Func(Person* p)
{
	p->BuyTicket();
}

int main()
{
	Person p;
	Student s;

	Func(&p);   
	Func(&s);   

	return 0;
}

协变只允许指针或引用 类型的返回值。只要两个类构成继承关系即可(例如 A* ↔ B*,A 是 B 的基类),不限于本例的 Person* ↔ Student*。

析构函数的重写:基类析构必须写成虚函数

析构函数也可以重写------而且几乎必须重写。看这个资源管理例子:

cpp 复制代码
class A
{
public:
	virtual ~A()   
	{
		cout << "~A()" << endl;
	}
};

class B : public A
{
public:
	~B()   
	{
		cout << "~B()" << endl;
		delete[] _arr;
	}
protected:
	int* _arr = new int[10];
};

int main()
{
	A* p1 = new A;
	A* p2 = new B;   

	delete p1;   
	delete p2;   

	return 0;
}

因为 ~A() 是虚函数,delete p2 会按对象的真实类型先调用 ~B(),B 的资源被正确释放,随后自动向上调用 ~A()。

如果基类析构不是 虚函数,delete p2 只会调用 ~A(),B 中 _arr 指向的堆内存不会被释放------内存泄漏。结论:只要一个类被设计成可以被继承,它的析构函数就应该写成虚函数。

override:让编译器帮你检查重写

C++11 提供了 override 关键字,显式声明"我要重写基类虚函数"。如果签名写错,编译器直接报错,而不是悄悄变成隐藏:

cpp 复制代码
class Car
{
public:
	virtual void Drive() {}
};

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

如果把 Drive() 误写成 Drive(int) 之类,编译器会报错:error C3668: "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法 。没有 override 时,同样的错误会静默变成"隐藏",非常难排查。

final:禁止重写

与 override 相反,如果不想让派生类重写某个虚函数,用 final 修饰:

cpp 复制代码
class Car
{
public:
	virtual void Drive() final {} 
};


// error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写
class Benz : public Car
{
	virtual void Drive()override
	{
	    cout << "舒适" << endl;
    }
};

final 也可以直接修饰类(class Benz final : public Car),表示该类不允许被继承。

纯虚函数与抽象类

在虚函数的声明后面写上 = 0,这个函数就变成了纯虚函数 。纯虚函数只需要声明、不需要定义实现(语法上允许实现,但没什么意义------它注定要被派生类重写)。包含纯虚函数的类叫做抽象类。

cpp 复制代码
class Car
{
public:
	virtual void Drive() = 0;   
};

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

class BMW : public Car
{
public:
	virtual void Drive() override
	{
		cout << "操作" << endl;
	}
};

int main()
{

	Car* pBenz = new Benz;
	pBenz->Drive();  

	Car* pBMW = new BMW;
	pBMW->Drive();   

	return 0;
}

抽象类有两个关键约束:

  • 不能实例化 :Car car; 或 new Car 都会编译报错(error C2259)。
  • 派生类必须重写所有纯虚函数才能实例化;只要有一个纯虚函数没重写,派生类自己也还是抽象类。

抽象类的指针 / 引用可以指向派生类对象,并照常触发多态------这正是上面 Car* pBenz 的用法。抽象类最常见的用途是定义"接口规范":基类只声明接口,强制每个派生类实现自己的行为(比如每辆车都必须能"驾驶",但开法各不相同)。

多态的原理:虚函数表

虚函数表与调用链路

动态多态能"看对象下菜",秘密在于虚函数表(virtual table,简称 vtable):

  • 每个包含虚函数的类,编译器都会生成一张虚函数表------本质是一个数组,存放该类所有虚函数的地址。
  • 每个对象的内存中会多出一个指针:虚表指针 vptr,指向它所属类的虚函数表。
  • 调用虚函数时,程序先通过对象的 vptr 找到虚表,再从虚表里取出函数地址执行。所以"实际调用哪个函数",是在运行时由对象的真实类型决定的。

Student s;Person* p = &s;p->BuyTicket();

① 传地址,指针指向派生类对象Student 对象vptr成员区:_name / _id

② 经 vptr 找到虚表虚函数表(vtable)0: Student::BuyTicket()1: (nullptr)

③ 取出函数地址

④ 跳转执行Student::BuyTicket(){ cout << "买票 - 半价" << endl; }

虚函数调用 = vptr → vtable → 函数地址 → 跳转执行;对象切片之所以失效,是因为按值拷贝出的"Person 对象"其 vptr 指向的是 Person 的虚表。

虚函数地址 vs 普通函数地址

虚函数和普通函数的代码都存放在代码段(只读区) ,地址本身没有本质区别;区别在调用方式 :普通函数直接 call 固定地址,虚函数要先查虚表、再间接调用。用一段代码验证(沿用 5.3 的 Base / Derive 类定义):

cpp 复制代码
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;

printf("Person虚表地址:%p\n", *(int*)p3);
printf("Student虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);

上述 *(int*) 取虚表指针是32 位环境下的探索写法 。64 位下指针占 8 字节,请改用 (uintptr_t*) 或 (void**) 强转;&Base::func1 取虚函数地址同样依赖编译器扩展,不同编译器表现略有差异。

单继承下的虚函数表

用"打印虚表"的方式,把单继承下虚表的内容逐个拿出来看:

cpp 复制代码
class Base
{
public:
	virtual void func1() {}
	virtual void func2() {}
private:
	int a;
};

class Derive : public Base
{
public:
	virtual void func1() {}  
	virtual void func3() {}  
	virtual void func4() {}   
	void func5() {}          
private:
	int b;
};

typedef void(*VFPTR)();

void printTable(VFPTR* vTable)
{
	cout << "虚函数表的地址" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf("第%d个虚函数的地址:0x%p,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();  
		cout << endl;
	}
}

int main()
{
	Base b;
	Derive d;

	VFPTR* ptr1 = (VFPTR*)(*((int*)&b));   
	printTable(ptr1);

	VFPTR* ptr2 = (VFPTR*)(*((int*)&d));  
	printTable(ptr2);

	return 0;
}
复制代码
Base 的虚函数表:
  0: Base::func1()
  1: Base::func2()

Derive 的虚函数表:
  0: Derive::func1()   ← 重写版本,顶掉了 Base::func1
  1: Base::func2()     ← 未重写,沿用基类实现
  2: Derive::func3()   ← 新增
  3: Derive::func4()   ← 新增

(实际打印的地址值因运行环境而异,这里展示的是逻辑结构)

由此归纳出单继承下虚表的四条规律:

  1. 重写的虚函数 :派生类版本覆盖 虚表中原来的槽位(Derive::func1 顶掉了 Base::func1 的地址);
  2. 未重写的虚函数 :沿用 基类实现,地址不变(Base::func2);
  3. 新增虚函数 :按声明顺序追加 到虚表末尾(func3、func4);
  4. 普通函数 :不进虚表 (func5),直接按地址调用。

覆盖继承新增Base 对象vptr成员:

int aBase 虚函数表0: Base::func1()1: Base::func2()Derive 对象vptr成员:int b + 继承的 aDerive 虚函数表0: Derive::func1() ← 覆盖1: Base::func2() ← 继承2: Derive::func3() ← 新增3: Derive::func4() ← 新增

普通函数 func5 不进入虚表,通过固定地址直接调用。

多继承下的虚函数表

多继承的情况复杂一些------有几个含虚函数的基类,就有几张虚函数表:

cpp 复制代码
class Base1
{
public:
	virtual void func1() {}
	virtual void func2() {}
private:
	int a;
};

class Base2
{
public:
	virtual void func1() {}
	virtual void func3() {}
private:
	int a;
};

class Derive : public Base1, public Base2
{
public:
	virtual void func1() {}   
	virtual void func3() {}   
	virtual void func4() {}  
	void func5() {}
private:
	int b;
};

typedef void(*VFPTR)();

void printTable(VFPTR* vTable)
{
	cout << "虚函数表的地址" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf("第%d个虚函数的地址:0x%p,->", i, vTable[i]);
	}
}

int main()
{
	Base1 b1;
	Derive d;

	VFPTR* ptr1 = (VFPTR*)(*((int*)&b1));  
	printTable(ptr1);

	VFPTR* ptr2 = (VFPTR*)(*((int*)&d));   
	printTable(ptr2);

	Base2* Bptr = &d;                        
	VFPTR* ptr3 = (VFPTR*)(*((int*)Bptr));   
	printTable(ptr3);

	return 0;
}

观察这份代码,可以得出多继承虚表的几个要点:

  1. 对象内存中有多个 vptr,分别指向各基类对应的虚表;
  2. 第一张虚表 对应第一个基类(Base1),Derive 新增的虚函数(func4)追加在这里;
  3. 第二张虚表 对应第二个基类(Base2)。通过 Base2* 调用虚函数时,this 必须先从 Base2 子对象位置调整回整个对象的起始位置,所以第二张虚表里存的往往不是函数本体,而是一小段 thunk ------汇编层面表现为 sub ecx, 偏移量; jmp Derive::func1():先修正 this,再跳转到真正的函数;
  4. 因此两个虚表里的 func1 地址不一样,以第一张表(Base1)里的 func1 地址为准------第一张表存的是函数本体,第二张表存的是 thunk;
  5. 这就是为什么 printTable 里注释掉了 f():直接以函数指针调用 thunk 时没有正确的 this,行为未定义。

第一张虚表(本体)thunk(this 调整后跳转)新增Derive 对象vptr₁└ Base1 子对象vptr₂└ Base2 子对象成员:b + 继承的两个 a指向指向第一张虚表(Base1 部分)0: Derive::func1() 本体1: Base1::func2()2: Derive::func4() 新增第二张虚表(Base2 部分)0: thunk:sub ecx, 偏移量jmp Derive::func1()1: thunk:sub ecx, 偏移量jmp Derive::func3()注意:两张表里的 func1 地址不同,以第一张表里的地址为准

thunk 的本质:先把 this 从 Base2 子对象位置调整回对象起始位置,再跳转到真正的 Derive 函数。

单继承 vs 多继承虚表

维度 单继承 多继承
虚表数量 1 张 每个含虚函数的基类各 1 张
vptr 数量 1 个 多个
重写虚函数 覆盖虚表中的原槽位 在每个基类虚表里各自覆盖
新增虚函数 追加到表尾 追加到第一张虚表
第二张虚表 --- 通常存 thunk(this 调整后跳转)

06

补充:虚继承与菱形继承

当两个派生类 A、B 都继承自同一个基类 Base,而 C 又同时继承 A 和 B,就形成了菱形继承(也叫钻石继承):

cpp 复制代码
class Base
{
public:
	void func() {}
	int b;
};

class A : public Base   
{
protected:
	int _a = 1;
};

class B : public Base   
{
protected:
	int _b = 2;
};

class C : public A, public B
{
protected:
	int _c = 3;
};

虚继承 就是用来解决这个问题的:让 A、B 用 virtual public Base 继承,这样无论经过几条继承路径,Base 子对象在 C 中都只保留一份:

cpp 复制代码
class Base
{
public:
	void func() {}
	int b;
};

class A : virtual public Base
{
public:
	void func() {}
protected:
	int _a = 1;
};

class B : virtual public Base
{
public:
	void func() {}
protected:
	int _b = 2;
};

class C : public A, public B
{
public:
	void func() {}
protected:
	int _c = 3;
};

int main()
{
	C c;
	c.func();            

	cout << sizeof(c) << endl;   

	return 0;
}

虚继承会影响对象的内存布局(额外引入虚基类指针来定位共享的基类子对象),这和虚函数表是两回事 :虚继承解决菱形继承的数据冗余与二义性,本身并不产生多态。日常开发中建议优先使用组合和单继承,避免复杂的菱形结构。

总结

把整篇的结论收拢成一张速查表:

主题 结论
多态的本质 同一个调用接口,运行期根据对象的实际类型选择实现
两个必要条件 虚函数重写 + 通过基类指针 / 引用调用(按值传参会切片,无效)
重写的"三同" 函数名、参数列表、返回值相同(协变除外)
缺省参数 静态绑定!函数体是动态的、缺省参数是静态的,重写时保持与基类一致
协变 返回值允许 基类指针 / 引用 ↔ 派生类指针 / 引用
析构函数 基类析构必须写成虚函数,否则 delete 基类指针会泄漏派生类资源
override / final C++11:显式声明重写,让编译器替你检查 / 禁止重写
抽象类 含纯虚函数(= 0),不能实例化,用于定义接口规范
虚函数表 存放虚函数地址的表:单继承"覆盖 + 追加";多继承每个基类一张表,第二张表通常存 thunk
虚继承 解决菱形继承的数据冗余与二义性,与多态无直接关系

多态是面向对象设计的灵魂:它让"统一的调用代码"不必关心"具体的实现是谁",从而支撑起框架与插件、策略与接口等设计模式。

相关推荐
是main不是漫1 小时前
【双指针——移动零】数组划分类题目的入门基石
c++·算法
吴卫斌1 小时前
定投ETF实战指南(四):行业/主题ETF——定投的“进攻端“
开发语言·python·股票·量化
·present·1 小时前
CSAPP(Bomb lab)
c语言·c++
王老师青少年编程1 小时前
2026年信奥赛c++普及组csp-j第二轮复赛真题
c++·真题·csp·信奥赛
超级架构师2 小时前
PRAXO-CORE 2.5.0:用 conformance 和显式边界构建 Go Agent 框架
开发语言·人工智能·后端·golang·ai编程·智能体框架·praxo-core
时间的拾荒人2 小时前
CMake 常用命令详解:从版本声明到安装部署
c++·面试
叼烟扛炮3 小时前
Linux 学习笔记(四十三):网络编程进阶——自定义协议解决 TCP 粘包与守护进程
linux·开发语言·网络·笔记·tcp/ip
LMT流氓兔3 小时前
第五单元——第十六课:给书店助手加上网页界面
java·开发语言·jvm·人工智能·oracle·langchain·1024程序员节
专注仿真4 小时前
梯度提升类(XFBoost)算法详解与代码实例
开发语言·人工智能·python·算法·大模型训练·控制算法