在你学了类、对象、继承和封装之后,有一个问题始终悬在头顶:同样的代码、同样的函数调用,能不能做到"不同的对象交出不同的表现"?答案是能,这就是 C++ 里最迷人的概念之一——多态(polymorphism)。

先给一个要记住的总纲:多态就是在继承关系的下,用基类的指针或引用来调用同一个函数,却能产生不同的行为。这句话看起来平平无奇,但背后藏着一整套涉及虚函数、虚函数表、虚表指针、动态绑定的底层机制。这篇文章,我会从"为什么需要多态"讲起,一路讲到底层虚表是怎样在运行时决定"到底该调哪个函数",最后用对象内存模型和几个关键字把这些知识串起来。你只需要有基本的继承知识——如果你对"继承后的对象在内存里是怎么布局的"印象不深,我会在讲虚表指针时顺带把这块补上。

先把整篇文章的地图给出来,你心里有个数:多态为什么存在 → 构成多态的两个充要条件 → 虚函数与重写的三要素 → 一个必考的"默认参数陷阱"选择题 → 重写 vs 隐藏 vs 重载三概念辨析 → 析构函数为什么常要 virtual、构造函数为什么不能是 virtual → 虚表 vtable 与虚表指针 vfptr 的底层身份 → 多态(动态绑定)的运作原理 → 静态绑定 vs 动态绑定 → 纯虚函数与抽象类 → 对象模型与内存布局 → final/override 两个编译期护身符。

为什么需要多态

先抛一个最直观的问题:假如你写了一个售票系统,人、学生、军人三个角色都要买票。没有多态之前,你大概率这样写:

#include <iostream>
using namespace std;
 
// 普通人的购票行为
class Person {
public:
    void BuyTicket() { cout << "买票-全价" << endl; }
};
 
// 学生:因为买票逻辑不一样,只能另起一个方法
class Student {
public:
    void BuyTicket() { cout << "买票-打折" << endl; }
};
 
// 军人:还是不一样,又得写一个
class Soldier {
public:
    void BuyTicket() { cout << "买票-优先" << endl; }
};
 
int main()
{
    Person ps;
    Student st;
    Soldier sr;
 
    ps.BuyTicket();  // 买票-全价
    st.BuyTicket();  // 买票-打折
    sr.BuyTicket();  // 买票-优先
    return 0;
}

这段代码功能是实现了,但你发现了问题没有?调用方必须事先知道对象的精确类型。如果你写一个通用的"购票"函数,想同时处理这三种对象,你没法传——Person、Student、Soldier 是三个互不相关的类型,BuyTicket 也是三份几乎重复的代码。这就是典型的"没有多态"的尴尬:代码重复、难以扩展,加一种新角色(比如退伍军人)就要到处改。

多态要解决的核心痛点就一句话:让"同一个形参、同一个函数调用",在不同的对象身上做不同的事。它把"做什么"和"谁来做"解耦了。

这里值得用一个生活类比帮助理解。想想一个真正的售票窗口:窗口的售票员(调用方)不看你的身份证件,只看你递进来的"购票凭证"(对象地址)。学生递学生证,就给你打学生折;军人递军人证,就给你优先窗口。售票员根本不需要在系统里维护一份"全世界所有角色的清单"——他只要遵守"凭证上写着"你是哪种人就怎么收钱即可。多态就是这个售票员的约定:调用方只需要知道"你是个 Person(广义身份)",至于具体是谁,由你(对象)自己说了算。这就是解耦。

而多态的威力在"扩展"上体现得淋漓尽致——这对应软件设计里著名的开闭原则(OCP,Open/Closed Principle):对扩展开放,对修改关闭。你写了一个通用购票函数,想增加一种新角色,不需要改动那个函数,只需要加一个新类、继承 Person、重写 BuyTicket。老代码一行不用动,新角色自动就"接入"了系统。这在面向对象设计里是价值千金的特性。我们写成代码看一眼:

#include <iostream>
using namespace std;
 
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; }
};
 
// 通用购票函数:只认识 Person*,不认识任何具体派生类
void Func(Person* ptr)
{
    ptr->BuyTicket();
}
 
int main()
{
    Person  ps;
    Student st;
    Soldier sr;
    // 平时单个调用
    Func(&ps);   // 买票-全价
    Func(&st);   // 买票-打折
    Func(&sr);   // 买票-优先
 
    // 更显威力:一锅端。
    // Func 完全不认识 Student/Soldier,却能把它们统一管理。
    Person* arr[] = { &ps, &st, &sr };
    for (int i = 0; i < 3; ++i)
        Func(arr[i]);   // 全价 / 打折 / 优先
 
    return 0;
}

注意那个 Person* arr[] 数组:它里面存的是三个不同类型对象的地址,可它们全都"挤"进了一个 Person* 数组。这在没有多态的设计里是做不到的——你退回到 Person、Student、Soldier 三个互不相干的类型时,根本无法放在同一个容器里统一遍历。多态让我们第一次有了"用基类指针批量管理一群派生类对象"的能力,这是 STL 容器、图形系统、游戏引擎里无穷无尽地用到的手段。

我们再看看多态为什么要分"编译时"和"运行时"两种。C++ 有编译时多态(静态多态)和运行时多态(动态多态)。编译时多态主要是函数重载和函数模板——它们传不同类型的实参就能调用不同的函数,参数匹配在编译期就完成了,所以叫"静态";而运行时多态是今天的主角,它指"调用同一个函数(虚函数),传不同的对象进去就完成不同的行为",函数的地址要到运行期才确定,所以叫"动态"。

一句话收尾这一节:编译时多态靠"参数"区分,运行时多态靠"对象"区分。前者在编译期就把一切敲定,零额外开销;后者在运行期查表决定,带一点性能代价,但换来了巨大的灵活性与扩展性。

多态的构成条件

所谓运行时多态,用最直白的场景来描述:同样是"动物叫"这个行为,传一个猫对象进去,输出"(>^ω^<)喵";传一个狗对象进去,输出"汪汪"。

要实现这种效果,不是随便写就行的,它有两个必须同时满足的硬性条件:

  • 必须是基类的指针或引用来调用虚函数。为什么?因为只有基类的指针或引用才能既指向基类对象,又指向派生类对象(这是"指针/引用天然支持向上转型"的特性)。如果你用对象本身调用,那就退化成普通的成员调用,谈不上多态。
  • 被调用的函数必须是虚函数,并且派生类完成了对它的重写(覆盖)。只有完成重写,基类版本和派生类版本才是两份不同的函数体,多态的"不同形态"才有东西可切换。

所以"多态的充要条件"说得再精炼一点就是:基类指针/引用 + 虚函数 + 派生类重写了该虚函数,三者由"派生类对象"这个实际目标把它们串起来。缺一个,多态就不成立。

你可以把一个会触发多态的对象,想象成一个"长了张基类脸"但"内里是派生类"的对象。想想看,如果你拿到一个 Person 的指针,编译器知道它最多只可能是 Person 能映射的范畴,但它在运行时可能真正指向一个 Student——这就是多态能成立的土壤。

为什么必须是"指针或引用",不能是"对象本身"?

这是初学者最常卡住的一处。答案是三个词:切片(slicing)。当你把一个派生类对象按值赋给一个基类对象时,发生的是"对象拷贝"——派生类对象里"多出来的那部分成员"会被丢弃,只把"基类子对象那一块"拷贝过去。这个过程叫做切片:像切蛋糕一样,把多余的部分切掉了。

