什么是多态?实现技术是什么?

看到你的简历上写着熟悉 C++ 的面向对象编程。那你能用自己的话简单解释一下,什么是多态吗?在 C++ 中,我们通常通过什么关键技术来实现多态?

我理解的多态,就是同一个函数在不同的继承类中可以有不同的内容,这取决于子类自己的实现方式。(更精炼的表达:同一个接口,不同的实现。

实现多态的关键技术通常是虚函数 (virtual function),即基类中声明的函数,在派生类中可以有自己另外的实现 (override),从而适配每个派生类的具体情况。(如果基类中没有对这一函数的实现,全部靠派生类自己实现,那么这种函数就是纯虚函数)

用基类指针指向派生类对象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Animal {
public:
    // 这是一个普通的虚函数,它有自己的实现
    virtual void makeSound() {
        std::cout << "Some generic animal sound" << std::endl;
    }
};

class Dog : public Animal {
public:
    // Dog 类重写(override)了父类的虚函数
    void makeSound() override {
        std::cout << "Woof!" << std::endl;
    }
};
1
2
Animal* pAnimal = new Dog(); // 用基类指针指向派生类对象
pAnimal->makeSound();        // 请问,这里会输出什么?

如果用基类指针指向派生类对象,并调用函数,那么这个函数应该是派生类中覆写后的版本,而不是基类中的版本。

即:输出结果取决于指针实际指向的对象的类型(动态类型),而不是指针变量本身声明的类型(静态类型)。

C++如何保证能调用正确的函数呢?这一点我只是依稀记得和 v-table 还有 v-ptr 相关,具体内容不记得了。

这个具体机制被称为“动态绑定 (dynamic binding)”,它分为三个阶段:

  1. 编译阶段:
    • 编译器发现某个类中至少有一个虚函数,它会为这个类创建一个静态的数组,这个数组就是虚函数表(v-table)。
    • V-table 中存放函数指针,每个指针指向该类的一个虚函数的实际实现地址
  2. 对象创建阶段:
    • 当编译器发现一个类有 v-table 时,它还会在这个类的对象中增加一个隐藏的成员,这个成员是一个指针——虚函数指针(v-ptr)。
    • 比如说,当我们创建一个 Dog 类的对象时 (new Dog()),这个对象的 v-ptr 会在构造函数中被自动设置为指向 Dog 类的 v-table
  3. 函数调用阶段:
    • pAnimal->makeSound() 这一步调用时,编译器会发现这个 makeSound 是一个虚函数,所以它不会在编译时就决定调用哪一个函数(静态绑定:编译时就决定调用哪一个函数)
    • 相反,它会采用动态绑定,即在运行时才决定调用的函数,过程如下:
      • 通过 pAnimal 指针,找到内存中的 Dog 类对象
      • 通过 Dog 类对象的 v-ptr,找到其 v-table
      • 通过 v-table,找到 makeSound 函数对应的指针。
      • 最后,调用对应的函数。

构造函数和析构函数可以是虚函数吗?

基于上面的过程,我认为构造函数不可以是虚函数,析构函数可以是虚函数,理由如下:

  • 虚函数的调用需要经过 v-ptr,而这个 v-ptr 是类的对象的成员。
  • 我们调用构造函数的目的是创建类的对象。如果构造函数也是虚函数,那么在类的对象还未创建时(也就是 v-ptr 还不存在时),就需要调用 v-ptr 来找到 v-table,这显然不合理。
  • 而析构函数则没有这一问题。事实上,只要某个类有可能被用作基类,那么它的析构函数必须是虚函数! 否则极易造成内存泄漏。

Polymorphism 多态

宏观理解

首先,让我们从最高层面理解“多态”。

  • 字面意思:“多态” (Polymorphism) 一词源于希腊语,意为“多种形态”。
  • 编程中的意义:指同样的一个接口(比如一个函数名或一个操作符),在作用于不同类型的对象时,能表现出不同的行为

简单来说,就是“同样的操作,不同样的结果”。C++ 主要通过两种方式实现多态:静态多态(对应函数重载)和动态多态(对应虚函数)。

静态多态 v.s. 动态多态

1. 静态多态 (Static Polymorphism) / 编译期多态

  • 绑定时机编译期 (Compile-time)。 在程序被编译成可执行文件的时候,哪个函数调用应该执行哪一段代码就已经被硬编码进去了。程序运行时没有选择的余地。
  • 实现方式
    • 函数重载 (Function Overloading): 编译器根据你调用函数时提供的参数的类型、数量和顺序来决定使用哪个版本的函数。这一切在编译时都是确定的。

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      
      #include <iostream>
      
      void print(int i) {
          std::cout << "Printing an integer: " << i << std::endl;
      }
      
      void print(double d) {
          std::cout << "Printing a double: " << d << std::endl;
      }
      
      int main() {
          // 编译器看到 print(10),因为 10 是 int,所以它直接将调用指令指向 print(int) 函数。
          print(10);
      
          // 编译器看到 print(5.5),因为 5.5 是 double,所以它直接将调用指令指向 print(double) 函数。
          print(5.5);
          return 0;
      }
    • 模板 (Templates): 模板允许我们编写泛型代码。当我们使用模板时,编译器会根据我们提供的具体类型,在编译时为我们生成特定版本的函数或类。这也属于静态多态。

  • 特点:速度快,效率高,因为省去了运行时的查找过程。但缺乏运行时的灵活性。

2. 动态多态 (Dynamic Polymorphism) / 运行期多态

  • 绑定时机运行期 (Run-time)。 程序在实际运行时,才会根据对象的真实类型来决定到底该调用哪个函数。
  • 实现方式继承体系 + 虚函数 (Virtual Functions)
  • 特点:非常灵活,是实现“高内聚,低耦合”等软件设计原则的利器,但会带来微小的性能开销。

虚函数、虚函数表(v-table)与虚函数指针 (v-ptr)

这是实现动态多态的“魔法引擎”。让我们揭开它的工作原理。

1. 虚函数 (virtual)

virtual 是一个关键字,当你用它修饰一个基类的成员函数时,你就在告诉编译器:“嘿,这个函数的绑定决策,请不要在编译时做死,把它推迟到运行时,根据对象的实际类型来决定吧!”

2. “魔法引擎”的工作机制

当一个类声明了虚函数(或者继承了有虚函数的基类),编译器会在幕后做两件重要的事情:

A. 虚函数表 (Virtual Table / v-table)

  • 是什么:编译器会为每一个拥有虚函数的类创建一个静态的、唯一的表。这个表本质上是一个函数指针数组

  • 存什么:表中的每一项都是一个函数指针,指向该类的一个虚函数的实际地址。如果派生类重写(override)了基类的虚函数,那么派生类的 v-table 中对应的那一项就会指向派生类自己的函数版本。

B. 虚函数指针 (Virtual Pointer / v-ptr)

  • 是什么:编译器会在每一个拥有虚函数的类的对象里,安插一个隐藏的指针。这个指针就是 v-ptr。

  • 指向哪:对象的 v-ptr 指向其所属类的 v-table。一个 Circle 对象的 v-ptr 指向 Circle 类的 v-table;一个 Square 对象的 v-ptr 指向 Square 类的 v-table。

3. 动态绑定 (Dynamic Binding) 的过程

让我们用一个经典的例子来看看这个引擎是如何工作的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>

// 基类 Shape
class Shape {
public:
    // 声明一个虚函数
    virtual void draw() const {
        std::cout << "Drawing a generic shape.\n";
    }
    virtual ~Shape() {} // 析构函数通常也应是虚函数
};

// 派生类 Circle
class Circle : public Shape {
public:
    // 重写(override)基类的虚函数
    void draw() const override {
        std::cout << "Drawing a circle: O\n";
    }
};

// 派生类 Square
class Square : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a square: []\n";
    }
};

