C++ 多态详解:虚函数、动态绑定、抽象类与虚表原理
多态的代码往往很短:基类函数前加一个 virtual,派生类中写一个同名函数,再通过基类指针调用。真正容易出问题的地方并不在关键字本身,而在于调用究竟何时绑定、怎样才算正确重写、对象内部如何支持动态分派,以及为什么多态基类通常需要虚析构函数。
这篇文章从一个完整示例开始,逐步拆解虚函数重写、override、final、协变返回、抽象类和虚函数表。默认读者已经了解类、对象和公有继承。
一、多态是什么
多态的字面含义是"多种形态"。在程序中,它表现为同一个调用接口面对不同类型的对象时,可以执行不同的行为。
C++ 中常见的多态可以分成两类:
| 类型 | 决定调用目标的时间 | 常见形式 |
|---|---|---|
| 静态多态 | 编译期 | 函数重载、运算符重载、函数模板 |
| 动态多态 | 运行期 | 继承、虚函数、基类指针或引用 |
函数重载为什么属于静态多态?因为编译器根据实参类型和候选函数,在生成程序时就能确定调用哪个版本。
cpp
void Print(int value);
void Print(double value);
Print(10); // 编译期确定为 Print(int)
Print(3.14); // 编译期确定为 Print(double)
动态多态则要等到程序运行时,根据对象的实际类型决定调用目标。它适合解决这样的问题:调用方只依赖统一接口,但具体行为由不同实现类完成。
二、一个完整的动态多态示例
假设程序需要通过不同通道发送消息。调用方不应该为每种通道分别编写一套流程,只要面向共同接口调用 Send 即可。
下面是为了帮助理解而补充的完整示例:
cpp
#include <iostream>
#include <string>
#include <vector>
class MessageChannel {
public:
virtual void Send(const std::string& message) const {
std::cout << "generic: " << message << '\n';
}
virtual ~MessageChannel() = default;
};
class ConsoleChannel : public MessageChannel {
public:
void Send(const std::string& message) const override {
std::cout << "console: " << message << '\n';
}
};
class AuditChannel : public MessageChannel {
public:
void Send(const std::string& message) const override {
std::cout << "audit: " << message << '\n';
}
};
void Notify(const MessageChannel& channel,
const std::string& message) {
channel.Send(message);
}
int main() {
MessageChannel generic;
ConsoleChannel console;
AuditChannel audit;
Notify(generic, "service started");
Notify(console, "service started");
Notify(audit, "service started");
std::vector<const MessageChannel*> channels{
&console, &audit
};
for (const MessageChannel* channel : channels) {
channel->Send("health check passed");
}
return 0;
}
编译并运行:
bash
g++ -std=c++11 -Wall -Wextra -pedantic polymorphism_basic.cpp -o polymorphism_basic
./polymorphism_basic
预期输出:
text
generic: service started
console: service started
audit: service started
console: health check passed
audit: health check passed
Notify 的形参始终是 const MessageChannel&,循环中的变量也始终是 const MessageChannel*,但调用结果取决于引用或指针实际绑定的对象。这就是运行时多态最直观的表现。
这里有三个关键点:
ConsoleChannel和AuditChannel公有继承MessageChannel;- 基类的
Send是虚函数,派生类正确重写了它; - 调用发生在基类引用或基类指针上。
少了其中任何一项,都可能得不到预期的动态分派效果。
三、虚函数与动态多态的构成条件
1. 什么是虚函数
在非静态成员函数声明前添加 virtual,该函数就成为虚函数:
cpp
class Base {
public:
virtual void Run() {
std::cout << "Base::Run\n";
}
};
虚函数属于类的成员函数。普通非成员函数和 static 成员函数都不能声明为虚函数,因为动态分派依赖调用对象,而静态成员函数没有 this 指针。
派生类重写虚函数时,即使省略 virtual,函数仍然保持虚函数属性:
cpp
class Derived : public Base {
public:
void Run() override {
std::cout << "Derived::Run\n";
}
};
现代 C++ 更推荐使用 override。它既表达意图,也让编译器检查这个函数是否真的完成了重写。
2. 为什么需要基类指针或引用
基类指针和引用既能关联基类对象,也能关联公有派生类对象:
cpp
Base base;
Derived derived;
Base* pointer = &base;
pointer = &derived;
Base& reference = derived;
调用表达式的静态类型都是 Base,但对象的动态类型可能是 Base,也可能是某个派生类。虚函数机制正是利用这两种类型的差异,在运行时选择最终函数。
- 静态类型:编译器从表达式本身看到的类型;
- 动态类型:指针或引用在运行时实际关联对象的类型。
在 Base* pointer = &derived; 中,pointer 的静态类型是 Base*,它所指对象的动态类型是 Derived。
3. 对象调用为什么通常看不出多态价值
cpp
Derived object;
object.Run();
object 的静态类型和动态类型都是 Derived,调用目标没有悬念。即使 Run 是虚函数,编译器也常常可以直接确定或优化调用。
动态多态真正有价值的场景,是调用方只知道基类接口,实际对象却可能来自多个派生类。
四、怎样才算虚函数重写
派生类对基类虚函数的重新实现称为重写,也常称为覆盖。常规情况下,下面这些部分需要匹配:
- 函数名相同;
- 参数列表相同;
- 成员函数的
const、引用限定符等保持匹配; - 返回类型相同,协变返回是特例。
cpp
class Base {
public:
virtual void Process(int value) const {}
};
class Derived : public Base {
public:
void Process(int value) const override {}
};
下面几个版本都没有正确重写:
cpp
class WrongDerived : public Base {
public:
// 参数类型不同
// void Process(double value) const override {}
// 缺少 const
// void Process(int value) override {}
// 函数名拼写不同
// void Proccess(int value) const override {}
};
保留 override 时,这些错误会在编译期被直接指出。去掉 override 后,代码可能仍能通过编译,但派生类只是声明了另一个函数,运行结果就容易偏离设计意图。
virtual 是否必须在派生类中重复书写
语法上不必重复。基类函数一旦是虚函数,在继承体系中会继续保持虚函数属性。下面两种写法都能构成重写:
cpp
void Process(int value) const;
void Process(int value) const override;
第二种写法更适合实际开发,因为它把编译器变成重写规则的检查者。
五、override 与 final
1. override 检查重写是否成立
cpp
class Service {
public:
virtual void Start() {}
};
class FileService : public Service {
public:
void Start() override {}
};
如果基类写成 Start,派生类误写成 Strat,override 会让编译立即失败。没有这个说明符,拼错的函数会被当作一个全新的成员,错误可能到运行时才暴露。
2. final 禁止继续重写
把 final 放在虚函数后,表示后续派生类不能再重写该函数:
cpp
class Service {
public:
virtual void Stop() final {}
};
class FileService : public Service {
public:
// void Stop() override {} // 编译错误
};
final 也可以修饰类,表示这个类不能继续派生:
cpp
class ClosedService final : public Service {};
两种用法表达的约束不同:函数级 final 封闭某个行为,类级 final 封闭整个继承入口。
六、重载、重写与隐藏不要混淆
这三个概念都可能出现同名函数,但判断标准完全不同。
| 对比项 | 重载 | 重写 | 隐藏 |
|---|---|---|---|
| 作用域 | 同一个作用域 | 基类与派生类 | 基类与派生类 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 通常必须相同 | 可以相同,也可以不同 |
| 是否要求虚函数 | 不要求 | 基类函数必须是虚函数 | 不要求 |
| 返回类型 | 可以不同,但不能仅靠返回类型区分 | 通常相同,协变除外 | 没有统一要求 |
| 主要目的 | 同名接口处理不同参数 | 派生类替换基类虚行为 | 名称查找时遮蔽基类成员 |
看一段容易误判的代码:
cpp
class Base {
public:
virtual void Print(int value) const {
std::cout << "Base: " << value << '\n';
}
};
class Derived : public Base {
public:
void Print(double value) const {
std::cout << "Derived: " << value << '\n';
}
};
Derived::Print(double) 没有重写 Base::Print(int),它只是隐藏了基类中的同名函数。通过基类指针调用时,仍会执行 Base::Print(int):
cpp
Derived derived;
Base* base = &derived;
base->Print(10); // Base::Print(int)
若派生类需要保留基类的重载集合,可以使用 using:
cpp
class Derived : public Base {
public:
using Base::Print;
void Print(double value) const {
std::cout << "Derived: " << value << '\n';
}
};
这里仍然没有发生重写,只是基类版本被引入派生类作用域,可以参与重载解析。
七、虚函数重写的几个特殊情况
1. 协变返回类型
一般重写要求返回类型一致,但 C++ 允许一种受限的例外:基类虚函数返回某个类的指针或引用时,派生类重写函数可以返回该类的派生类型指针或引用,这称为协变返回。
cpp
#include <iostream>
class Document {
public:
virtual Document* Clone() const {
std::cout << "clone Document\n";
return new Document(*this);
}
virtual ~Document() = default;
};
class Report : public Document {
public:
Report* Clone() const override {
std::cout << "clone Report\n";
return new Report(*this);
}
};
int main() {
Report report;
Document* source = &report;
Document* copy = source->Clone();
delete copy;
}
Document::Clone 返回 Document*,Report::Clone 返回 Report*。因为 Report* 可以安全向上转换为 Document*,这个重写是合法的。
协变只适用于指针或引用返回,不意味着普通值类型可以随意改变:
cpp
// virtual Document Clone() const;
// Report Clone() const override; // 不构成合法协变
2. 析构函数的重写
基类和派生类的析构函数名字看起来不同,例如 ~Base() 与 ~Derived(),但当基类析构函数是虚函数时,派生类析构函数会参与虚分派。
cpp
#include <iostream>
class Resource {
public:
Resource() : buffer_(new int[4]) {}
virtual ~Resource() {
std::cout << "Resource destroyed\n";
delete[] buffer_;
}
private:
int* buffer_;
};
class CachedResource : public Resource {
public:
CachedResource() : cache_(new int[8]) {}
~CachedResource() override {
std::cout << "CachedResource destroyed\n";
delete[] cache_;
}
private:
int* cache_;
};
int main() {
Resource* resource = new CachedResource;
delete resource;
}
预期输出:
text
CachedResource destroyed
Resource destroyed
如果 Resource::~Resource 不是虚函数,通过 Resource* 删除 CachedResource 对象会产生未定义行为,不能简单理解为"只少调用一次析构函数"。资源泄漏只是可能出现的后果之一。
注意:只要一个类准备作为多态基类,并且对象可能通过基类指针销毁,析构函数就应当是虚函数。写成
virtual ~Base() = default;通常足够。
3. 默认参数不会动态绑定
默认实参在编译期根据调用点可见的静态类型确定,虚函数的函数体则可能在运行时动态选择。两套规则混在一起,会产生不够直观的结果:
cpp
#include <iostream>
class Base {
public:
virtual void Print(int value = 1) const {
std::cout << "Base: " << value << '\n';
}
};
class Derived : public Base {
public:
void Print(int value = 0) const override {
std::cout << "Derived: " << value << '\n';
}
};
int main() {
Derived object;
Base* base = &object;
base->Print(); // Derived: 1
object.Print(); // Derived: 0
}
base->Print() 的函数体动态分派到 Derived::Print,但 base 的静态类型是 Base*,默认值取自 Base::Print,所以输出 Derived: 1。
虚函数接口尽量不要在不同层级提供不同默认值。更稳妥的做法是统一默认值,或者把默认参数移到非虚包装函数中。
八、纯虚函数与抽象类
如果基类只负责规定接口,并不提供有意义的通用实现,可以把函数声明为纯虚函数:
cpp
class Storage {
public:
virtual void Save(const std::string& data) = 0;
virtual ~Storage() = default;
};
包含纯虚函数的类是抽象类。抽象类不能直接实例化:
cpp
// Storage storage; // 编译错误:Storage 是抽象类
派生类只有实现所有尚未实现的纯虚函数,才会成为可以实例化的具体类:
cpp
#include <iostream>
#include <string>
class Storage {
public:
virtual void Save(const std::string& data) = 0;
virtual ~Storage() = default;
};
class MemoryStorage : public Storage {
public:
void Save(const std::string& data) override {
value_ = data;
std::cout << "saved: " << value_ << '\n';
}
private:
std::string value_;
};
void Persist(Storage& storage, const std::string& data) {
storage.Save(data);
}
int main() {
MemoryStorage storage;
Persist(storage, "configuration");
}
预期输出:
text
saved: configuration
抽象类的价值不只是"不能创建对象",更重要的是它把调用方与具体实现隔开。Persist 只依赖 Storage 接口,后续可以接入文件存储、数据库存储或测试替身,而不用修改调用流程。
纯虚函数能否有函数体
纯虚函数在语法上可以在类外提供定义,但类仍然是抽象类。派生类也仍需重写该函数,才能成为具体类。
cpp
class Base {
public:
virtual void Run() = 0;
};
void Base::Run() {
std::cout << "shared implementation\n";
}
派生类可以在自己的重写中显式调用 Base::Run(),用于复用公共实现。这种写法并不常见,理解它有助于区分"纯虚声明"和"函数完全不存在"。
工程提示:纯虚析构函数必须提供定义,因为销毁派生对象时最终仍要执行基类析构过程。
九、静态绑定与动态绑定
绑定可以理解为把一次函数调用与实际执行的函数实现连接起来。
静态绑定
非虚函数调用、重载解析等通常在编译期确定目标:
cpp
class Base {
public:
void Run() const {
std::cout << "Base::Run\n";
}
};
class Derived : public Base {
public:
void Run() const {
std::cout << "Derived::Run\n";
}
};
Derived object;
Base* pointer = &object;
pointer->Run(); // Base::Run
Run 不是虚函数,编译器只看 pointer 的静态类型 Base*,直接绑定到 Base::Run。
动态绑定
把基类函数声明为虚函数后,通过基类指针或引用调用它,目标会依据对象的动态类型决定:
cpp
class Base {
public:
virtual void Run() const {
std::cout << "Base::Run\n";
}
};
class Derived : public Base {
public:
void Run() const override {
std::cout << "Derived::Run\n";
}
};
Derived object;
Base* pointer = &object;
pointer->Run(); // Derived::Run
调用过程可以抽象为:
#mermaid-svg-Hi8JINLNc0AcRBQy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Hi8JINLNc0AcRBQy .error-icon{fill:#552222;}#mermaid-svg-Hi8JINLNc0AcRBQy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Hi8JINLNc0AcRBQy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy .marker.cross{stroke:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Hi8JINLNc0AcRBQy p{margin:0;}#mermaid-svg-Hi8JINLNc0AcRBQy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster-label text{fill:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster-label span{color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster-label span p{background-color:transparent;}#mermaid-svg-Hi8JINLNc0AcRBQy .label text,#mermaid-svg-Hi8JINLNc0AcRBQy span{fill:#333;color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .node rect,#mermaid-svg-Hi8JINLNc0AcRBQy .node circle,#mermaid-svg-Hi8JINLNc0AcRBQy .node ellipse,#mermaid-svg-Hi8JINLNc0AcRBQy .node polygon,#mermaid-svg-Hi8JINLNc0AcRBQy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .rough-node .label text,#mermaid-svg-Hi8JINLNc0AcRBQy .node .label text,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape .label,#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape .label{text-anchor:middle;}#mermaid-svg-Hi8JINLNc0AcRBQy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .rough-node .label,#mermaid-svg-Hi8JINLNc0AcRBQy .node .label,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape .label,#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape .label{text-align:center;}#mermaid-svg-Hi8JINLNc0AcRBQy .node.clickable{cursor:pointer;}#mermaid-svg-Hi8JINLNc0AcRBQy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy .arrowheadPath{fill:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Hi8JINLNc0AcRBQy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Hi8JINLNc0AcRBQy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hi8JINLNc0AcRBQy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Hi8JINLNc0AcRBQy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hi8JINLNc0AcRBQy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster text{fill:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster span{color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Hi8JINLNc0AcRBQy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy rect.text{fill:none;stroke-width:0;}#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape p,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape .label rect,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hi8JINLNc0AcRBQy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Hi8JINLNc0AcRBQy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Hi8JINLNc0AcRBQy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过基类指针或引用调用虚函数
读取对象的动态类型信息
定位该类型对应的虚函数入口
调用最终重写函数
这是语言层面的行为。编译器在能够证明对象实际类型时,可能进行去虚拟化优化,直接生成目标函数调用,但程序的可观察结果不能改变。
十、虚函数表如何支持动态分派
C++ 标准规定了虚函数调用应表现出的语义,却没有强制编译器必须使用某种对象布局。主流编译器通常通过虚函数表 和虚函数表指针实现动态分派。
1. 常见实现模型
含虚函数的对象通常带有一个隐藏指针,常称为 vptr。这个指针指向对象动态类型对应的虚函数表,常称为 vtable。虚表中保存虚函数入口。
text
Base 对象
+------------------+ Base 虚表
| vptr ------------|-----> +------------------+
| base_data | | &Base::Run |
+------------------+ | &Base::~Base |
+------------------+
Derived 对象
+------------------+ Derived 虚表
| Base 子对象 | +------------------+
| vptr ----------|-----> | &Derived::Run |
| base_data | | &Derived::~Derived|
+------------------+ +------------------+
| derived_data |
+------------------+
当派生类重写 Run 时,对应虚表槽位会指向 Derived::Run。没有被重写的基类虚函数通常继续使用基类版本,派生类新增的虚函数则可能占用新的槽位。
基类指针虽然只能看到基类接口,但它指向的基类子对象中保存着与动态类型匹配的信息。调用虚函数时,程序就能沿着这个入口找到派生类实现。
2. 同类型对象通常共享虚表
虚函数代码不会复制进每个对象。常见实现中,同一种动态类型的对象共享一张虚表,每个对象只保存指向相应虚表的隐藏指针以及自己的数据成员。
text
Derived object1 --vptr--+
+--> Derived vtable
Derived object2 --vptr--+
虚函数本身和普通成员函数一样,最终都是可执行指令。区别在于虚函数的入口还会参与动态分派结构。
3. sizeof 结果为什么不能死记
假设一个类含有 int、char 和虚函数,在某个 32 位环境中可能因为隐藏指针和对齐得到 12 字节。但这个结果不能推广到所有环境:
- 32 位与 64 位环境的指针大小不同;
- 编译器 ABI 和对象布局策略可能不同;
- 对齐、填充、多继承和虚继承都会影响对象大小;
- 标准并未规定
vptr必须位于对象开头,也未规定虚表必须存放在哪个内存区域。
因此,sizeof 题目必须结合目标平台、编译器和编译选项分析。能够确定的是语言行为,不是某次调试窗口显示的具体地址。
4. 不要用强制转换手工读取虚表
把对象地址强制转换为整数指针,再把某段内存当成虚表调用,是依赖具体 ABI 的实验手法。它还可能违反对象模型、别名规则和函数指针转换要求,不适合作为可移植业务代码。
理解虚表时使用概念模型即可;需要观察实现时,应把结论限定在明确的编译器、平台和构建配置中。
十一、对象切片会让动态类型消失
多态依赖基类指针或引用保留原对象的动态类型。如果按值把派生类对象复制给基类对象,只会复制基类子对象,派生部分被切掉:
cpp
#include <iostream>
class Base {
public:
virtual void Run() const {
std::cout << "Base::Run\n";
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void Run() const override {
std::cout << "Derived::Run\n";
}
};
void CallByValue(Base object) {
object.Run();
}
void CallByReference(const Base& object) {
object.Run();
}
int main() {
Derived derived;
CallByValue(derived); // Base::Run
CallByReference(derived); // Derived::Run
}
CallByValue 的形参是一个新建的 Base 对象,其动态类型就是 Base。CallByReference 没有复制对象,引用仍绑定原来的 Derived,因此动态分派得以保留。
需要存放多态对象集合时,也不要使用 std::vector<Base> 保存派生对象。更常见的方式是保存智能指针:
cpp
std::vector<std::unique_ptr<Base>> objects;
这里需要包含 <memory> 和 <vector>,并确保基类析构函数为虚函数。
十二、常见问题与易错点
1. 派生类重写时不写 virtual 可以吗
可以。虚函数属性会沿继承体系保留,但建议写 override:
cpp
void Run() override;
它比重复写 virtual 更有价值,因为能够检查签名是否匹配。
2. 只要函数同名就是重写吗
不是。参数列表、const 等限定不匹配时,通常属于隐藏或另一个新函数。给预期重写的函数加 override,不要只靠肉眼比较。
3. 返回类型不同一定不能重写吗
一般不能。只有满足协变规则的类指针或类引用返回类型可以不同,普通值类型不适用。
4. 基类析构函数为什么要声明为虚函数
因为通过基类指针删除派生对象时,需要根据动态类型进入派生类析构过程。非虚析构会导致未定义行为,不能保证派生资源被正确释放。
5. 抽象类能否定义构造函数和普通成员
可以。抽象类不能直接实例化,不代表它不能保存基类状态或提供公共实现。创建派生对象时,抽象基类的构造函数仍会参与基类子对象的初始化。
6. 纯虚函数是否必须由第一层派生类实现
不必须。如果某个派生类仍然不实现全部纯虚函数,它会继续保持抽象,后续派生类可以再完成实现。
7. 虚函数是否一定比普通函数慢
动态分派通常多一次间接访问,也可能影响内联,但不能脱离场景笼统判断性能。编译器能够证明动态类型时,还可能去虚拟化。接口设计正确性通常比猜测一次调用成本更值得优先考虑,性能敏感路径应通过实际测量判断。
8. 虚表中是否保存普通成员函数
通常不会。虚表服务于虚函数动态分派,普通成员函数采用静态绑定,不需要占用虚函数入口。
9. 每个对象都有一整张虚表吗
常见实现不是这样。同类型对象通常共享虚表,对象中保存的是指向相应虚表的隐藏指针。具体布局仍属于实现细节。
10. 构成多态后一定在运行时查表吗
语言要求的是动态分派语义。主流实现常通过虚表完成,但优化器若能推断唯一目标,可以把间接调用优化成直接调用。不能用"源码写了虚函数"推断最终机器指令一定包含某种固定查表过程。
十三、实际设计中的使用建议
1. 用抽象接口表达稳定能力
当多个类型确实需要通过统一入口被替换使用时,可以定义小而清晰的抽象基类。接口只描述调用方真正需要的行为,不要为了复用几个成员就创建庞大的继承体系。
2. 多态基类提供虚析构函数
这是多态所有权设计的底线。即使当前代码只使用栈对象,也要考虑接口是否允许未来通过基类指针管理对象。
3. 重写函数统一使用 override
它能捕获拼写、参数和限定符错误,还能让阅读者一眼识别函数在继承体系中的角色。
4. 默认参数不要随层级改变
默认实参静态绑定、函数体动态绑定,两者组合很容易产生反直觉结果。统一默认值或使用非虚包装接口更稳妥。
5. 用引用或智能指针传递多态对象
按值传递会发生对象切片。无所有权传递可以使用引用或观察指针;需要表达独占所有权时,可以使用 std::unique_ptr<Base>。
6. 区分语言规则与编译器实现
动态分派、重写和抽象类属于语言语义;vptr 位置、虚表布局、对象大小和虚表所在区域属于实现策略。调试观察可以帮助理解,但不要把单个平台的结果写进跨平台逻辑。
十四、总结
C++ 动态多态建立在继承、虚函数和基类指针或引用之上。调用表达式的静态类型决定编译期能看到哪些接口,对象的动态类型决定虚函数最终执行哪个重写版本。
重写要求严格匹配函数签名,override 能把许多隐蔽错误提前到编译期;final 用于封闭重写或继承;协变返回和虚析构属于重写规则中的特殊情况;纯虚函数则把基类塑造成不能直接实例化的抽象接口。
从常见实现看,对象通过隐藏的虚表指针关联所属类型的虚函数表,重写函数替换对应入口,从而完成运行时分派。但对象布局、虚表位置和具体大小并不是标准承诺。掌握多态的关键,是把接口语义、调用规则和实现模型分层理解,再用虚析构、override、引用与智能指针把这些规则落实到代码中。