这种切掉是灾难性的:你以为拷过去的是一个 Derived,实际上留下的只是一个空壳 Base。切片导致的直接后果就是——通过基类对象(值)调用虚函数,永远只能调到基类版本,多态彻底失效。因为虚表指针也和成员一起被"切"成了基类的那个。

#include <iostream>
using namespace std;
 
class Base {
public:
    virtual void f() { cout << "Base::f" << endl; }   // 虚函数
    int a = 1;
};
 
class Derived : public Base {
public:
    virtual void f() { cout << "Derived::f" << endl; } // 重写
    int b = 2;                                         // 派生独有成员
};
 
void byValue(Base obj)  { obj.f(); }   // 按值传参:发生切片
void byRef(Base& obj)   { obj.f(); }   // 按引用:不切片,多态生效
void byPtr(Base* obj)   { obj->f(); }  // 按指针:不切片,多态生效
 
int main()
{
    Derived d;
    byValue(d);   // 切片 -> Base 版本:输出 Base::f
    byRef(d);     // 虚表指针没动 -> Derived 版本:输出 Derived::f
    byPtr(&d);    // 同上 -> 输出 Derived::f
 
    Derived copy_Derived = d;      // 同类型拷贝,不切片
    Derived cut = d;               // (示意)这里没有 base 子对象专属拷贝问题
 
    // 64 位程序里:Base 有 vfptr(8) + int a(4) + 填充(4)=16
    //              Derived 再叠 int b(4) + 填充(4)=24
    // 注意:sizeof 在不同位数的程序下不同,见下文"虚表指针"一节。
    cout << sizeof(Base) << " " << sizeof(Derived) << endl;
 
    return 0;
}

(上面 byValue 那行的关键:函数形参是 Base obj,传 d 进去时,编译器会做一次"派生类对象 → 基类对象"的拷贝,把 b 和三态的表针一起处理——实际切掉的就是属于 Derived 的一切,vfptr 也换成了 Base 的。于是 obj.f() 静态解析到 Base::f。)

顺带提醒:正因为按值拷贝有切片陷阱,C++ 社区有一条铁律——面向对象设计里,对象要"引用/指针传递"而不是"值传递"。值传递不仅白白多一次拷贝开销,还可能把多态静悄悄破坏掉。凡是看到"以基类为形参对象"的函数,多半是设计错了,应当改成基类指针或引用。

虚函数与重写(override):virtual 与三要素

虚函数很简单:在类的成员函数前面加上 virtual 关键字,它就成了虚函数。注意一个细节:只有成员函数能加 virtual,普通(非成员)函数不能加。

为什么普通函数不能加 virtual?因为 virtual 的语义是"让调用在运行时按对象的动态类型去查表",而"动态类型"这个概念只存在于类的对象里——一个裸的全局函数 void f() 不属于任何对象、没有任何"形态"可言,自然无从谈起多态。编译器的角度更干脆:虚函数的地址要进虚表,虚表是挂在类上的,非成员函数不属于任何类,放不进任何虚表。所以这个限制是一种逻辑上的必然,不是设计者的任性。

#include <iostream>
using namespace std;
 
// 基类:把 BuyTicket 声明为虚函数
class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
 
// 派生类:重写了基类的虚函数(重写又称覆盖)
class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
 
// 一个"看起来人畜无害"的通用函数:传基类指针
void Func(Person* ptr)
{
    // 注意:这里调用 BuyTicket 的结果,
    // 不取决于形参 ptr 的类型,而取决于 ptr 实际指向的对象
    ptr->BuyTicket();
}
 
int main()
{
    Person ps;
    Student st;
 
    Func(&ps);  // ptr 指向 Person  对象 -> 买票-全价
    Func(&st);  // ptr 指向 Student 对象 -> 买票-打折
    return 0;
}

这段代码的输出是:

买票-全价
买票-打折

把眼睛盯住 Func 函数里的那行 ptr->BuyTicket()。明明是同一个 Person* 指针在调用同一个 BuyTicket,结果却取决于 ptr 实际指向的是谁。这就是多态最经典的演示。

关于虚函数的重写/覆盖,有一个严格定义:派生类中有一个与基类返回值类型、函数名、参数列表完全相同的虚函数,就称派生类的虚函数重写(覆盖)了基类的虚函数。三要素缺一不可。

再强调一个考试和面试都爱埋的坑:派生类重写基类虚函数时,即便不写 virtual,也能构成重写。为什么?因为继承后基类的虚函数属性被"带下来"了,派生类里同名同参的版本自动继承虚函数属性。不过这种写法不规范,强烈建议在派生类里也明明白白地写上 virtual(或者用后面要讲的 override),让别人一眼看出"这里是一个虚函数重写"。

#include <iostream>
using namespace std;
 
class Animal {
public:
    virtual void talk() const {}   // 基类虚函数,空实现
};
 
class Dog : public Animal {
public:
    virtual void talk() const      // 显式加 virtual,规范写法
    {
        cout << "汪汪" << endl;
    }
};
 
class Cat : public Animal {
public:
    virtual void talk() const      // 显式加 virtual,规范写法
    {
        cout << "(>^ω^<)喵" << endl;
    }
};
 
// 用基类引用接收,同样构成多态(指针、引用都行)
void letsHear(const Animal& animal)
{
    animal.talk();
}
 
int main()
{
    Cat cat;
    Dog dog;
    letsHear(cat);  // 传猫进来 -> (^ω^)喵
    letsHear(dog);  // 传狗进来 -> 汪汪
    return 0;
}

这里我用的是基类的引用(const Animal&),再次印证了规则:指针和引用都行。你可以把这两种写法当成多态的两种"马甲",底层的动作完全一致。

再做一个对照组实验,把 virtual 去掉看看会发生什么——这是真正理解"虚函数的价值"的捷径:一个虚函数、一个非虚函数,放在同一个类里,通过基类指针调用,结果天差地别。

#include <iostream>
using namespace std;
 
class Animal {
public:
    void speak() { cout << "Animal::speak" << endl; }      // 非虚
    virtual void cry() { cout << "Animal::cry" << endl; }  // 虚
};
 
class Dog : public Animal {
public:
    void speak() { cout << "Dog::speak" << endl; }         // 只是"隐藏"基类的 speak
    virtual void cry() { cout << "Dog::cry" << endl; }     // 才是"重写"基类的 cry
};
 
void call(Animal* a)
{
    a->speak();   // 非虚 -> 编译期按静态类型 Animal 定死 -> 永远 animal 版本
    a->cry();     // 虚   -> 运行期按动态类型查虚表 -> 可能是 Dog 版本
}
 
int main()
{
    Dog dog;
    Animal* a = &dog;
 
    a->speak();   // Animal::speak   (非虚,静态绑定,与派生类无关)
    a->cry();     // Dog::cry        (虚,多态生效)
 
    Dog* dp = &dog;
    dp->cry();    // Dog::cry        (即使派生类指针直调,虚也照常)
    dp->speak();  // Dog::speak      (用派生类指针才"看见"隐藏的那个 Dog 版本)
 
    call(&dog);   // Animal::speak / Dog::cry
    return 0;
}

这个对照太重要了,值得逐行讲透:

  • a->speak():speak 不是虚函数,a 是 Animal*,编译器在编译期就静态解析到 Animal::speak——哪怕 a 实际指向 Dog,打印的也是 Animal::speak。非虚函数 + 基类指调 = 无多态。
  • a->cry():cry 是虚函数且被 Dog 重写,运行时查虚表,打印 Dog::cry。虚函数 + 基类指针 + 重写 = 多态。
  • dp->speak():用派生类指针 dp 去调,这里反而调到了 Dog::speak。为什么?因为 speak 是隐藏(名字层面),派生类指针能直接"看见"派生类里那个同名版本;而 a->speak() 能调到 Animal 版,是静态类型决定的。这正是后面要讲的"隐藏与重写"的分界线雏形。

