C++ 多态:同一调用,不同行为
C++ 多态:同一调用,千面行为
编译期写下
ptr->BuyTicket(),运行期才揭晓答案——这不是不确定性,这是多态。多态是 C++ 面向对象三大特性(封装、继承、多态)中最具表现力的一个。
1. 多态的概念
多态(Polymorphism),字面意思是"多种形态"。通俗来说:去完成某个行为(函数),传入不同的对象就会产生不同的行为,达到多种形态。
C++ 中的多态分为两大类:
| 类型 | 别称 | 机制 | 绑定时机 | 典型场景 |
|---|---|---|---|---|
| 编译时多态 | 静态多态 | 函数重载、函数模板 | 编译期确定 | 传不同参数类型→匹配不同函数 |
| 运行时多态 | 动态多态 | 虚函数 + 继承 | 运行期确定 | 传不同对象→同一调用产生不同行为 |
1.1 编译时多态(回顾)
编译时多态的核心是通过参数类型的不同来达到多种形态——实参传给形参的参数匹配在编译时完成:
// 函数重载:编译时根据参数类型选择版本
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
// 函数模板:编译时根据参数类型实例化
template<typename T>
T max(T a, T b) { return a > b ? a : b; }
之所以叫"编译时",是因为编译器在编译阶段就确定好了调用哪个函数地址——我们把编译时归为静态,运行时归为动态。
1.2 运行时多态(本文重点)
运行时多态才是 C++ 面向对象的核心竞争力。看两个生活中的例子:
例子一:买票系统
买票这个行为,普通人买票是全价,学生买票是打折(5折或75折),军人买票是优先。
// 多态的目标:不管 ptr 指向谁,这一行代码自动调到正确的函数
ptr->BuyTicket();
// ptr 指向 Person → 全价
// ptr 指向 Student → 打折
// ptr 指向 Soldier → 优先
例子二:动物叫声
同样是"叫"这个行为,传猫对象过去就是
(>^ω^<)喵,传狗对象过去就是汪汪。
void letsHear(const Animal& animal) {
animal.talk(); // 同一个调用,不同的声音
}
如果没有多态,你会怎么写?大概率是一个 switch 判断类型,然后分别处理。每增加一种类型,就要找到所有 switch、所有 if-else——代码越加越散,分支越铺越乱。多态把这种"类型判断"的负担从调用方转移给了语言运行时。
2. 前置概念:静态类型 vs 动态类型
在深入多态的实现细节之前,必须先理解一对基础概念。C++ 中每个指针或引用变量都有两种类型:
- 静态类型(Static Type):声明时写下的类型,编译期确定,永远不会变
- 动态类型(Dynamic Type):实际指向/引用的对象类型,运行期可变
⚠️ 只有指针和引用才有动态类型的区分。 普通对象被赋值给基类变量时,派生类部分会被切掉(Sliced Down)——多态对它彻底失效。
Student st;
Person* ptr = &st; // 静态类型 Person*,动态类型 Student*
Person& ref = st; // 静态类型 Person&,动态类型 Student&
Person obj = st; // 切片!静态类型和动态类型都是 Person——多态对它无效
ptr->BuyTicket(); // 动态绑定 → 调用 Student::BuyTicket ✓
obj.BuyTicket(); // 静态绑定 → 调用 Person::BuyTicket ✗
理解这一对概念,才能明白为什么多态的第一个条件是"必须通过基类的指针或引用调用"——值语义的对象根本不存在动态类型。
📖 参考:《C++ Primer》第15章
3. 多态的定义及实现
3.1 多态的构成条件
运行时多态必须同时满足两个条件:
- 调用方式:必须通过基类的指针或引用调用虚函数
- 函数定义:被调用的函数必须是虚函数,且派生类完成了重写/覆盖(Override)
为什么必须是指针或引用?因为只有指针/引用才能既指向基类对象又指向派生类对象。值传递会触发切片,丢失派生类信息。
两个条件缺一个,多态就不生效——编译器会退回到静态绑定,在编译期直接确定函数地址。
3.2 虚函数
在类成员函数前加 virtual 关键字,该函数就成为虚函数:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
⚠️ 注意:非成员函数不能加
virtual。virtual关键字只作用于类内的非静态成员函数。
3.3 虚函数的重写/覆盖
派生类中定义一个与基类虚函数返回值类型、函数名、参数列表完全相同的函数,就构成了重写(Override),也叫覆盖。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-打折" << endl; } // 重写
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "买票-优先" << endl; } // 重写
};
void Func(Person* ptr) {
// 虽然都是 Person* 指针在调用 BuyTicket
// 但实际调哪个函数跟指针的静态类型无关——由 ptr 指向的真实对象决定
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps); // 输出:买票-全价
Func(&st); // 输出:买票-打折
Func(&sr); // 输出:买票-优先
return 0;
}
💡 多态不只发生在基类和单个派生类之间——多个派生类继承同一个基类并各自重写虚函数后,多态在多个派生类之间也同样生效。上面的例子中,
Func对Person、Student、Soldier三个不同类型的对象各产生了不同的行为。
关于派生类重写时是否写 virtual:语法上可以省略(基类的虚函数属性会被继承下来,派生类中它仍然是虚函数),但强烈不推荐省略。显式写出 virtual 让代码意图一目了然,这是工程规范。考试选择题中也经常拿这个设坑——问"派生类没写 virtual 是否构成多态"——答案是构成。
3.4 完整的 Animal 示例——引用也能多态
class Animal {
public:
virtual void talk() const {}
};
class Dog : public Animal {
public:
virtual void talk() const {
std::cout << "汪汪" << std::endl;
}
};
class Cat : public Animal {
public:
virtual void talk() const {
std::cout << "(>^ω^<)喵" << std::endl;
}
};
void letsHear(const Animal& animal) {
animal.talk(); // 引用调用——同样构成多态
}
int main() {
Cat cat;
Dog dog;
letsHear(cat); // (>^ω^<)喵
letsHear(dog); // 汪汪
return 0;
}
4. 接口继承 vs 实现继承:三种函数的契约语义
理解了虚函数的基本机制后,需要站在设计的角度审视基类中的三种成员函数。它们代表了三种不同的契约强度:
| 函数类型 | 继承了什么 | 派生类的义务 | 设计意图 |
|---|---|---|---|
纯虚函数 = 0 |
只继承接口 | 必须提供实现(除非它也是抽象类) | “你必须做,而且怎么做完全由你定” |
普通虚函数 virtual |
继承接口 + 默认实现 | 可以重写,也可以直接用默认行为 | “你应该这么做,但允许你有不同做法” |
| 非虚函数 | 继承接口 + 强制实现 | 不应重写——这是基类承诺的不变性 | “就这么做,不用改” |
class Shape {
public:
virtual void draw() const = 0; // 纯虚:你只需知道"Shape 可以绘制"
virtual void error(const string& msg); // 普通虚:有默认错误处理,可重写
int objectID() const { return _id; } // 非虚:所有 Shape 共用,不可重写
private:
int _id;
};
💡 纯虚函数在语法上也可以有实现体——派生类通过
Base::func()显式调用基类的默认实现。但这种情况极少见,了解即可。
📖 参考:《Effective C++》条款34
5. 多态的另一种实现:NVI 模式
传统的多态是"public 虚函数 + 派生类重写"。但还有一种更安全的设计——NVI(Non-Virtual Interface,非虚接口),也就是经典的 Template Method 模式:
class GameCharacter {
public:
int healthValue() const { // public 非虚函数——固定框架
// 前置工作:加锁、日志、参数验证……
int retVal = doHealthValue(); // 调用派生类的重写
// 后置工作:解锁、验证后置条件……
return retVal;
}
private:
virtual int doHealthValue() const = 0; // private 虚函数——派生类在此注入行为
};
class Knight : public GameCharacter {
private:
virtual int doHealthValue() const override { return 100; }
};
NVI 的核心优势:基类在调用虚函数前后执行固定的控制逻辑(加锁、日志、前后置条件检查),派生类只能填写具体行为,无法绕过框架。这是一种"框架调用你,不是你调用框架"的设计哲学。
📖 参考:《Effective C++》条款35
6. 虚函数重写的特殊情况
6.1 协变(Covariance)
派生类重写基类虚函数时,返回值类型可以不同——但仅限于:基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用。这称为协变。
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket() {
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket() { // 返回值 B* 与基类的 A* 不同——构成协变
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr) {
ptr->BuyTicket(); // 多态调用正常生效
}
int main() {
Person ps;
Student st;
Func(&ps); // 买票-全价
Func(&st); // 买票-打折
return 0;
}
💡 协变的实际应用场景并不多,了解即可。但它是"重写要求返回值相同"这条规则的唯一例外。
6.2 析构函数的重写——最容易忽视的坑
这是面试必考题。先看一段有问题的代码:
class A {
public:
~A() { cout << "~A()" << endl; } // 注意:没有 virtual
};
class B : public A {
public:
~B() {
cout << "~B()->delete:" << _p << endl;
delete _p; // 释放资源
}
protected:
int* _p = new int[10];
};
int main() {
A* p2 = new B;
delete p2; // 只调用了 ~A(),B 的资源泄漏了!
return 0;
}
发生了什么? delete p2 时,编译器根据指针的**静态类型(A*)**在编译期就决定了调用 A::~A()——这是静态绑定。B::~B() 根本没被调用,_p 指向的 10 个 int 永远泄漏。
为什么函数名不同却构成重写? 编译器在编译后会把所有析构函数名统一处理成 destructor。所以从编译器的视角看,基类和派生类的析构函数本质上是同名函数——满足重写条件。
修复方案:基类析构函数加 virtual。
class A {
public:
virtual ~A() { cout << "~A()" << endl; } // 加 virtual
};
class B : public A {
public:
~B() { // 自动构成重写,virtual 可省略
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
int main() {
A* p1 = new A;
A* p2 = new B;
delete p1; // ~A()
delete p2; // ~B() → ~A() 正确!先派生类,后基类
return 0;
}
🔴 铁律:只要一个类被设计为基类(会被继承),它的析构函数就必须是
virtual。
📖 参考:《高质量C++/C编程指南》第9章
7. override 和 final:C++11 的安全网
虚函数重写的要求非常严格——返回值类型(协变除外)、函数名、参数列表必须完全一致。问题在于,如果因为疏忽导致函数名拼错一个字母、参数列表差一个 const,编译器不会报错,但你没有真正完成重写——多态静悄悄地失效了。这种 bug 只有在运行时没得到预期结果时才会被发现,debug 成本极高。
C++11 提供了两个关键字来堵上这个口子。
7.1 override:声明"我在重写"
class Car {
public:
virtual void Dirve() {} // 拼写错误:Dirve 而非 Drive
};
class Benz : public Car {
public:
virtual void Drive() override {} // ❌ 编译报错:error C3668
// "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法
};
加上 override 后,编译器会主动检查这个函数是否真的重写了基类的虚函数。只要不匹配——拼写错误、参数不一致、const 缺失——立刻报错。所有重写虚函数的地方都应该加 override——让编译器替你检查,别相信自己的拼写。
7.2 final:声明"到此为止"
final 有两个用法:
用法一:修饰虚函数——禁止派生类重写
class Car {
public:
virtual void Drive() final {} // 此函数不允许任何派生类重写
};
class Benz : public Car {
public:
virtual void Drive() {} // ❌ 编译报错:error C3248
// "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写
};
用法二:修饰类——禁止继承
class Base final { // 此类不能被继承
// ...
};
class Derived : public Base {}; // ❌ 编译报错
💡
override和final不是关键字(keyword),而是标识符(identifier)——它们只在特定上下文中具有特殊含义。也就是说,你可以用override和final作为变量名(但显然不推荐)。
8. 重载、重写、隐藏——三者的清晰边界
这是考试和面试的高频考点。重载和重写大家比较熟悉,但**隐藏(Hide)**经常被忽视——而隐藏恰恰是最容易在不经意间触发的。
8.1 隐藏(Hide)是什么?
派生类中定义了一个与基类同名的函数(不管参数是否相同),只要它不是对基类虚函数的重写,基类的那个同名函数就在派生类中被"遮住"了——这就是隐藏。
class Base {
public:
void func(int x) { cout << "Base::func(int)" << endl; }
};
class Derived : public Base {
public:
void func(double x) { cout << "Derived::func(double)" << endl; }
// 这不是重写(Base::func 不是 virtual),也不是重载(不在同一作用域)
// 这是隐藏!Base::func(int) 在 Derived 中被遮住了
};
int main() {
Derived d;
d.func(10); // 调用 Derived::func(double),10 被隐式转换为 10.0
// d.func(10); // 如果想调用 Base::func(int),需要 d.Base::func(10);
return 0;
}
隐藏的触发条件比想象的更宽松:只要派生类定义了同名函数,且不构成重写——无论参数列表是否相同、无论基类函数是否 virtual——一律触发隐藏。
8.2 三者的完整对比
| 重载(Overload) | 重写(Override) | 隐藏(Hide) | |
|---|---|---|---|
| 作用域 | 同一作用域内(同一个类中) | 基类与派生类之间 | 基类与派生类之间 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 | 可以相同,也可以不同 |
| 返回值 | 可以不同 | 必须相同(协变例外) | 可以不同 |
| virtual | 不要求 | 基类函数必须是 virtual | 不要求(只要不构成重写就隐藏) |
| 关系本质 | 同一函数名的不同版本 | 替换基类的实现 | 派生类名字"遮住"基类名字 |
💡 快速判断口诀:
- 同一类内,名字同、参数不同 → 重载
- 跨类,基类是 virtual,派生类名字+参数+返回值全同 → 重写
- 跨类,基类不是 virtual 或参数不同 → 隐藏
8.3 一个综合辨析题
class Base {
public:
virtual void f1(int) { cout << "Base::f1(int)" << endl; } // (1)
virtual void f2(int) { cout << "Base::f2(int)" << endl; } // (2)
void f3(int) { cout << "Base::f3(int)" << endl; } // (3)
};
class Derived : public Base {
public:
virtual void f1(int) { cout << "Derived::f1(int)" << endl; } // 重写 (1)
void f2(double) { cout << "Derived::f2(double)" << endl; } // 隐藏 (2)
void f3(int) { cout << "Derived::f3(int)" << endl; } // 隐藏 (3)(Base::f3 非virtual)
};
int main() {
Derived d;
Base* pb = &d;
pb->f1(1); // 重写生效 → Derived::f1(int) (多态)
pb->f2(1); // 调用 Base::f2(int)(通过基类指针访问,派生类的隐藏不影响基类)
d.f2(1); // 调用 Derived::f2(double),1 → 1.0(隐藏!基类的 f2(int) 被遮住)
// d.f2(1); // 如果想调基类版本:d.Base::f2(1);
return 0;
}
9. 纯虚函数与抽象类
9.1 基本概念
在虚函数声明末尾加上 = 0,它就是纯虚函数(Pure Virtual Function)。包含纯虚函数的类叫抽象类(Abstract Class)——抽象类不能实例化。
class Car {
public:
virtual void Drive() = 0; // 纯虚函数:只声明接口,不提供实现
};
class Benz : public Car {
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
class BMW : public Car {
public:
virtual void Drive() override { cout << "BMW-操控" << endl; }
};
int main() {
// Car car; // ❌ 编译报错:error C2259 "Car": 无法实例化抽象类
Car* pBenz = new Benz;
pBenz->Drive(); // Benz-舒适
Car* pBMW = new BMW;
pBMW->Drive(); // BMW-操控
return 0;
}
9.2 纯虚函数的设计意义
纯虚函数的核心作用是强制派生类重写该函数。派生类继承后如果不重写纯虚函数,那么派生类也是抽象类,照样实例化不了对象。这是一种接口契约——“想用这个继承体系,就必须实现这些接口。”
从设计角度看,纯虚函数体现的是"只继承接口"的语义。基类说"我要求你能做这件事",但具体怎么做、做得好不好,完全由派生类决定。
💡 纯虚函数语法上可以有实现体:
virtual void f() = 0 { /* 实现 */ }。派生类可以通过Base::f()显式调用它。但这种情况极其罕见——如果派生类必须显式调用,纯虚函数就失去了"强制重写"的意义。了解即可。
10. 多态场景经典选择题
在进入底层原理之前,先看一道经典题目——这道题综合了多态、继承链调用、虚函数默认参数三个考点:
class A {
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A {
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[]) {
B* p = new B;
p->test();
return 0;
}
请问输出结果是什么?
选项:A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确
答案:B(输出 B->1)
调用链分析:
p->test()——B没有重写test(),调用继承来的A::test()A::test()内部执行func()—— 注意这里的func()等价于this->func()this指向的是B对象,func是虚函数 → 动态绑定,调用B::func()- 但默认参数是静态绑定的——
A::test()中写的是func()(没传参数),使用A::func声明的默认值1
所以:函数体来自 B(输出 B->),默认参数值来自 A(val = 1),最终输出 B->1。
考点总结:
test()中通过this->func()调用虚函数 → 动态绑定- 虚函数的默认参数值是静态绑定 → 由调用处可见的静态类型决定
- 即使
B::func声明了自己的默认值0,也不会被用到
11. 虚函数的默认参数陷阱
上一节的题目已经揭示了这个问题,这里正式展开。C++ 中有一条规则让无数人踩过坑:
🔴 虚函数是动态绑定,但默认参数值是静态绑定。
class Shape {
public:
virtual void draw(int color = 0) const {
cout << "Shape::draw, color=" << color << endl;
}
};
class Circle : public Shape {
public:
virtual void draw(int color = 255) const override { // 重新定义了默认参数!
cout << "Circle::draw, color=" << color << endl;
}
};
int main() {
Shape* ps = new Circle;
ps->draw(); // 输出:Circle::draw, color=0
// ^^^^^^ 动态绑定 → Circle 的函数体
// ^^^ 静态绑定 → Shape 声明的默认值 0
delete ps;
return 0;
}
为什么会这样? 默认参数值是在编译期根据调用处的静态类型确定的,而虚函数体是在运行期通过虚表查找的。两者的决策时机不同,来源自然不同。
解决方案:永远不要改变重写虚函数的默认参数值。如果确实需要不同默认行为,用 NVI 模式替代——让 public 非虚函数处理默认值,private 虚函数只负责核心逻辑。
📖 参考:《Effective C++》条款37
12. 多态的原理:深入虚函数表
多态不是黑魔法。它的底层依赖一张表——虚函数表(Virtual Function Table,简称虚表 / vtable)。
12.1 虚函数表指针(__vfptr)
一个包含虚函数的类,编译器会在其每个对象中安插一个隐藏的指针——虚函数表指针(__vfptr,v = virtual, f = function, ptr = pointer)。这个指针指向该类共享的一张虚函数表。
用 sizeof 就能验证它的存在:
class Base {
public:
virtual void Func1() {
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
cout << sizeof(Base) << endl;
// 32位环境输出:12(而不是 5 + 1 = 8 加上对齐)
// 64位环境输出:16(而不是 8,因为 vfptr 占 8 字节)
return 0;
}
内存布局(32位环境):
Base 对象的内存布局(32位):
┌──────────────┐
│ __vfptr │ 4 bytes —→ 指向 Base 的虚函数表
├──────────────┤
│ _b (int) │ 4 bytes
├──────────────┤
│ _ch (char) │ 1 byte
├──────────────┤
│ (padding) │ 3 bytes —→ 对齐到 4 的倍数
└──────────────┘
总计:12 bytes
⚠️
__vfptr在对象中的位置与编译器平台有关——VS 编译器一般放在对象最前面,某些平台可能放在最后面。这是实现细节,C++ 标准未作规定。
关键事实:
- 一个含有虚函数的类中至少有一个虚函数表指针(多继承情况下可能有多个)
- 同类型的所有对象共享同一张虚函数表
- 虚函数表中的条目是函数指针,指向实际的函数代码
12.2 派生类的虚函数表构成
派生类的虚函数表由三个部分组成:
- 基类的虚函数地址(未被派生类重写的,原样复制过来)
- 派生类重写的虚函数地址(覆盖掉基类对应位置的函数指针)
- 派生类自己新增的虚函数地址(追加在表后面)
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; } // 普通函数——不在虚表中
protected:
int a = 1;
};
class Derive : public Base {
public:
virtual void func1() override { cout << "Derive::func1" << endl; } // 覆盖 Base::func1
virtual void func3() { cout << "Derive::func3" << endl; } // 新增
void func4() { cout << "Derive::func4" << endl; } // 普通函数——不在虚表中
protected:
int b = 2;
};
Base 虚函数表:[ &Base::func1, &Base::func2, 0x00000000 ]
Derive 虚函数表:[ &Derive::func1, &Base::func2, &Derive::func3, 0x00000000 ]
↑ 覆盖了 ↑ 保留基类 ↑ 新增
内存布局可视化:
Derive 对象(32位):
┌──────────────────┐
│ __vfptr ────────┼──→ Derive 虚函数表
├──────────────────┤ ┌─────────────────────┐
│ Base::a = 1 │ │ &Derive::func1 │ ← 覆盖后的地址
├──────────────────┤ │ &Base::func2 │ ← 继承来的地址
│ Derive::b = 2 │ │ &Derive::func3 │ ← 新增的地址
└──────────────────┘ │ 0x00000000 │ ← 结束标记(VS编译器)
└─────────────────────┘
几个关键事实:
- 虚函数和普通函数一样,编译后都是代码段中的指令。唯一区别是:虚函数的地址还被额外存到了虚表中。
- 普通函数不在虚表中——
func4()、func5()的地址不会出现在任何虚表里。 - VS 编译器在虚表末尾放
0x00000000作为结束标记;g++ 不这样做。这是编译器实现细节,C++ 标准未规定。
12.3 动态绑定如何运作——汇编视角
当通过基类指针调用虚函数时:
ptr->BuyTicket();
编译器不再在编译期确定函数地址,而是生成这样的逻辑:运行时,去 ptr 指向的对象的虚表中,查找 BuyTicket 的实际地址,然后调用它。
这就是动态绑定(Dynamic Binding)。我们来对比汇编代码:
动态绑定(满足多态条件——指针 + 虚函数):
; ptr->BuyTicket(); — 动态绑定
mov eax, dword ptr [ptr] ; 1. 取出 ptr 指向的对象地址
mov edx, dword ptr [eax] ; 2. 从对象开头取出虚表指针(__vfptr)
mov esi, esp
mov ecx, dword ptr [ptr] ; 3. 准备 this 指针(ecx = ptr)
mov eax, dword ptr [edx] ; 4. 从虚表第一项取出函数地址
call eax ; 5. 间接调用——地址在运行时才确定
静态绑定(不满足多态条件——函数不是 virtual):
; ptr->BuyTicket(); — 静态绑定
mov ecx, dword ptr [ptr] ; 1. 准备 this 指针
call Student::BuyTicket (0EA153Ch) ; 2. 直接调用——地址在编译期已确定
区别一目了然:
- 静态绑定:
call 固定地址——一步到位 - 动态绑定:
mov eax, [ptr] → mov edx, [eax] → mov eax, [edx] → call eax——两次解引用,间接跳转
💡 虚函数调用的额外开销就是一次额外的指针解引用(通过虚表间接跳转)。这在绝大多数场景中完全值得——换来的是设计清晰度和可维护性的巨大提升。只有当 profiling 确凿证明虚函数调用是性能热点时,才考虑优化。
12.4 虚函数表存在哪里?
这个问题没有标准答案(C++ 标准未规定),但我们可以通过实验验证。以下代码在 VS 编译器下的运行结果表明:虚表存在代码段(常量区)。
int main() {
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 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);
return 0;
}
VS 运行结果示例:
栈: 010FF954
静态区: 0071D000
堆: 0126D740
常量区: 0071ABA4
Person虚表地址: 0071AB44 ← 与常量区地址接近!
Student虚表地址: 0071AB84 ← 与常量区地址接近!
虚函数地址: 00711488
普通函数地址: 007114BF
虚表地址(0071AB44、0071AB84)与常量区地址(0071ABA4)非常接近——说明 VS 编译器把虚表放在了代码段(常量区)。虚函数本身的地址(00711488)也在同一区域,因为虚函数和普通函数一样,编译后都是代码段的指令。
⚠️ 再次强调:虚表的具体存储位置是编译器实现细节,不同编译器可能不同。不要在实际代码中依赖虚表的位置。
13. 运行时类型识别:dynamic_cast
多态让我们通过基类指针调用派生类的虚函数。但如果需要访问派生类独有的成员(不在基类接口中的),就必须把基类指针安全地转回派生类指针——这就是 dynamic_cast 的用武之地:
void processPerson(Person* ptr) {
// 尝试安全转型为 Student*
if (Student* sp = dynamic_cast<Student*>(ptr)) {
// 转型成功——ptr 确实指向 Student
cout << "学号: " << sp->_stuid << endl;
sp->study(); // 调用 Student 独有成员
} else if (Soldier* sop = dynamic_cast<Soldier*>(ptr)) {
cout << "代号: " << sop->_codename << endl;
} else {
// 普通 Person
cout << "全价购票" << endl;
}
}
几个要点:
dynamic_cast对指针失败时返回nullptr——用if判断即可dynamic_cast对引用失败时抛出std::bad_cast——需要用try/catchdynamic_cast有运行时开销——需要遍历继承树来验证类型。不要在高频循环中使用- 频繁出现
dynamic_cast往往是设计不良的信号——考虑用虚函数替代类型分支
// 更好的设计:把行为放进虚函数,消除 dynamic_cast
class Person {
public:
virtual void displayInfo() const { cout << "全价" << endl; }
virtual ~Person() = default;
};
class Student : public Person {
public:
virtual void displayInfo() const override {
cout << "学号: " << _stuid << "半价" << endl;
}
protected:
int _stuid;
};
📖 参考:《C++ Primer》第15章
14. 常见误区
误区一:“加 virtual 会影响性能,所以尽量少用”
虚函数调用的额外开销是一次指针解引用(通过虚表跳转)。在多态设计带来的代码清晰度和可维护性面前,这点开销完全值得。只有当 profiling 确凿证明虚函数调用是性能热点时,才考虑优化。先写对,再优化。
误区二:“派生类重写虚函数可以不写 virtual”
语法上确实可以不写——基类的 virtual 属性会被继承,派生类中仍然是虚函数。但从可读性角度看,缺少 virtual 让阅读者需要回溯到基类才能确认这是虚函数。统一写法:重写虚函数时加上 virtual,再叠加 override——virtual void func() override {}。
误区三:“析构函数没必要 virtual,我又不用基类指针 delete”
只要一个类可能被继承,就有人会在未来的某天用基类指针持有派生类对象。等到资源泄漏发生,debug 的成本远比加一个 virtual 高昂。这条规则没有例外——除非你明确用 final 禁止继承。
误区四:“纯虚函数不能有实现”
语法上纯虚函数可以有实现体:virtual void f() = 0 { /* ... */ }。但调用它的方式只能是 Base::f() 这种显式限定调用。实际项目中几乎不需要这么做,了解即可。
误区五:“隐藏和重写是一回事”
隐藏发生在基类函数不是 virtual 或参数列表不同的情况下——语法上它只是遮住了基类名字,不参与多态。重写一定是基类有 virtual、派生类三要素(返回值 + 函数名 + 参数)完全相同。两者底层机制完全不同。
15. 本节要点总结
| 知识点 | 核心要义 |
|---|---|
| 多态构成条件 | 基类指针/引用 + 虚函数重写,两个条件缺一不可 |
| 静态类型 vs 动态类型 | 只有指针和引用才有动态类型;值传递会切片 |
| 三种函数契约 | 纯虚 = 只继承接口;普通虚 = 接口+默认实现;非虚 = 接口+强制实现 |
| 协变 | 返回值类型可以不同,但仅限于基类/派生类指针或引用的对应关系 |
| 析构函数 | 基类析构函数务必加 virtual,否则 delete 基类指针时派生类资源泄漏 |
| override / final | C++11 安全网——override 让编译器检查重写;final 禁止进一步重写或继承 |
| 重载 / 重写 / 隐藏 | 三个不同维度的概念:同一作用域 vs 跨作用域;参数不同 vs 相同;virtual vs 非 virtual |
| 纯虚函数 = 接口契约 | 抽象类不能实例化,派生类不重写纯虚函数就也是抽象类 |
| 虚函数表(vtable) | 多态的底层实现——编译期不决定函数地址,运行期查表 |
| 动态绑定 vs 静态绑定 | 动态 = 两次解引用 + 间接调用(虚表);静态 = 直接 call 固定地址 |
| 虚表存储位置 | VS 编译器下存在代码段(常量区),g++ 可能不同——这是实现细节 |
| 默认参数陷阱 | 虚函数动态绑定,但默认参数值静态绑定——永远不要改变重写虚函数的默认参数 |
| NVI 模式 | public 非虚接口 + private 虚实现——框架调用你,不是你调用框架 |
📖 参考:《高质量C++/C编程指南》第9章(虚析构函数);《Effective C++》条款34-37(接口继承、虚函数替代方案、非虚函数、默认参数)、第7-9章(编译期多态 vs 运行期多态);《C++ Primer》第15章(面向对象程序设计);《C++ Primer Plus》第13章(多态公有继承)
*本文有改动,第一次写的时候发现内容有点省略的太多了
📎 上一篇:C++ 继承:代码复用的层次之道 —— 多态的根基是继承,先理解 is-a 关系再来驾驭虚函数。
更多推荐



所有评论(0)