目录

多态是面向对象程序设计的三大支柱之一,也是 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())时,运行期先做如下几步:

  1. 取指针/引用所指子对象的地址(this)。
  2. 读该子对象开头的 vptr(单继承或首基类视角下即对象首 8 字节)。
  3. 经 vptr 找到该类的虚函数表(address point)。
  4. 取该虚函数对应的槽位,得到具体函数地址。
  5. 以 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 吃编译期已知类型,能内联,但没有统一的非模板基类指针,撑不起“运行期才出现、还能被第三方扩展”的集合。开放集靠虚函数或类型擦除;闭集才适合静态手段。互补,不是互相取代。

参考资料

  1. cppreference 相关页面(en.cppreference.com/w/cpp/language/):virtual、abstract class、override、final、object slicing、operators(运算符重载)、function template(函数模板)。
  2. cppreference 相关页面(en.cppreference.com/w/cpp/types/):type_info、bad_cast,以及 dynamic_cast、static_cast、reinterpret_cast 的对比说明。
  3. cppreference 标准库:std::variant、std::visit、std::function、std::any、std::polymorphic(C++26)。
  4. 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]。
  5. P3019(indirect and polymorphic:值语义间接与多态包装,C++26)。
  6. Itanium C++ ABI(虚函数表布局、vtable、thunk、RTTI 结构的事实标准):github.com/itanium-cxx-abi/cxx-abi,其“Virtual Table Layout”与“RTTI”两节描述 vtable 槽位排列与 this 调整桩函数的生成规则。
  7. 《C++ Primer》(Stanley B. Lippman 等著)第 15 章:面向对象程序设计,涵盖虚函数、抽象基类、动态绑定与 RTTI。
  8. 《Effective C++》(Scott Meyers 著)条款 07(为多态基类声明 virtual 析构函数)、条款 33(避免遮掩继承来的名称)与条款 34(区分接口继承与实现继承)。
  9. Klaus Iglberger《C++ Software Design》:External Polymorphism 与 Type Erasure(类型擦除设计模式)。