为什么虚函数是三要素"完全相同"才算重写?

因为运行时包装要做"替换":派生类虚表里,某个槽位 (返回值, 函数名, 参数) 若与基类虚表对应槽位完全匹配,派生类就会把该槽位的函数地址换成自己的版本(这就是后面"对象模型"一节讲的"覆盖")。如果参数列表不同,槽位在虚表里的"编号"就对不上,两者在各虚表里是不同的槽位,自然谈不上覆盖,只能各自独立;这时派生类的同名函数就沦落成"隐藏"而不是"重写"——多态随之失效。所以"三要素完全相同"不是编题人随便定的规矩,它直接对应虚表槽位的对齐规则。

协变(了解即可)

重写要求"返回值类型相同",但有一个例外叫协变:如果基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用,那么也构成重写。注意,这要求返回值必须是"指针或引用",而且必须是类类型的指针/引用,不是任意类型。

#include <iostream>
using namespace std;
 
class A {};
class B : public A {};   // 注意 B 继承 A
 
class Person {
public:
    virtual A* BuyTicket()     // 基类返回 A*
    {
        cout << "买票-全价" << endl;
        return nullptr;
    }
};
 
class Student : public Person {
public:
    virtual B* BuyTicket()     // 派生类返回 B*(B 是 A 的派生类),构成协变重写
    {
        cout << "买票-打折" << endl;
        return nullptr;
    }
};
 
int main()
{
    Person ps;
    Student st;
    A* p = ps.BuyTicket();   // 买票-全价
    A* q = st.BuyTicket();   // 买票-打折(调用的是 Student 的重写版本)
    return 0;
}

协变成立的精确姿势:两边必须都是指针,或必须都是引用(不能一边指针一边引用);且返回类型必须构成"派生→派生"的继承关系(基类返回 A*,派生返回它的某个派生类 B*)。为什么允许类型不同却仍算重写?因为返回类型不同并没有破坏虚表槽位的可替换性——基类槽位返回 A*、派生槽位返回 B*,而 B* 可以隐式转成 A*,调用方拿到的依旧是合法的 A*。这个"返回类型可以不同但必须能向上转型"的规则,让虚表替换依旧成立,所以标准网开一面允许它。

引用版本同样成立,比如基类 virtual A& get();、派生 virtual B& get();。协变的实际意义并不大(主要用于"工厂/克隆"类场景,比如 virtual Base* clone() const 在派生类里返回 Derived*),竞赛和面试里知道"返回值类型不同也能构成重写的唯一合法情形叫协变"就足够了。

一个经典选择题:默认参数与虚函数

课件里有一道堪称经典的陷阱题,绝大多数人会在这里翻车,我单独拿出来讲——因为它把"虚函数动态绑定"和"默认参数静态绑定"这两个极容易被混淆的机制放在一起考。

#include <iostream>
using namespace std;
 
class A {
public:
    virtual void func(int val = 1) { cout << "A->" << val << endl; } // 默认参数 1
    virtual void test() { func(); }                                  // 内部调用 func()
};
 
class B : public A {
public:
    void func(int val = 0) { cout << "B->" << val << endl; }         // 重写,默认参数 0
};
 
int main(int argc, char* argv[])
{
    B* p = new B;
    p->test();   // 输出?A选项里正确一项
    delete p;
    return 0;
}

选项大概是 A: A->0 / B: B->1 / C: A->1 / D: B->0 / E: 编译出错 / F: 以上都不正确。正确答案是 B: B->1。

我们来拆解:

  1. p->test(),p 是 B*,test 是虚函数但 B 没有重写它,所以真正执行的是 A::test。
  2. A::test 里那句 func():func 是虚函数,且 B 重写了它。这里的 this 实际指向 B 对象,所以动态绑定到 B::func——这一层决定了"调用哪个函数体":调用 B::func。
  3. 但 func 的默认参数填什么?关键就在这里:默认参数是在编译期、由调用者那个代码点的静态类型决定的。调用点位于 A::test 的源码 func(); 处,编译时这个调用点的函数静态类型是 A(因为 test 是 A 的成员函数,它看到的 func 签名按 A 来解析),于是编译器取 A::func 的默认参数 1。
  4. 运行时,动态绑定拾起了"函数体是 B::func",而参数早已由编译器用静态默认参数 1 填好。于是调用的是 B::func(1),打印 B->1。

结论一句话,请刻进脑子:虚函数是"动态绑定",默认参数是"静态绑定"。动态绑定管"调用哪个函数体",静态绑定管"参数用什么默认值"——两者各管各的,互不商量。这就是为什么 B::func 声明的 = 0 完全没起作用,因为它根本不是"调用点"能看到的默认参数。

这个陷阱给工程实践的启示也极其重要:永远别在虚函数里用默认参数,或者让基类和派生类的默认参数保持一致。因为一旦重写函数改变了默认参数,就会因为"函数体用派生类的、参数用基类的"这两头错位而写出极难排查的 bug。这也是为什么现代 C++ 里很多库刻意把带默认参数的逻辑拆到非虚公共接口里、把真正可定制逻辑放到无默认参数的虚函数里。C++ 不拦你这把火,但你应该自己知道往后退一步。

虚函数重写 vs 名字隐藏:三概念辨析

这是初学者最容易分不清的一对,而且考试选择题里专门出它。请把下面这张表刻进脑子里:

概念位置条件
重写/覆盖(override)派生类基类函数是虚函数,且函数名、参数列表、返回值完全相同(或协变)
重载(overload)同一个类内部函数名相同、参数列表不同,与是否虚无关
隐藏(hiding)派生类函数名相同即隐藏基类同名函数(不管参数是否相同、不管是否虚)

先澄清重载:它是"拍扁"在同一个类里的三个兄弟;而重写/隐藏是"纵向"跨基类派生类的两层楼关系。重载和重写名字很像(英文 overload / override 就差三个字母),但作用位、判定规则完全不同——重载比"参数不同",重写比"参数相同",别搞混。

重点讲隐藏,这是重灾区。所谓隐藏:只要派生类里出现了一个与基类某函数同名的函数,无论它参数一不一样、基类的那个是不是虚函数,基类的版本在"经过派生类这个渠道"时都会被"遮蔽"。这里有个坑:如果派生类重写基类虚函数时"三要素不齐"(比如参数列表变了),它不构成重写,就退化成隐藏——而一旦变成隐藏,多态就没了。子类重写不满足条件就是隐藏,这是面试官最爱的陷阱。

#include <iostream>
using namespace std;
 
class Base {
public:
    void show(int x)   { cout << "Base::show(int) x=" << x << endl; }   // 普通函数
    virtual void fun(int n) { cout << "Base::fun(int) n=" << n << endl; } // 虚函数
};
 
class Derived : public Base {
public:
    // 同名 show,但参数是 double —— 不管 Base::show 是不是虚函数,都构成"隐藏"
    void show(double d) { cout << "Derived::show(double) d=" << d << endl; }
 
    // 重写 Base::fun:函数名、参数、返回都一致 -> 构成重写
    virtual void fun(int n) { cout << "Derived::fun(int) n=" << n << endl; }
};
 
void callByBase(Base* p)
{
    p->fun(1);  // fun 是虚函数且被重写 -> 动态绑定,走多态
}
 