// 这个函数接受一个基类指针,体现了多态
void render(const Shape* shape_ptr) {
    std::cout << "Rendering... ";
    // 这就是动态绑定发生的地方!
    shape_ptr->draw();
}

int main() {
    Circle c;
    Square s;

    render(&c); // 传递 Circle 对象地址
    render(&s); // 传递 Square 对象地址

    return 0;
}

程序输出:

Rendering... Drawing a circle: O
Rendering... Drawing a square: []

shape_ptr->draw(); 这行代码在运行时的详细步骤:

  1. 程序看到 shape_ptr 是一个指针,并且它调用的 draw() 是一个 virtual 函数。
  2. 于是,它不会直接调用 Shape::draw()
  3. 第一步 (找 v-ptr):程序通过 shape_ptr 找到它当前指向的那个对象(第一次是 c,第二次是 s)的内存地址。
  4. 第二步 (找 v-table):程序从对象的隐藏成员中,取出 v-ptr 的值。
    • shape_ptr 指向 c 时,取出的 v-ptr 指向 Circle 类的 v-table。
    • shape_ptr 指向 s 时,取出的 v-ptr 指向 Square 类的 v-table。
  5. 第三步 (查表并调用):程序在刚刚找到的 v-table 中,查找 draw() 函数对应的条目,得到一个函数地址,然后调用这个地址指向的函数。
    • Circle 的 v-table 中,draw() 条目指向的是 Circle::draw()
    • Square 的 v-table 中,draw() 条目指向的是 Square::draw()

