目录
多态是面向对象程序设计的三大支柱之一,也是 C++ 面试里被问得最勤的主题。许多教材把多态讲成一句“同一接口、多种实现”,但停留在比喻层面无助于应对追问:虚函数究竟怎么在运行期找到正确的函数?虚函数表的存在会给对象增加多少内存?多重继承下为什么会出现 this 指针调整?构造函数里调用虚函数会不会下降到派生类?
概念框架
多态(polymorphism)字面意思是“多种形态”。在 C++ 中它分为两类,二者的关键差异在于函数绑定的时机:
静态多态在编译期就确定了调用哪个函数,典型手段是函数重载、运算符重载与模板。它的好处是零运行期开销,缺点是灵活性受编译期信息限制。
动态多态在运行期才根据对象的实际类型确定调用哪个函数,手段是虚函数配合继承与基类指针/引用。换来的是事后可加新派生类型,代价是一次间接调用,以及每对象一份(多重继承下可多份)虚表指针。
理解这两条线的边界,是判断“该用重载还是该用虚函数”“为什么这里没有多态生效”的前提。
静态多态:编译期决议
静态多态不依赖虚函数表,完全由编译器在编译期根据调用处的类型信息选定函数。最常见的是函数重载与运算符重载。
下面的 Point 同时提供 operator+ 与 operator<< 两个重载。这两处调用在编译期就已绑定到具体函数,运行期不存在查表动作。
#include <iostream>
// 运算符重载是静态多态的一种:operator+ 在编译期按参数类型决议
class Point {
public:
int x = 0;
int y = 0;
// 成员 operator+:编译期按操作数类型决议,运行期不查表
Point operator+(const Point& other) const {
Point r;
r.x = x + other.x;
r.y = y + other.y;
return r;
}
};
// 输出运算符重载,使 Point 可用 << 直接打印
std::ostream& operator<<(std::ostream& os, const Point& p) {
os << "Point(" << p.x << ", " << p.y << ")";
return os;
}
int main() {
Point a{1, 2};
Point b{3, 4};
// 以下两次调用在编译期即绑定到对应重载,运行期不再查表
Point c = a + b;
std::cout << c << "\n";
}
输出:
Point(4, 6)
静态多态的另一支是模板:函数模板与类模板根据实参类型实例化出不同版本,同样在编译期完成。模板带来的多态有时被称为“参数化多态”,它与虚函数机制互不相关。
静态多态的优势在于性能:没有间接调用、可被内联、便于编译器做常量传播。但它要求所有类型在编译期可知;运行期才确定类型时,要用动态多态、类型擦除等手段。是否上 CRTP,先看类型集闭不封闭,见后文“多态选型”。
动态多态:运行期决议
动态多态要成立,行为上需要同时满足:
其一,基类把成员函数声明为 virtual(或该函数继承自基类虚函数),表示允许在派生类中被覆盖。
其二,派生类提供签名匹配的覆盖实现。override 推荐写上以便编译期校验,但并非动态绑定的必要条件,不写 override 只要签名匹配同样会覆盖并参与分发。
其三,通过基类的指针或引用(而非对象本身)去调用该函数,使调用处无法在编译期确定对象的实际类型。注意用限定名(如 ra.Animal::speak())会抑制虚调用,始终绑定到指定类的版本。
按值传参或按值赋值会触发对象切片,动态绑定同样失效。下面用 Animal / Dog / Cat 演示虚函数、覆盖与基类引用的配合。
#include <iostream>
// 基类把 speak 声明为虚函数,允许派生类重写
struct Animal {
virtual void speak() const {
std::cout << "Animal\n";
}
// 基类析构函数声明为虚函数,确保经基类指针删除派生对象时析构链完整
virtual ~Animal() = default;
};
struct Dog : Animal {
void speak() const override {
std::cout << "Dog\n";
}
};
struct Cat : Animal {
void speak() const override {
std::cout << "Cat\n";
}
};
int main() {
Dog dog;
Cat cat;
// 基类引用分别指向不同派生对象,同一句调用分发到不同实现
const Animal& ra = dog;
const Animal& rb = cat;
ra.speak();
rb.speak();
}
输出:
Dog
Cat
ra 与 rb 的静态类型都是 const Animal&,但运行期它们实际指向 Dog 与 Cat,于是 speak() 分别落到 Dog::speak 与 Cat::speak。这就是“同一接口、多种实现”的运行期含义。
如果把调用改成按值传参 void f(Animal a),则会触发对象切片(见后文“对象切片”一节),动态绑定同样失效,因为 a 的静态类型与动态类型都被收窄为 Animal。
底层实现:虚函数表与虚表指针
动态多态的底层依赖一个编译器生成的查表结构。ISO/IEC 14882 只规定行为、不规定实现。GCC/Clang 遵循 Itanium C++ ABI;MSVC 使用自有对象模型与虚表布局。下文以 Itanium 思路说明(与常见 g++/clang++ 实测一致):
每个声明了虚函数的多态类,编译器会为它生成虚函数表(vtable,virtual table);多重继承下还可有主表与次表多份。表是该类对象共享的只读数据。在 Itanium ABI 中,vptr 指向 address point(第一个虚函数指针处):address point 往前(负偏移)存的是 offset-to-top 与 typeinfo 指针,往后则按声明顺序排虚函数入口。若存在虚析构,通常还占两个槽位(complete object destructor 与 deleting destructor)。
每个多态类(或其派生类)的对象,在构造时会被写入一个虚表指针(vptr,virtual pointer),指向它所属类的虚函数表(或其对应子对象的次表)。vptr 通常位于对象内存的首 8 字节(64 位平台;多重继承下非首基类子对象另有自己的 vptr)。
调用经基类引用发起的虚函数(如 b.value())时,运行期先做如下几步:
- 取指针/引用所指子对象的地址(this)。
- 读该子对象开头的 vptr(单继承或首基类视角下即对象首 8 字节)。
- 经 vptr 找到该类的虚函数表(address point)。
- 取该虚函数对应的槽位,得到具体函数地址。
- 以 this 为参数间接跳转到该地址。
因为第 4 步拿到的地址取决于“对象真实所属类的虚表”,所以同一句调用会落到不同实现。下面的对象布局实证把这个结构摆到台面上。
对象内存的实证如下。空类大小为 1(占位),而一旦加入虚函数,对象便多出 8 字节的 vptr;多重继承下每个含虚函数的基类各贡献一份 vptr,于是 C 的大小达到 16。
#include <iostream>
struct Empty {};
struct WithVtable {
virtual void f() {}
virtual ~WithVtable() = default;
};
struct A { virtual void a() {} virtual ~A() = default; };
struct B { virtual void b() {} virtual ~B() = default; };
// 多重继承:每个含虚函数的基类子对象各持有一份虚表指针
struct C : A, B { void a() override {} void b() override {} };
int main() {
std::cout << "sizeof(Empty) = " << sizeof(Empty) << "\n";
std::cout << "sizeof(WithVtable) = " << sizeof(WithVtable) << "\n";
std::cout << "sizeof(C) = " << sizeof(C) << "\n";
C c;
A* pa = &c;
B* pb = &c;
// 通过两个基类子对象指针观察各自的内存偏移
std::cout << "pa offset = " << (char*)pa - (char*)&c << "\n";
std::cout << "pb offset = " << (char*)pb - (char*)&c << "\n";
}
输出:
sizeof(Empty) = 1
sizeof(WithVtable) = 8
sizeof(C) = 16
pa offset = 0
pb offset = 8
sizeof(WithVtable) 为 8,正是 64 位平台上 vptr 的尺寸;sizeof(C) 为 16,说明 C 内部有两份 vptr(分别服务 A 子对象与 B 子对象);pb 指向的对象地址比 &c 高出 8,正是 B 子对象相对 A 子对象的偏移。
汇编佐证:虚调用是一次间接调用
光看对象布局还不够。下面这个最小例子用于把“运行期查表”从文字变成指令。
#include <cstdio>
struct Base {
virtual int value() const { return 1; }
virtual ~Base() = default;
};
struct Derived : Base {
int v = 42;
int value() const override { return v; }
};
// 经基类引用发起虚函数调用,便于观察运行期间接调用
int call_virtual(const Base& b) {
return b.value();
}
int main() {
Derived d;
std::printf("%d\n", call_virtual(d));
}
输出:
42
用 g++ -std=c++23 -O0 -fno-inline -S -masm=intel 得到 call_virtual 的反汇编如下(节选;寄存器分配因平台略有差异,逻辑相同):
call_virtual(const Base&):
mov [rbp-0x8], rdi ; 保存参数:rdi 入参即 this
mov rdi, [rbp-0x8] ; 恢复 this 到 rdi(System V 第一参数)
mov rax, [rdi] ; rax = *this = vptr(指向 vtable address point)
call [rax] ; 间接调用 vtable[0](此处为 value)
关键在最后一条 call [rax]:目标来自虚函数表,而不是链接期写死的地址。调用前 this 一直留在 rdi,不会被 vptr 覆盖。这正是动态绑定与静态调用(直接 call 函数名)的根本区别:目标地址在运行期才解开。
对照两个 value() 的实现更能看清对象布局:Base::value 直接 mov eax, 0x1 返回常量 1;Derived::value 执行 mov eax, [rax+0x8],即读取 this 偏移 8 处的成员 v。偏移 0 是 vptr,偏移 8 才是 int v,与图1一致;64 位下整对象 sizeof(Derived) 通常为 16(vptr 8 + int 4 + 对齐填充 4)。
多重继承:双虚表与 this 调整
单继承下每个对象只有一份 vptr,问题简单。一旦进入多重继承,情况变复杂:每个含虚函数的基类子对象都要有自己的 vptr,否则就无法通过 B* pb = &c 正确地以 B 视角查表。
于是 C 对象内部出现两份 vptr:一份属于 A 子对象(位于 offset 0),一份属于 B 子对象(位于 offset 8,见前文输出)。它们各自指向不同的虚函数表。
麻烦点在“this 指针调整”。当代码写 B* pb = &c; pb->b(); 时,pb 指向的是 offset 8 处的 B 子对象,此时 this 已是 &c+8。最终落到 C::b 时,函数体期望的是完整对象指针 &c,因此次虚函数表里通常放一个 thunk(一小段桩代码):先把 this 从 &c+8 调回 &c,再跳转到真正的函数体。
thunk 的存在解释了两个实践现象:多重继承对象比单继承更大(多一份 vptr),且经由非首基类进行的虚调用多一次指针修正。需要菱形继承时,还应考虑虚继承(virtual inheritance)以消除重复的基类子对象,但那已超出本文范围。
虚析构函数:为什么基类析构要 virtual
如果一个类可能被通过基类指针删除派生对象,其析构函数必须声明为 virtual。否则经基类指针 delete 派生对象属于未定义行为;常见实现里可能只调用基类析构、派生部分未正确释放,但这不是可依赖的行为。
下面的例子经基类指针删除派生对象,虚析构函数保证析构从最派生类一路回退到基类:
#include <iostream>
struct Animal {
virtual void speak() const { std::cout << "Animal\n"; }
// 基类析构函数声明为虚函数
virtual ~Animal() { std::cout << "Animal dtor\n"; }
};
struct Dog : Animal {
void speak() const override { std::cout << "Dog\n"; }
~Dog() override { std::cout << "Dog dtor\n"; }
};
int main() {
// 经基类指针持有并删除派生对象
Animal* p = new Dog();
delete p; // 先 Dog dtor,再 Animal dtor:虚析构保证链完整
}
输出:
Dog dtor
Animal dtor
实践上:只要类可能被经基类指针多态删除,就声明 virtual 析构(或采用 protected 非虚析构并禁止经基类删除);更省心的写法是 virtual ~Animal() = default;(C++11 起)。
对象切片:多态失效的典型陷阱
动态多态只通过指针或引用生效。一旦把派生类对象按值赋给基类对象(或按值传参、按值返回),派生类多出来的部分会被“切掉”,只剩基类子对象,这就是对象切片(object slicing)。
切片后的对象静态类型与动态类型都变成了基类,虚调用自然落到基类版本,多态消失。
#include <iostream>
struct Animal {
int id = 0;
virtual void speak() const { std::cout << "Animal(" << id << ")\n"; }
virtual ~Animal() = default;
};
struct Dog : Animal {
void speak() const override { std::cout << "Dog(" << id << ")\n"; }
};
// 按值传参:参数类型是基类,派生部分被切掉
void by_value(Animal a) { a.speak(); }
// 按引用传参:保留完整对象与多态
void by_ref(const Animal& a) { a.speak(); }
int main() {
Dog dog;
dog.id = 7;
by_value(dog); // 切片:只剩 Animal 子对象,输出 Animal(7)
by_ref(dog); // 多态:输出 Dog(7)
}
输出:
Animal(7)
Dog(7)
规避切片的办法是始终用(智能)指针或引用持有多态对象,避免按值传递或按值返回可能进入继承体系的对象。
协变返回类型
重写虚函数时,满足下面三个条件就允许协变返回类型(covariant return type):返回类型是指针或引用;新返回类型指向原返回类型所指类的无歧义可访问派生类;派生侧的 cv 限定不多于基类侧(equally or less cv-qualified)。好处是调用方能直接拿到更具体的类型,省去一次向下转型。
struct Base { virtual ~Base() = default; };
struct Derived : Base {};
struct Factory {
// 协变返回类型:重写时返回类型可从 Base* 收窄为 Derived*
virtual Base* create() { return new Base(); }
virtual ~Factory() = default;
};
struct DerivedFactory : Factory {
Derived* create() override { return new Derived(); }
};
int main() {
DerivedFactory f;
// 调用方直接得到更具体的派生类型指针,无需向下转型
Derived* d = f.create();
delete d;
}
协变返回要求返回类型都是指针或引用(不能是值类型),且派生关系是沿着同一继承链。它不改变运行期分发机制,只是放宽了重写时返回类型的约束。
RTTI:typeid 与 dynamic_cast
运行期类型识别(RTTI,Run-Time Type Information)在主流实现里借助虚表 address point 之前的 typeinfo 指针。typeid 对多态对象取的是动态类型,dynamic_cast 则用于在继承体系中安全向下转型。
#include <iostream>
#include <typeinfo>
struct Base { virtual ~Base() = default; };
struct Derived : Base {};
int main() {
Derived d;
Base* p = &d;
// typeid 经虚表取得动态类型,而非静态类型 Base
std::cout << typeid(*p).name() << "\n";
// dynamic_cast 在继承体系中安全向下转型
if (Derived* dp = dynamic_cast<Derived*>(p)) {
std::cout << "downcast ok\n";
}
}
输出:
7Derived
downcast ok
typeid(*p).name() 打印的是被实现修饰过的名字(GCC 下 “7Derived” 表示长度 7 的 “Derived”),并非源码里的名字;要得到可读名需用 abi::__cxa_demangle 或编译器工具。注意 RTTI 依赖虚表 address point 之前的 typeinfo:对非多态类型(没有虚函数)使用 typeid 只能得到静态类型;dynamic_cast 对指针转型失败时返回 nullptr,对引用转型失败时抛出 std::bad_cast。
若用 -fno-rtti 编译,对多态对象的 typeid 与 dynamic_cast 将不可用(静态 typeid(T) 视实现仍可能可用),这对一些追求极小体积或嵌入式场景是常见取舍。
多态选型:开放集与闭集
虚函数不是运行期多态的唯一手段。动手前先想清楚三件事:类型集合编译期能不能列完,要不要值语义,要不要给第三方扩展。
| 手段 | 类型集 | 语义 | 典型场景 |
|---|---|---|---|
| virtual + 基类指针/引用 | 开放 | 引用/指针 | 插件、框架回调、GUI 控件树 |
std::variant + visit | 闭集 | 值 | 热路径、已知有限几种替代类型 |
类型擦除(如 std::function) | 开放 | 值(常含 SBO) | 回调、异构对象无公共基类 |
std::polymorphic(C++26) | 开放派生 | 值(深拷贝保动态类型) | 可拷贝的多态成员或容器 |
闭集、又在乎热点路径,优先 variant 或静态多态;必须开放扩展,再用虚函数或类型擦除。选型定了再抠虚调用开销,顺序别反。
std::variant 例子(类型集编译期封闭):
#include <iostream>
#include <string>
#include <variant>
using Msg = std::variant<int, std::string>;
void print(const Msg& m) {
std::visit([](const auto& v) { std::cout << v << "\n"; }, m);
}
int main() {
print(42);
print(std::string{"hi"});
}
输出:
42
hi
std::polymorphic(C++26)
std::unique_ptr<Base> 能避免切片,但是指针语义:不能按值拷出“原来的派生类型”,拷贝往往要自己写虚 clone()。C++26 的 std::polymorphic<T> 把“T 或其公有派生”做成值包装,堆上持有对象,拷贝按动态类型深拷,拷出来还是原来的派生类。
- 可持有
T,或公有且无歧义派生自T的类型; - 能放进
polymorphic的类型须可拷贝构造(标准对构造函数的约束),拷贝后动态类型不变,多数场景不必再写虚clone(); - 移后可能处于 valueless,用
valueless_after_move()判断;要可空就包一层std::optional。
因此 vector<polymorphic<Shape>> 比 vector<unique_ptr<Shape>> 更适合“整份按值拷贝”的组合对象。
接口示意(需 C++26 标准库,不纳入本文的 c++23 实测清单):
#include <memory>
#include <vector>
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};
struct Circle : Shape {
double r = 1.0;
explicit Circle(double radius) : r(radius) {}
double area() const override { return 3.14159 * r * r; }
};
struct Picture {
std::vector<std::polymorphic<Shape>> shapes;
};
int main() {
Picture p;
p.shapes.emplace_back(std::in_place_type<Circle>, 2.0);
Picture q = p; // q 里仍是 Circle,不会切成裸 Shape
(void)q.shapes.front()->area();
}
说明:若只通过 polymorphic 管理生命周期,销毁走的是擦除后的实际类型析构;例中仍给 Shape 虚析构,是为了接口完整、并避免有人再拿 Shape* 去 delete。
类型擦除导读
类型擦除(type erasure)对外只留统一接口,具体类型藏在内部,被擦的类型不必共有一个基类。标准库里常见的是 std::function(擦可调用对象)和 std::any(擦可拷贝的任意类型)。
实现上多半是内部 Concept/Model 或函数指针表:构造时把具体操作绑到间接入口。所以擦除照样是间接调用,小对象通常靠 SBO(small buffer optimization,小缓冲优化)省掉堆分配,并不是“写了擦除就零开销”。
和虚函数比:虚函数要先有继承树;擦除可以对现成类型包一层,更偏值语义。开放扩展又不想强迫用户继承时,擦除往往比硬加基类合适。
#include <functional>
#include <iostream>
#include <string>
int main() {
// 函数指针、lambda、函数对象都能塞进同一个 function,无需公共基类
std::function<void(const std::string&)> sink = [](const std::string& s) {
std::cout << s << "\n";
};
sink("erased");
sink = [](const std::string& s) { std::cout << "[" << s << "]\n"; };
sink("call");
}
输出:
erased
[call]
CRTP 与 deducing this
CRTP(奇异递归模板模式)把派生类写进基类模板参数,用编译期静态分发:接口在基类,实现在派生类,通常能内联。
#include <iostream>
template <class D>
struct Writer {
void write(const char* s) {
static_cast<D*>(this)->write_impl(s); // 编译期落到派生类,无虚表
}
};
struct Console : Writer<Console> {
void write_impl(const char* s) { std::cout << s << "\n"; }
};
int main() {
Console c;
c.write("crtp");
}
输出:
crtp
限制也很干脆:Writer 是模板,没有统一的 Writer*,装不了“一堆未知派生”的开放集合。那种场景还得虚函数、variant 或类型擦除。
C++23 的显式对象参数(常叫 deducing this)能少写一层 CRTP 样板。下面是单类型上的静态分发,不是完整 CRTP 替代品:
#include <iostream>
struct Console {
void write_impl(const char* s) { std::cout << s << "\n"; }
template <class Self>
void info(this Self& self, const char* msg) {
self.write_impl(msg); // 调用 c.info(...) 时 Self 为 Console
}
};
int main() {
Console c;
c.info("deducing this");
}
输出:
deducing this
样板可以少写,但还是得不到“一个非模板基类指针,装运行期才出现的派生”。闭集走静态手段,开放集走虚函数或擦除。
注意事项(避坑清单)
- 基类析构函数通常要 virtual:只要类可能被经基类指针多态删除,就声明 virtual 析构,否则行为未定义(亦可用 protected 非虚析构禁止经基类删除)。
- 构造函数不能是 virtual:标准规定构造函数不得声明为 virtual([class.ctor]);构造需要已知的完整类型,虚分发无意义。需要“按动态类型构造”时改用工厂或原型(clone)模式。
- 构造/析构函数内调用虚函数不下降:在基类构造函数体里调用虚函数,只会调用当前正在构造的类(基类)的版本,不会下降到派生类;析构函数同理。这是标准明文规定的行为,不是编译器缺陷。
- 避免按值持有多态对象:按值传参/返回会触发对象切片,多态随之失效,改用(智能)指针或引用。
- 建议写 override / final:override 让编译器检查签名是否真重写了基类虚函数,final 阻止继续重写;二者都能尽早暴露误写(标准不强制写 override)。
- 多重继承带来 thunk 与更大对象:每多一个含虚函数的基类就多一份 vptr,经非首基类调用虚函数会多一次 this 调整。
- 纯虚析构函数仍需定义体:声明 virtual ~T() = 0; 时仍要提供函数体,否则链接期报错;抽象类的析构同样需要可调用。
- 虚函数有成本:间接调用和丢内联之外,异构指针数组的缓存与堆分配往往更伤。闭集热点优先
variant或静态多态;开放扩展再用虚函数或类型擦除。 - 虚函数表通常置于只读数据段:vtable 是类级只读数据,内容不可写;试图篡改虚表属于未定义行为且通常被操作系统禁止。
- 开放/闭集决定手段:开放用虚函数或擦除;闭集用
variant/模板/CRTP;要可拷贝的多态值语义再看 C++26 的std::polymorphic。
附录:高频面试题
【问题一】静态多态和动态多态有什么区别,分别有哪些手段?
【解题思路】围绕“绑定时机”与“实现机制”两点展开,说明各自适用边界。
【参考答案】静态多态在编译期决议,手段包括函数重载、运算符重载与模板(参数化多态),零运行期开销但要求类型编译期可知。动态多态在运行期决议,手段是虚函数配合继承与基类指针/引用,靠虚函数表与虚表指针做间接调用;事后好加派生类型,代价是间接调用与每对象一份(多重继承下可多份)vptr。用哪种看调用点类型是否在编译期确定。
【问题二】虚函数是怎么实现运行时多态的,描述一次调用的全过程?
【解题思路】讲清 vtable 与 vptr 的分工,并给出“取子对象→取 vptr→查表→取地址→间接调用”五步。
【参考答案】每个多态类由编译器生成虚函数表(vtable),存放该类各虚函数地址,是类级只读数据(多重继承下可有主表与次表);每个对象在构造时被写入虚表指针(vptr)指向所属类的虚表。调用经基类指针/引用发起时:先取所指子对象地址 this,再读该子对象开头的 vptr,经 vptr 找到虚表 address point,取对应槽位得到函数地址,最后以 this 为参数间接跳转。因地址来自对象真实所属类的虚表,同一调用在不同对象上落到不同实现。本文 call_virtual 的反汇编 call [rax] 即为这一间接调用的实证。
【问题三】为什么基类析构函数通常要声明为 virtual?
【解题思路】点明“经基类指针删除派生对象”这一前提,以及不 virtual 的后果。
【参考答案】若经基类指针删除派生对象,而基类析构非 virtual,则行为未定义(常见表现是只调用基类析构、派生部分未正确释放)。声明为 virtual 后,delete 从最派生类析构一路回退到基类,资源正确释放。只要类有被经基类指针多态删除的可能,就给析构加 virtual(或 protected 非虚析构),最省心的写法是 virtual ~T() = default;。
【问题四】构造函数可以声明为 virtual 吗,为什么?
【解题思路】直接回答“不能”,并与“构造期内虚调用不下降”区分开。
【参考答案】构造函数不能声明为 virtual,这是标准硬性规定。构造必须按已知的完整类型分配并初始化子对象,虚分发没有可依赖的“已完成对象”作为动态类型来源。需要按运行期类型创建对象时,改用工厂函数或原型模式(如 virtual clone)。注意:构造/析构期间仍可发生虚调用,但只落到当前正在构造/析构的类(见下一题),这与“不能声明虚构造函数”是两件不同的事。
【问题五】在构造函数或析构函数内部调用虚函数,会调用派生类重写的版本吗?
【解题思路】这是高频追问,答案是“不会”,强调标准规定调用当前正在构造/析构类的版本。
【参考答案】不会。在基类构造函数体里调用虚函数,只调用当前正在构造的类(即基类)自己的版本,不会下降到尚未构造完成的派生类;析构函数同理,派生部分已先被销毁。标准明文如此规定,目的是保证构造/析构期间对象处于一致状态。因此不要在构造或析构函数里依赖虚调用的“多态”。
【问题六】什么是纯虚函数和抽象类,有什么约束?
【解题思路】说明 = 0 语法、不能实例化、派生类必须重写,以及纯虚析构仍需定义体。
【参考答案】在函数声明后写 = 0 即纯虚函数(pure virtual),含纯虚函数的类成为抽象类(abstract class),不能被直接实例化,只能作基类,派生类必须重写所有纯虚函数才能实例化。注意纯虚析构函数虽可声明为 = 0,仍必须提供函数体,否则链接失败;抽象类的析构同样需要可调用。
【问题七】override 与 final 关键字有什么作用,为什么建议写?
【解题思路】分别说明二者作用:override 强制检查重写,final 阻止重写。
【参考答案】override 显式标记该函数意在重写基类虚函数,编译器会校验签名是否真的匹配某个基类虚函数,拼写或参数写错会立即报错,避免“本想重写却变成重载/新增”的静默 bug。final 用于类或虚函数,表示该虚函数(或该类)不再允许被进一步重写/派生,既表达设计意图也便于编译器去虚化优化。C++11 起建议对每一个重写都加 override。
【问题八】多重继承下虚函数调用为什么需要 this 指针调整(thunk)?
【解题思路】讲清多份 vptr、非首基类子对象偏移,以及调整 this 的动机。
【参考答案】多重继承下每个含虚函数的基类子对象各有一份 vptr,非首基类子对象相对对象起点有偏移(本文实证 B 子对象在 offset 8)。当通过 B* pb = &c 调用并落到 C::b 时,this 已是 &c+8,而 C::b 期望完整对象指针 &c,因此次虚表里通常放一个 thunk:先把 this 从 &c+8 调回 &c,再进入真正的函数体。它同时解释了多重继承对象更大、且经非首基类调用多一次指针修正的现象。
【问题九】什么是对象切片,如何避免?
【解题思路】说明按值传递/赋值时派生部分被丢弃、多态失效,给出规避方式。
【参考答案】把派生类对象按值赋给基类对象、或按值传参/按值返回时,只有基类子对象被保留,派生部分被切掉,结果是静态与动态类型都变成基类,虚调用落到基类版本,多态消失。避免办法是始终用指针或引用(优先智能指针与 const 引用)持有可能进入继承体系的对象,绝不对多态对象按值传递或返回。
【问题十】dynamic_cast 转换失败时返回什么,RTTI 有什么开销与禁用场景?
【解题思路】区分指针与引用两种失败表现,说明 RTTI 依赖虚表 typeinfo 以及 -fno-rtti。
【参考答案】dynamic_cast 对指针转型失败时返回 nullptr,对引用转型失败时抛出 std::bad_cast,这是它与 static_cast(失败产生未定义行为或编译错误)的关键区别。RTTI 在主流实现里借助虚表 address point 之前的 typeinfo 指针,因此只对多态类型在运行期安全;非多态类型只能用 typeid 取静态类型。RTTI 会占用虚表空间并带来少量运行期开销,追求极小体积或受控环境常用 -fno-rtti 关闭,关闭后 typeid(对多态对象)与 dynamic_cast 均不可用。
【问题十一】开放集与闭集下,如何在虚函数、std::variant、类型擦除之间选择?
【解题思路】抓住“类型集是否封闭”和“要不要值语义/第三方扩展”两条轴。
【参考答案】类型集编译期能列完、又偏值语义和热点路径,用 std::variant + visit(或模板/CRTP)。集合要对第三方开放、用继承建模,用虚函数。开放扩展又不想强迫继承,用类型擦除(如 std::function)。先把场景归类,再争论虚调用快不快。
【问题十二】std::polymorphic 解决什么问题,和 unique_ptr<Base> 有何不同?
【解题思路】从切片、clone、值语义三方面对比。
【参考答案】unique_ptr<Base> 是指针语义:能避免切片,但拷贝不会自动带上动态类型,通常要虚 clone()。C++26 的 std::polymorphic<T> 是值包装:可拥有 T 或其公有派生,拷贝按动态类型深拷,多数时候不用手写 clone()。可空就 optional<polymorphic<T>>。它补的是“可拷贝的多态组合”,不是取代所有虚函数。
【问题十三】CRTP(或 deducing this)能否完全替代虚函数?为什么?
【解题思路】区分编译期闭集与运行期开放集。
【参考答案】不能。CRTP / deducing this 吃编译期已知类型,能内联,但没有统一的非模板基类指针,撑不起“运行期才出现、还能被第三方扩展”的集合。开放集靠虚函数或类型擦除;闭集才适合静态手段。互补,不是互相取代。
参考资料
- cppreference 相关页面(en.cppreference.com/w/cpp/language/):virtual、abstract class、override、final、object slicing、operators(运算符重载)、function template(函数模板)。
- cppreference 相关页面(en.cppreference.com/w/cpp/types/):type_info、bad_cast,以及 dynamic_cast、static_cast、reinterpret_cast 的对比说明。
- cppreference 标准库:
std::variant、std::visit、std::function、std::any、std::polymorphic(C++26)。 - ISO/IEC 14882 标准条款:[class.virtual]、[class.abstract]、[class.dtor]、[class.ctor]、[class.cdtor]、[class.copy]、[class.derived]、[expr.call]、[expr.dynamic.cast]、[expr.typeid]、[over.load]、[over.match.oper]、[temp.fct]。
- P3019(indirect and polymorphic:值语义间接与多态包装,C++26)。
- Itanium C++ ABI(虚函数表布局、vtable、thunk、RTTI 结构的事实标准):github.com/itanium-cxx-abi/cxx-abi,其“Virtual Table Layout”与“RTTI”两节描述 vtable 槽位排列与 this 调整桩函数的生成规则。
- 《C++ Primer》(Stanley B. Lippman 等著)第 15 章:面向对象程序设计,涵盖虚函数、抽象基类、动态绑定与 RTTI。
- 《Effective C++》(Scott Meyers 著)条款 07(为多态基类声明 virtual 析构函数)、条款 33(避免遮掩继承来的名称)与条款 34(区分接口继承与实现继承)。
- Klaus Iglberger《C++ Software Design》:External Polymorphism 与 Type Erasure(类型擦除设计模式)。