int main()
{
    Derived d;
    Base* p = &d;
 
    // 隐藏的体现:通过派生类调用时,基类的 show(int) 被遮蔽
    d.show(3.14);  // 调的是 Derived::show(double)(隐藏了基类版本)
    // d.show(3);     // 编译报错!派生类里看不到 Base::show(int)
 
    // 想强制调用被隐藏的基类版本:用作用域解析 ::
    d.Base::show(5);   // Base::show(int) x=5
 
    // 虚函数被重写 -> 多态生效
    callByBase(p);  // 输出 Derived::fun(int)  说明基类虚函数被派生类版本"接管"
    return 0;
}

注意 d.show(3) 那行被注释掉——如果你把它取消注释,MSVC 会报 error C2660: "Derived::show": 函数不接受 1 个参数。原因是 show(double) 把 show(int) 藏起来了,派生类这个"频道"里再也看不见基类那个 show(int)。这就是隐藏的直接恶果:基类的同名函数在派生类视角下"消失"了。要强行叫出它,只有用 d.Base::show(5) 这种作用域解析。

再做一个更能说明"隐藏 vs 重写的本质差异"的实验:同样一个调用,用基类指针和派生类指针分别调,结果可能完全不同——这在隐藏里成立,在重写里不成立。看:

#include <iostream>
using namespace std;
 
class Base {
public:
    void go() { cout << "Base::go" << endl; }              // 非虚
    virtual void fly() { cout << "Base::fly" << endl; }    // 虚
};
 
class Derived : public Base {
public:
    void go() { cout << "Derived::go" << endl; }           // 隐藏 Base::go
    virtual void fly() { cout << "Derived::fly" << endl; } // 重写 Base::fly
};
 
int main()
{
    Derived d;
    Base*   bp = &d;
    Derived* dp = &d;
 
    // fly(重写):
    bp->fly();   // Derived::fly —— 多态,跟"谁在调"无关,只跟"调谁"(动态类型)有关
    dp->fly();   // Derived::fly —— 一样
 
    // go(隐藏):
    bp->go();    // Base::go    —— 非虚,按基类静态类型解析
    dp->go();    // Derived::go —— 派生类指针能看到被隐藏的自己版本
 
    return 0;
}

请重点体会这条分界线:隐藏是"名字层面"的把戏,重写是"虚表层面"的把戏。隐藏只跟"名字撞了"有关,编译器按静态类型去找名字;重写跟"虚函数 + 三要素一致"有关,运行时通过虚表去换函数地址。这也是为什么我反复强调"三要素不齐就退化成隐藏"——它俩的判定完全是两套逻辑,别混为一谈。

所以,光从"两种指针调同一个名字、结果不同"就能瞬间判断:非虚同名=隐藏(静态决定、跟指针类型走),虚同名且签名一致=重写(动态决定、跟对象走)。这张"双指针实验"是做题时的王牌判据。

虚函数重写的其他问题:析构函数与构造函数

重写机制还有个极其重要、面试必考的延伸——析构函数的重写。

析构函数为什么常要写成虚函数

基类的析构函数如果是虚函数,那么派生类只要自己定义析构函数,无论加不加 virtual,都与基类析构函数构成重写。这里有个反直觉的地方:基类和派生类的析构函数明明名字不相同(~A 和 ~B),怎么满足重写的"三要素"?答案是:编译器对析构函数的名称做了特殊处理,编译后统一把析构函数的名字处理成 destructor,所以从编译器的视角看,它们名字是相同的,自然构成重写。

那"基类析构函数要加 virtual"到底图什么?一句话:为了保证 delete 一个"基类指针指向的派生类对象"时,能正确调用到派生类的析构函数,避免内存泄漏。

#include <iostream>
using namespace std;
 
class A {
public:
    virtual ~A()     // 基类析构函数加 virtual!这是关键
    {
        cout << "~A()" << endl;
    }
    // 注意:如果去掉这里的 virtual,delete p2 就只调 ~A 而漏调 ~B
};
 
class B : public A {
public:
    ~B()             // 无需加 virtual,也构成对 ~A 的重写
    {
        cout << "~B() 释放资源: " << _p << endl;
        delete[] _p; // 释放派生类自己 new 的资源
    }
 
private:
    int* _p = new int[10];  // 简化示意:派生类持有堆资源
};
 
int main()
{
    A* p1 = new A;
    A* p2 = new B;   // 基类指针指向派生类对象
 
    delete p1;       // 只涉及 A,正常
    delete p2;       // 关键:必须同时调用 ~B 和 ~A
    return 0;
}

把思路捋一下:p2 是 A* 却指向一个 B 对象。delete p2 要析构的是这个对象。如果 ~A 不是虚函数,编译器按静态类型 A* 决定——只调用 ~A(),而 B 自己 new 的那块 int[10] 内存永远不会被释放,直接内存泄漏。而一旦 ~A 是虚函数,delete p2 就走多态,动态绑定到 B 的析构函数,先跑 ~B()(释放自有用例,再清理派生类资源),随后编译器自动调用基类析构 ~A()。这就是"基类析构函数建议设计为虚函数"的根本原因。实际工程里概括成一条铁律:只要一个类可能被继承、且可能通过基类指针 delete 掉派生类对象,就把它的析构函数声明为 virtual。

这里把析构顺序也交代清楚(后面"对象模型"还会讲构造顺序):对象构造时"从基类到派生类"逐层构建;析构时严格反序"从派生类到基类"。所以当虚析构触发、动态绑到 ~B() 时,~B() 执行完,编译器自动继续调用 ~A()——这个顺序是 C++ 保证的,不会乱。你能看到的输出先是 ~B() ... 再是 ~A()。

再补一个边界:只有"通过基类指针 delete 派生类对象"这种跨界场景,虚析构才真正必要。为了让下面这段对比更直观,我们看"不写 virtual"会怎样:

#include <iostream>
using namespace std;
 
class NoVirtual {
public:
    ~NoVirtual() { cout << "~NoVirtual" << endl; }
};
 
class Child : public NoVirtual {
public:
    ~Child()  { cout << "~Child 释放内部资源" << endl; }
};
 
int main()
{
    NoVirtual* p = new Child;  // 基类指针指向派生类对象
    delete p;                  // ~NoVirtual 不是虚函数 -> 只调 ~NoVirtual
    return 0;
}

上面这段 delete p 后,只打印 ~NoVirtual,Child 里的析构清理逻辑完全不执行——资源就漏了。这就是"不写 virtual"的代价。别小看它:C++ 内存泄漏的隐蔽来源之一,就是这种"误以为派生类析构会被调用"的假象。

当然,虚析构也不是"免费的午餐":它会让类带上一个 vfptr(对象变大),也会让类的所有虚函数机制都激活。所以如果这个类永远不会被继承,或者永远只以栈对象使用(栈对象自动按确切类型析构,不需要虚函数帮忙),加 virtual 就是不必要的额外负担。工程上的拿捏:默认给可能成为基类的类写虚析构,纯工具类/值语义类(如 int 的包装、简单的 POD)就算了。

构造函数为什么不能是虚函数

紧承上一节的疑问:析构能是虚的,为什么构造函数就不能是虚函数?这个问题和"为什么继承的多态里一定要有指针/引用"一样,是理解对象本质的试金石。核心答案分四个层面:

第一,鸡生蛋悖论。 虚函数的实现依赖"查虚表 → 找函数地址"。而虚表的指针 vfptr 存放在对象的体内,必须等对象的内存真正分配出来(对象"诞生")之后,我们才有地方取 vfptr 去查表。可构造函数的职责恰恰是"创建对象"——在对象还没诞生之前,就没有 vfptr 可用;而要多态地"选哪个构造函数",又必须先查虚表。要查虚表需要对象,要建对象需要构造函数——两者互相依赖,这是逻辑上的死循环。所以构造函数天然不可能走"运行期查虚表"这条路。