就这样,通过 v-ptrv-table 的两级跳转,程序在运行时实现了函数的动态选择,这就是动态绑定的核心原理。

总结

对比项静态多态 (以函数重载为例)动态多态 (以虚函数为例)
绑定时机编译期运行期
决策依据函数名和参数列表(静态类型)指针或引用所指向对象的真实类型(动态类型)
实现机制编译器直接解析v-ptr 和 v-table
效率,无运行时开销略低,有查找 v-table 的微小开销
灵活性低,编译后无法更改,可以轻松扩展新的派生类

和 Gemini 的问答:

我有点懂了,v-ptr就是某个具体类的对象用来存储其v-table的地址的东西,然后v-table是每个类都独立的,里面存储着自己类对基类的virtual function的具体实现,对吗?

完全正确!

你的这个总结堪称完美,一句话就抓住了 v-ptr 和 v-table 的本质关系!这说明你已经彻底理解了。我们来把你这句话再确认和巩固一下。

“v-ptr就是某个具体类的对象用来存储其v-table的地址的东西”

完全到位。 v-ptr 就是对象级别的,每个对象都有一个。它就像每个员工(对象)胸前工作证上的那个独一无二的二维码(v-ptr),这个二维码链接到他所属公司的官方手册(v-table)。它回答了这个问题:“我这个对象,应该去查阅哪一本手册?

“然后v-table是每个类都独立的,里面存储着自己类对基类的virtual function的具体实现,对吗?”

同样非常精准。 v-table 是类级别的,每个(有虚函数的)类都只有一份。它回答了这个问题:“对于这个应急预案(虚函数),具体的操作步骤是什么?

如果允许我做一个小小的补充,让它更严谨的话:

一个派生类的 v-table,可以看作是先完整复制一份其基类的 v-table,然后再用自己重写(override)过的函数地址,去覆盖掉基类对应的条目

举个例子:

假设基类 Shape 有两个虚函数:

  • virtual void draw()
  • virtual void rotate()

那么 Shape 类的 v-table 就像这样:

  1. draw() 条目 -> 指向 Shape::draw() 的代码地址
  2. rotate() 条目 -> 指向 Shape::rotate() 的代码地址

现在派生类 Circle 只重写了 draw() 函数,继承了 rotate() 函数。 那么 Circle 类的 v-table 就会是这样:

  1. draw() 条目 -> 被更新为指向 Circle::draw() 的代码地址
  2. rotate() 条目 -> 依然指向 Shape::rotate() 的代码地址

所以你的理解是完全正确的,它里面存储的就是这个类最终生效的、所有虚函数的具体实现版本地址

你已经完全掌握了 C++ 动态多态的核心机制了!