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 多态的构成条件

运行时多态必须同时满足两个条件

  1. 调用方式:必须通过基类的指针或引用调用虚函数
  2. 函数定义:被调用的函数必须是虚函数,且派生类完成了重写/覆盖(Override)

为什么必须是指针或引用?因为只有指针/引用才能既指向基类对象又指向派生类对象。值传递会触发切片,丢失派生类信息。

两个条件缺一个,多态就不生效——编译器会退回到静态绑定,在编译期直接确定函数地址。

3.2 虚函数

在类成员函数前加 virtual 关键字,该函数就成为虚函数:

class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

⚠️ 注意:非成员函数不能加 virtualvirtual 关键字只作用于类内的非静态成员函数。

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;
}

💡 多态不只发生在基类和单个派生类之间——多个派生类继承同一个基类并各自重写虚函数后,多态在多个派生类之间也同样生效。上面的例子中,FuncPersonStudentSoldier 三个不同类型的对象各产生了不同的行为。

关于派生类重写时是否写 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 {};   // ❌ 编译报错

💡 overridefinal 不是关键字(keyword),而是标识符(identifier)——它们只在特定上下文中具有特殊含义。也就是说,你可以用 overridefinal 作为变量名(但显然不推荐)。


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

调用链分析

  1. p->test() —— B 没有重写 test(),调用继承来的 A::test()
  2. A::test() 内部执行 func() —— 注意这里的 func() 等价于 this->func()
  3. this 指向的是 B 对象,func 是虚函数 → 动态绑定,调用 B::func()
  4. 但默认参数是静态绑定的——A::test() 中写的是 func()(没传参数),使用 A::func 声明的默认值 1

所以:函数体来自 B(输出 B->),默认参数值来自 Aval = 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 派生类的虚函数表构成

派生类的虚函数表由三个部分组成:

  1. 基类的虚函数地址(未被派生类重写的,原样复制过来)
  2. 派生类重写的虚函数地址(覆盖掉基类对应位置的函数指针)
  3. 派生类自己新增的虚函数地址(追加在表后面)
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

虚表地址(0071AB440071AB84)与常量区地址(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/catch
  • dynamic_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 关系再来驾驭虚函数。

更多推荐