第二,大小无从确定。 假设有一天 C++ 允许虚构造函数,那么通过 new Baseexact(...) 这样的写法,运行期需要先知道"对象到底是哪个类、要分配多大内存"。可要回答"多大",又得先知道确切类型;而要确定类型,又得靠多态分发——还是死循环。而普通 new Derived 则必须由程序员显式写出确切类型,否则连 Derived 的 sizeof 都不知道。虚构造函数解决不了这个"分配多大"的根本问题。

第三,语义层面根本不成立。 多态的意义是"对同一个已经存在的对象,在不同形态下展现不同行为"。可构造的语义是"新建一个对象、一次性把它定型"。你没法对一个"还不存在的东西"谈多态。析构函数则完全不同——delete 发生时对象已经完整存在、vfptr 已经就绪,所以析构可以安全地走虚表做动态绑定。"对象存续期"这个差别,决定了析构能虚、构造不能虚。

第四,语言层面的现实。 C++ 标准明确规定:构造函数不可能是虚函数。即便你写 virtual A() = 0; 也会直接编译报错。原因就是上面三条的综合。

而与"构造函数不能是虚函数"紧密相关、又极其容易踩的坑是:在构造函数(或析构函数)内部调用虚函数,不会发生多态。为什么?因为构造对象时,vfptr 是按"当前正在构造的那个类"逐步设置的:构造 Derived 时会先构造 Base 子对象,而构造 Base 期间 vfptr 还指向 Base 的虚表(派生类部分的表还没接管),此时调用虚函数只能解析到 Base 版本;等 vfptr 切到 Derived 虚表之后,才轮到派生类版本。析构顺序相反,也有同样的"阶段感"。写代码验证:

#include <iostream>
using namespace std;
 
class Base {
public:
    Base() { log(); }               // 构造期间调用虚函数 —— 只会调 Base::log
    virtual ~Base() {}
    virtual void log() { cout << "Base::log" << endl; }
};
 
class Derived : public Base {
public:
    Derived() : Base() { log(); }   // 这里 vfptr 已切到 Derived,会调 Derived::log
    virtual void log() { cout << "Derived::log" << endl; }
};
 
int main()
{
    Derived d;
    return 0;
}

输出会是:

Base::log
Derived::log

看完这个例子你就明白:"在构造函数里调虚函数想实现多态"是一个美丽的幻觉——Base 构造时 vfptr 还没指到 Derived 虚表,你调不到派生类版本,只会静默地拿到基类版本。这也是为什么很多规范(包括 Google C++ 风格指南)明确建议:不要在构造函数和析构函数里调用虚函数,否则极易写出"我以为调了派生类、实际调了基类"的隐性 bug。

虚函数表 vtable 与虚表指针 vfptr

现在,我们开始撕开多态的"黑盒",看它到底是怎么做到"同一个调用、不同结果"的。这需要引入两个核心概念:虚函数表(vtable)和虚表指针(vfptr)。

先回答一个最「底层」的问题:一个含有虚函数的对象,比没有虚函数的对象,身材要大多少?

答案是:每个含虚函数的对象,身上会多藏一个指针,用来指向它所属类的虚函数表。这个指针叫虚函数表指针,缩写 vfptr(v 代表 virtual,f 代表 function)。一个含虚函数的类,其对象里至少都有一个 vfptr,因为该类所有虚函数的地址都要放进这个类的虚函数表里,而对象得有个指针能找到它。

我们写段代码实测对象大小,你看就懂了:

#include <iostream>
using namespace std;
 
class Base {
public:
    virtual void Func1() { cout << "Func1()" << endl; }  // 一个虚函数
 
protected:
    int _b = 1;    // 4 字节
    char _ch = 'x'; // 1 字节
    // 对齐后通常凑成 8 字节
};
 
int main()
{
    Base b;
    // 32 位环境下:8(对齐后的 _b + _ch)+ 4(一个 vfptr)= 12 字节
    cout << sizeof(b) << endl;   // 输出 12(32 位程序)
    return 0;
}

在 32 位程序里,sizeof(b) 是 12 字节:_b(4)+ _ch(1)+ 对齐填充(3)= 8 字节,再叠加 4 字节的虚表指针,共 12。_b 和 _ch 是"不让人意外"的部分,多出来的那 4 个字节就是 vfptr,它通常被放在对象的最前面(部分平台会放最末尾,这跟具体编译器的实施有关,不是 C++ 标准强制规定的)。请记住这个结论:只要类里写了虚函数,它的每个对象就都要为 vfptr 多掏一个指针的内存。这也解释了为什么结构体/类里"加了 virtual 之后 sizeof 变了"。

务必把这个结论扩展到 64 位:指针在 64 位系统下是 8 字节。所以在 64 位程序里,上面的 Base 是 _b(4) + _ch(1) + 填充(3) = 8,再叠加一个 8 字节的 vfptr,总大小从 12 变成 16。同样的源码,32 位是 12、64 位是 16,这就是为什么面试题爱限定"编译为 32 位程序"——它是在问你"指针占几个字节"。顺带:空类 sizeof 是 1(为了"有地址可取"),那是一处与虚表无关的 C++ 特例;而加了哪怕一个虚函数的"看起来空"的类,sizeof 立刻变成指针大小(4 或 8),因为有了 vfptr。

再往前推一步——多重继承会把这一份变成好几份。如果一个派生类同时继承多个"各含虚函数的基类",对象里往往会携带多个 vfptr(每个基类各一个),因为每个虚表都要能被找到。看:

#include <iostream>
using namespace std;
 
struct A { virtual void fa() { cout << "A" << endl; } };
struct B { virtual void fb() { cout << "B" << endl; } };
 
// C 同时继承两个基类,各含 void 函数 -> 两个 vfptr
struct C : A, B {};
 
int main()
{
    C c;
    // 64 位下:两棵虚表指针(各 8 字节)= 16;空派生无成员
    cout << sizeof(c) << endl;   // 输出 16(64 位程序)
    return 0;
}

一对一继承关系下派生类复用基类那份 vfptr 即可;多重继承则通常是"一基类一指针"。这解释了"对象大小里的那个焦点"不总是一个。明白了这点,很多底层内存布局问题就不难推断了。

那么,虚函数表(vtable)是什么?一句话:虚表本质上是一个"存虚函数指针的指针数组"。一个类里每个虚函数都有一个地址,编译器平时用不着它们,就把这些地址收集起来,按顺序存进一张表里,这张表就叫虚表。每个含虚函数的类,都有自己独立的一张虚表。同类型的对象共享同一张虚表(多个同一类的对象,vfptr 值相同、指向同一张表),不同类型的对象各自有独立虚表——所以"基类和派生类"站在各自虚表后面,互不共享。

这里必须澄清两个"存放位置"的问题,它们经常被问倒人:

  • 虚函数本身存在哪? 虚函数和普通函数一样,编译好后是一段机器指令,都放在代码段里。虚函数地址并没有"单独挪走",只是在虚表里额外存了一份它的函数地址,方便运行时查找。
  • 虚函数表存在哪? 严格说,C++ 标准并没有硬性规定虚表放哪,这取决于编译器实现。我们通过实测地址来验证:在 VS 系列编译器下,分别打印栈、静态区、堆、常量区数据的地址,再打印虚表地址,会发现虚表的地址落在常量区(数据段里放只读常量的区域)附近,也就是说 VS 下虚表通常放在代码段/常量区。GCC/Clang 等编译器做法可能略有差异,但"虚表一般属于只读数据,放在进程的可执行文件映射区"这个大局是通的。

我们可以用下面这段代码,直观地窥探虚表的"长相"和位置:

#include <iostream>
using namespace std;
 
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() { cout << "Derive::func1" << endl; } // 重写 func1
    virtual void func3() { cout << "Derive::func3" << endl; } // 派生类自己的虚函数
    void func4() { cout << "Derive::func4" << endl; }         // 普通函数
 
protected:
    int b = 2;
};
 
int main()
{
    int i = 0;                    // 栈上的变量
    static int j = 1;             // 静态区变量
    int* p1 = new int;            // 堆上的内存
    const char* p2 = "xxxxxxxx";  // 常量区字符串
 
    Base b;
    Derive d;
    Base* p3 = &b;   // 指向 Base   对象
    Derive* p4 = &d; // 指向 Derive 对象
 
    printf("栈:     %p\n", &i);
    printf("静态区: %p\n", &j);
    printf("堆:     %p\n", p1);
    printf("常量区: %p\n", p2);
    // 把对象第一个成员(即 vfptr)取出来,就是虚表地址
    printf("Base   虚表地址: %p\n", *(int**)p3);
    printf("Derive 虚表地址: %p\n", *(int**)p4);
    printf("虚函数 func1 地址: %p\n", &Base::func1);
    printf("普通函数 func5 地址: %p\n", &Base::func5);
    return 0;
}

运行后你会看到大致的地址分布:栈和堆的地址较"远",静态区、常量区、虚表、函数地址彼此靠得很近。这说明虚表确实落在可执行文件映射出来的只读数据区,虚函数则和普通函数一样躺在代码段。另外你还会发现 Base 和 Derive 的虚表地址不一样——两个类的对象各自指向各自的虚表,互不共享。这为接下来讲"多态的原理"铺好了路。

关于虚表内容,还有一个工程细节值得点破:虚表末尾有没有"哨兵"取决于编译器。VS 系列的虚表通常允许在数组末尾放一个 0x00000000 当作结束/预留标记;但 C++ 标准完全不要求这个标记,GCC/Clang 一般不放。所以"虚表最后是不是一个空指针"是编译器相关的实现细节,别当语言规则背——考试若问,通常问的是"是否必须",答案是"不必须,各编译器自由"。

最后补一个尽快建立联系的结论:虚表不存普通(非虚)成员函数,func5、func4 这种都不会进虚表——它们是静态绑定的,直接 call 写死地址即可,不需要表来"兜底"。真正要进虚表的,只有virtual 的函数(以及每个虚析构)。

多态的原理:动态绑定如何发生

有了虚表和虚表指针,多态的实现就一目了然了。我们把最经典的 Person/Student 场景还原成底层视角:

#include <iostream>
#include <string>
using namespace std;
 
class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; } // 虚函数
 
private:
    string _name;   // 基类成员
};
 
class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; } // 重写
 
private:
    string _id;     // 派生类成员
};
 
class Soldier : public Person {
public:
    virtual void BuyTicket() { cout << "买票-优先" << endl; } // 重写
 
private:
    string _codename;
};
 
// 一个接收基类指针的通用函数
void Func(Person* ptr)
{
    // 这行是重点:最终调 Base::BuyTicket 还是 Student::BuyTicket,
    // 不取决于形参 ptr,而取决于 ptr 实际指向的那个对象
    ptr->BuyTicket();
}
 
int main()
{
    Person ps;
    Student st;
    Soldier sr;
 
    Func(&ps);   // 买票-全价
    Func(&st);   // 买票-打折
    Func(&sr);   // 买票-优先
    return 0;
}

那么 ptr->BuyTicket() 在底层是怎么"自动选择"的?逐层拆解:

  1. Person、Student、Soldier 三张虚表,分别记下了各自 BuyTicket 的地址(Student 的虚表里,BuyTicket 的位置被覆盖成了 Student::BuyTicket 的地址)。
  2. 每个对象最前面有个 vfptr。所以"对象是哪个类的",等价于"对象身上的 vfptr 指向哪张虚表"。
  3. 当运行 Func(&st) 时,ptr 指向 st 这个 Student 对象。运行时,程序顺着 ptr 找到对象 → 取出对象开头的 vfptr → 顺着 vfptr 找到 Student 的虚表 → 在虚表里定位 BuyTicket 那个槽位 → 读出函数地址 → 调用它。

于是,"调用哪个函数"是在运行期、根据对象身上的虚表动态确定的,这就是动态绑定的本质。你传的是 Person 指针没关系,运行时会揭掉这层皮,看它真正指向的对象。用完这句话可以再想想:为什么 Person 对象调用的是 Person::BuyTicket?因为它的 vfptr 指向 Person 的虚表。

用大白话说,多态的本质就是一句话:凭空多了一次"借着 vfptr 查虚表"的中转。普通函数调用是"拿着地址直接 call",多态调用是"先到对象里抓 vfptr,再顺着抓到的虚表取出真地址,再 call"。这一步正是动态绑定的全部。

再强调一个事实,好让你彻底放心:多态不仅能发生在基类和派生类两两之间,还能同时发生在多个派生类之间。Student、Soldier 都继承自 Person 都重写了 BuyTicket,于是同一个 Func 被传入任一对象都能正确调用——这就是"多处派生、一处分发"的多态威力所在。这也是前文数组例子里 Person* arr[] 能遍历的原因:每个对象凭各自的虚表"如实交代"自己是谁。

静态绑定与动态绑定

我们在前面反复提到"绑定"这个词,现在正式把术语敲定。

  • 静态绑定(编译时绑定):不满足多态条件的函数调用,编译器在编译期就能确定被调用函数的地址,直接把这个地址写死在调用点。比如,"不是虚函数"或者"用对象本身调用"。
  • 动态绑定(运行时绑定):满足多态条件的调用(基类指针/引用 + 调用虚函数 + 已重写),编译期无法确定地址,只能在运行时到指向对象的虚表里查表,找到函数地址再调用。

用一屏对比代码讲得更清楚。你可以理解为同一个调用点在"动态"和"静态"下走的完全是不同的管道:

#include <iostream>
using namespace std;
 
class Sample {
public:
    void Debug()        { cout << "Sample::Debug(普通)" << endl; } // 非虚
    virtual void Run()  { cout << "Sample::Run(虚)" << endl; }     // 虚
};
 
int main()
{
    Sample* p = new Sample;
    // 1) 动态绑定:p 是指针,Run 是虚函数 -> 满足多态条件,
    //    运行时到 p 指向对象的虚表里查 Run 的地址再调用
    p->Run();
    //    对应汇编(示意):
    //    mov eax, [p]      ; 取对象地址
    //    mov edx, [eax]    ; 取 vfptr(虚表指针)
    //    mov eax, [edx]    ; 从虚表取 Run 的函数地址
    //    call eax          ; 间接调用
 
    // 2) 静态绑定:Debug 不是虚函数,不满足多态条件,
    //    编译器直接确定 Sample::Debug 的地址写死
    p->Debug();
    //    对应汇编(示意):
    //    mov ecx, [p]
    //    call Sample::Debug ; 直接调用,地址是写死的
 
    delete p;
    return 0;
}

后面那两段汇编注释是我标注的示意。真正的动态绑定汇编会看到一串 mov eax, [p] → mov edx, [eax] → mov eax, [edx] → call eax 的"间接跳转";而静态绑定则是 call 一个写死的函数地址。看清这层差异,你就明白为什么动态绑定通常比静态绑定多一次间接寻址、性能略慢——这是多态能力的代价,也是它"润物细无声"的底层真相。

这里值得展开讲一层工程视角:动态绑定那串"查表"本质上是一次额外的内存间接访问(结构体/套两层指针)。现代 CPU 有分支预测(branch prediction)和乱序执行,连续对同一虚表槽位的调用(比如循环里反复调同一个虚函数)往往能被 CPU 顺畅流水化,性能损耗不明显;但理论上每次调用都多一次 load,同一对象里虚函数数量越多、调用越碎,损耗会累积。正因如此,追求极致性能的场合(如游戏引擎热点循环)会刻意回避虚函数,改用前面提过的"静态多态/模板"或 std::function 等手段。多态是"可维护性"换一点"性能",前提是你知道这笔账怎么算。

再一个容易混淆的点:不是所有"通过指针/引用调用 API"都是动态绑定。判定的唯一标准是"该函数是否虚、是否满足多态条件"。一个非虚函数哪怕你用指针调、用引用接,也是静态绑定——因为编译器拿着静态类型就能唯一确定函数地址,没有理由留到运行时。只有"编译器无法在编译期确定到底调谁"时,才被迫走虚表做动态绑定。

纯虚函数与抽象类

多态还有更"硬核"的玩法——纯虚函数和抽象类。

在虚函数结尾写上 = 0,这个函数就变成了纯虚函数。纯虚函数只需要声明,不需要(也通常没必要)提供定义实现——因为它的意义就是"等着被派生类重写",实现写了也会被覆盖掉(语法上 C++ 允许给纯虚函数写个实现,但几乎没有实用价值)。注意:纯虚函数和普通虚函数的写法区别,就在那后面一个 = 0。

包含(至少一个)纯虚函数的类,称为抽象类。抽象类有两个铁律:

  • 抽象类不能实例化出对象。Car car; 会直接编译报错,比如 MSVC 报 error C2259: "Car": 无法实例化抽象类,并指出是哪个纯虚函数没实现。
  • 抽象类的派生类,只要不把纯虚函数重写(实现掉),它就也是抽象类,仍不能实例化。

换句话说,纯虚函数在某种程度上"强制"了派生类必须重写它——不重写就实例化不出对象,编译直接不让过。

#include <iostream>
using namespace std;
 
// 抽象类:含纯虚函数,不能实例化
class Car {
public:
    virtual void Drive() = 0;   // 纯虚函数,只声明不定义
};
 
// 派生类实现(重写)了纯虚函数 -> 可以实例化
class Benz : public Car {
public:
    virtual void Drive() { cout << "Benz-舒适" << endl; } // 实现纯虚函数
};
 
class BMW : public Car {
public:
    virtual void Drive() { cout << "BMW-操控" << endl; }  // 实现纯虚函数
};
 
class NoDrive : public Car {
    // 没有重写 Drive,所以 NoDrive 仍是抽象类,不能实例化
};
 
int main()
{
    // Car car;        // 编译报错:无法实例化抽象类(Car)
    // NoDrive nd;     // 编译报错:NoDrive 没重写 Drive,仍是抽象类
 
    Car* pBenz = new Benz;   // 基类指针指向派生类,OK
    pBenz->Drive();          // Benz-舒适(多态)
 
    Car* pBMW = new BMW;     // 基类指针指向派生类,OK
    pBMW->Drive();           // BMW-操控(多态)
 
    return 0;
}

抽象类的价值,可以理解为画了一张"必须实现的接口契约"。它把所有派生类必须提供的"公共行为"(这里是 Drive)写成纯虚函数,谁想成为一类"能开"的 Car,谁就必须把它实现掉,否则根本造不出对象。这在架构设计里是划定"职责边界"的利器,也是后面讲接口(纯接口类)时思想的前身。

三个容易被追问的深化点:

其一,抽象类的析构函数该不该是虚的? 应当,理由和普通多态基类完全一样(前面"析构函数那段"):只要有人通过 Car* delete 一个 Benz,就需要虚析构保证调用到 Benz::~Benz。更重要的是——抽象类几乎必然要被 delete 基类指针,所以工程里所有抽象类的析构函数几乎都写虚拟的。注意纯虚析构还需要额外提供一份定义(给基类部分析构留个落点),这属于进阶细节,此处点到为止。

其二,"纯虚函数能不能带实现?" 语法上可以:你可以给一个纯虚函数额外写函数体(写成 virtual void f() = 0; 之后再 void Base::f() {...}),这样派生类可以主动通过限定名 Obj.Base::f() 调用这段"默认实现",但纯虚仍然强制派生类必须重写它(否则仍不能实例化)。这个"既有默认实现、又强制重写"的复合语义,常用于"模板方法"里的公共默认逻辑。看一个合法例子:

#include <iostream>
using namespace std;
 
class Shape {
public:
    virtual ~Shape() {}
    virtual double area() const = 0;          // 纯虚,强制实现
    void describe() const { cout << "area=" << area() << endl; }
};
 
// 纯虚函数也可以有定义(可选),派生类可用限定名 Shape::area() 主动调用
double Shape::area() const { return -1; }
 
class Square : public Shape {
public:
    explicit Square(double s) : s_(s) {}
    virtual double area() const override { return s_ * s_; }
private:
    double s_;
};
 
int main()
{
    Square q(3.0);
    Shape& s = q;
    s.describe();               // area=9(多态走 Square 版本)
    cout << s.Shape::area() << "\n"; // 限定名调用纯虚的"默认实现" -> -1
    return 0;
}

这里 s.Shape::area() 用限定名强行调用那个 = 0 函数的定义,输出 -1。它演示的是:纯虚≠绝不能有函数体,只是"通常不写"。绝大多数场景确实没必要写,知道有此能力即可。

其三,抽象类看作"可继承的接口骨架"。 如果一个抽象类里全是纯虚函数(没有任何普通实现),它几乎就是一个"接口"(Java/C# 里 interface 的 C++ 等价物)。这也是 C++ 里"面向接口编程"(Programming to the Interface)的常用落地方式:上层代码只依赖抽象基类的纯虚接口,具体实现由各派生类提供,客户端与具体实现彻底解耦。

多态对象模型与内存布局

到了这里,我们把前面所有知识点落到一张"地图"上——一个含虚函数的派生类对象,内存里到底长什么样。

先说结论,让你先有个整体构图:派生类对象 = 继承下来的基类部分 + 自己的成员。而继承下来的基类部分内部自带一个 vfptr,派生类一般不再另外生成虚表指针。这里有个容易想歪的地方:派生类对象里那个"基类部分的 vfptr",和"一个独立的基类对象身上的 vfptr"不是同一个指针——就像"基类对象里的成员"和"派生类对象里那个基类子对象的成员"是两份独立存储一样,它们各自有各自的虚表指针,指向各自的虚表。

再细化一下派生类虚表里到底存了啥,共有三部分:

  1. 继承下来的、没有被重写的基类虚函数地址;
  2. 被重写的基类虚函数——派生类虚表里对应槽位会被替换(覆盖)成派生类重写版本的地址;
  3. 派生类自己新增的虚函数地址。

注意第 2 点,这正是多态能够在虚表层面"偷梁换柱"的关键:Student 虚表里 BuyTicket 那格,存的就是 Student::BuyTicket 的地址,而不是 Person::BuyTicket。

#include <iostream>
using namespace std;
 
class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; } // 会被 Derive 重写
    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() { cout << "Derive::func1" << endl; } // 重写基类 func1
    virtual void func3() { cout << "Derive::func3" << endl; } // 派生类自己的虚函数
    void func4() { cout << "Derive::func4" << endl; }         // 普通函数,不进虚表
 
protected:
    int b = 2;
};
 
int main()
{
    Base b;
    Derive d;
 
    // d 的虚表(示意)应为:
    // 槽0: &Derive::func1   <- 覆盖了 Base::func1 的槽位
    // 槽1: &Base::func2     <- 没被重写,沿用基类版本
    // 槽2: &Derive::func3   <- 派生类新增的虚函数
 
    // 通过基类指针调用 func1,会命中虚表槽0,动态绑定到 Derive 版本
    Base* p = &d;
    p->func1();   // Derive::func1
    p->func2();   // Base::func2
    return 0;
}

关于虚表的"长相",还有几个工程层面的细节值得记一笔:虚表本质上是一个指针数组,VS 系列编译器一般会在数组末尾再放一个 0x00000000 作为结束标记;但 C++ 标准并没有规定必须有这个标记,GCC 系列编译器通常就不放。所以"虚表末尾有没有一个空指针"是编译期相关的实现细节,别当成语言规则。用调试器(比如 VS 的监视窗口)看派生类对象的虚表时,有时看不到派生类新增的虚函数(比如上面 func3),这是因为监视窗口只显示"重叠的基类虚函数",想看全得进内存窗口翻——这也侧面印证了虚表里确实按"基类虚函数 + 覆盖 + 新增"的顺序在排。

再补充两点对象模型密切相关的事实,帮助你把图画全:

第一,构造顺序与析构顺序(对象的一生)。 构造对象是从基类到派生类逐层进行:先搭好基类子对象(此时 vfptr 指向基类虚表),再逐步往里填派生类自己的成员,最后在派生类构造体开头把 vfptr 切到派生类虚表。析构完全反序。这一点正是上一节"构造期间调用虚函数不走多态"的原理支点。

第二,vfptr 通常放最前面,但也可能不是。 标准没有硬性规定 vfptr 的位置,主流编译器(VS/GCC/Clang)把它放对象最前,这是业界最常见的布局;少部分实现可能放末尾。你的代码永远不该依赖 vfptr 的确切位置——这属于"编译器实现的地盘",不是语言承诺。做底层观测时,"对象第一个成员很可能是 vfptr"这种经验只适用于主流工具链,离开就不要再当真。

把这张对象模型图记牢,你会发现之前所有关于 vfptr、vtable、动态绑定、多态的知识,全都串成了一条线:对象 -> vfptr -> 虚表 -> 函数地址 -> 调用。

final 与 override 关键字

C++ 对虚函数重写的要求很严格,但"严格"也意味着容易出错——比如函数名拼错、参数列表写错,这些错误编译期往往不报错,只会在运行时"没得到预期结果"时才被察觉,那会儿再 debug 就得不偿失了。C++11 提供了两个关键字来根治这类问题:override 和 final。

override:显式声明"我这是在重写基类虚函数"。如果写错了(基类根本没有签名匹配的虚函数),编译器直接报错。它把"运行时才发现"的事情提前到"编译期拦截"。

final:禁止派生类再重写这个虚函数。加在虚函数上,任何派生类一旦试图重写它,编译直接报错。它把"防御"写进类型系统里。

两个关键字各写一个完整示例(第一个会因为写错而编译失败,所以我用注释标出,并给出正确版):

#include <iostream>
using namespace std;
 
class Car {
public:
    virtual void Drive() {}   // 基类虚函数(注意方法名是 Drive,D 大写)
};
 
// 错误示范:拼写错误 B->Drive 写成了 B->Dirve,和基类对不上
// class BenzWrong : public Car {
// public:
//     virtual void Dirve() override {}   // 编译错误:C3668
// };
 
// 正确示范:重写签名完全对上,override 帮我们确认无误
class Benz : public Car {
public:
    virtual void Drive() override   // override:告诉编译器我要重写,写错会报错
    {
        cout << "Benz-舒适" << endl;
    }
};
 
int main()
{
    Car* p = new Benz;
    p->Drive();   // 多态:输出 Benz-舒适
    delete p;
    return 0;
}

注意:override 并没有任何"运行时/虚表"的作用,它的全部意义就是编译期校验——向编译器声称"我是在重写基类方法",如果声称失败就报错。没有它,错的 Dirve 也能编译通过(因为它只是悄悄变成了一个与基类不同名的新函数 + 一个隐藏),等到运行时调用 Drive 才发现没生效。override 把这种"阴险 bug"直接消灭在编译期。

再看 final 的完整示例(这次是"想重写但被禁止"):

#include <iostream>
using namespace std;
 
class Car {
public:
    virtual void Drive() final {}   // final:禁止派生类重写此项
};
 
// 错误示范:Benz 试图重写一个 final 的虚函数,编译报错 C3248
// class Benz : public Car {
// public:
//     virtual void Drive() { cout << "Benz-舒适" << endl; }
// };
 
// 唯一能用的写法:完全不重写它,老老实实继承基类默认实现
class Benz : public Car {
    // 这里不写 Drive,接受基类 final 的默认空实现
};
 
int main()
{
    Benz b;   // 可正常实例化,只是不能自定义 Drive
    b.Drive(); // 调用的是 Car::Drive(空实现)
    return 0;
}

使用习惯总结成两条:凡是准备重写基类虚函数的派生类方法,一律打上 override,让编译器替你盯住"我有没有真的重写成功";凡是希望"到此为止"、不再允许往下扩展的虚函数,打上 final。这一对关键字是 C++11 起每个合格 C++ 工程师都该养成的防错习惯,能让大量"重写没生效"的隐性 bug 在编译期就现形。

最后补几句版本与边界,避免你踩到别处的雷:

  • 版本差异:override 和 final 都是 C++11 引入。严格说它们不是"保留关键字",而是上下文相关的标识符(被称为"带 signifier 的标识符")——只有出现在特定语法位置时才被当作关键字。这意味着你可以在别处合法地把 override 当普通标识符用(虽然极不推荐)。这条特性沿用到 C++14/17 都成立。
  • final 也能修饰整个类,作用是把"禁止继承"提升到类级别:class NoInherit final { ... };。任何试图继承 NoInherit 的派生类都会编译报错(MSVC 报 error C3246)。这用于"我不想让任何人再从我这里派生"的场景,比"逐个函数加 final"更彻底。
  • override + final 可以叠加在一个函数上(比如 virtual void f() override final),语义是"我重写了一个基类虚函数,且我这里的最终版本不得再被我之后的派生类重写"。这在多层继承里很常见:中间层重写并"钉死"。
  • 纯虚也能配 final(virtual void f() = 0 final 之类的异形组合)属于极少用的边缘语法,了解即可,不再展开。

到这里,C++ 运行时多态的整个图景就完整了:从"为什么需要多态",到构成本它的两个充要条件(基类指针/引用 + 虚函数重写);从 virtual 定义虚函数、纯虚函数与抽象类的强制契约,到 override/final 的编译期防护;再往底层,是每个对象多一个 vfptr、每个类一张虚表,运行时通过查表完成动态绑定——而静态绑定则是编译期就把地址写死。你还应该记住,析构函数经常要 virtual 兜底,否则基类指针 delete 派生类对象会漏析构导致资源泄漏;而子类一旦重写不满足条件,就会退回"名字隐藏",多态便静悄悄失效。这些知识叠加起来,你不仅"会用多态",更知道它底层那一步"间接寻址"究竟发生在哪一杠。希望你合上这篇文章后,能把这个对象模型一口气在白板上画出来:对象 → vfptr → 虚表 → 函数地址 → 调用。下次面试官问起"虚表存在哪"或"什么时候该给析构函数加 virtual",你就能从容地对答如流了。