多态:虚函数、纯虚函数、抽象类、动态绑定

多态,说白了就是「同一个接口,多种实现」。我刚开始学C++时,觉得这东西挺玄乎的。后来在项目中写了一个图形渲染引擎,才真正体会到它的威力。你想想看,如果没有多态,你要画圆、画方、画三角形,就得写三个不同的函数调用。有了多态,一个draw()搞定一切。

虚函数:多态的基石

虚函数是C++实现多态的核心机制。用virtual关键字修饰的成员函数,就是虚函数。它的作用是什么?让派生类可以覆盖基类的实现。

核心要点:基类指针或引用调用虚函数时,实际执行的是派生类的版本。这就是动态绑定。

class Shape {
public:
    virtual void draw() {
        std::cout << "绘制一个图形" << std::endl;
    }
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "绘制一个圆形" << std::endl;
    }
};

class Square : public Shape {
public:
    void draw() override {
        std::cout << "绘制一个正方形" << std::endl;
    }
};

你看,Shape里定义了draw()CircleSquare各自覆盖了它。调用时用基类指针指向派生类对象,就能自动调用正确的版本。

void render(Shape* shape) {
    shape->draw();  // 动态绑定,调用实际对象的draw()
}

int main() {
    Circle c;
    Square s;
    render(&c);  // 输出:绘制一个圆形
    render(&s);  // 输出:绘制一个正方形
    return 0;
}

我的习惯:在派生类覆盖虚函数时,一定加上override关键字。这能帮编译器检查你是否真的覆盖成功了。我曾经因为拼写错误,少写了一个字母,结果调用了基类的版本,排查了半天。

纯虚函数与抽象类

有时候,基类的某个函数根本没有合理的默认实现。比如Shape::draw(),你说「绘制一个图形」——到底画什么?圆形还是方形?这时候就该用纯虚函数了。

纯虚函数的写法是在函数声明后面加上= 0

class Shape {
public:
    virtual void draw() = 0;  // 纯虚函数
    virtual ~Shape() = default;  // 虚析构函数,别忘了
};

含有纯虚函数的类,就是抽象类。抽象类不能实例化。你写Shape s;编译器会报错。为什么?因为draw()没有实现,你没法调用它。

注意:抽象类虽然不能实例化,但可以定义指针或引用。这正是多态发挥作用的地方。

派生类必须实现所有纯虚函数,否则它自己也会变成抽象类。我见过不少新手写代码,派生类里漏掉了一个纯虚函数,结果编译不过去,一脸懵。

class Triangle : public Shape {
public:
    void draw() override {
        std::cout << "绘制一个三角形" << std::endl;
    }
};

嗯,这里要注意:纯虚函数也可以有实现体。虽然很少用,但有时候基类可以提供一些公共逻辑,派生类再调用它。

动态绑定:运行时才决定调用谁

动态绑定,也叫迟绑定。意思是函数调用的目标地址在运行时才确定。与之相对的是静态绑定,编译时就确定了。

为什么会这样?因为虚函数表(vtable)。每个含有虚函数的类,编译器都会生成一个虚函数表。对象里藏着一个虚指针(vptr),指向这个表。调用虚函数时,程序通过vptr找到vtable,再找到正确的函数地址。

避坑指南:我曾经在构造函数里调用虚函数,以为会调用派生类的版本。结果调的是基类的。因为构造时派生类还没构造完,vptr指向的是基类的vtable。记住:构造函数和析构函数里,虚函数不会表现出多态行为。

来看一个动态绑定的完整例子:

void processShapes(Shape* shapes[], int count) {
    for (int i = 0; i < count; ++i) {
        shapes[i]->draw();  // 动态绑定
    }
}

int main() {
    Circle c;
    Square s;
    Triangle t;
    
    Shape* shapes[] = {&c, &s, &t};
    processShapes(shapes, 3);
    // 输出:
    // 绘制一个圆形
    // 绘制一个正方形
    // 绘制一个三角形
    return 0;
}

你想想看,如果没有动态绑定,你得写多少个if-else或者switch?每加一个新形状,就得改一遍代码。有了多态,扩展起来轻松多了。

知识体系结构图

C++ 多态知识体系 多态 (Polymorphism) 虚函数 (virtual) 纯虚函数 (= 0) 动态绑定 override 关键字 虚析构函数 抽象类 (不可实例化) 派生类必须实现 vptr / vtable 运行时确定 核心思想:基类指针/引用 → 派生类对象 同一个接口,多种实现

虚函数表(vtable)的工作原理

每个含有虚函数的类,都有一个虚函数表。这个表是个函数指针数组,存放着所有虚函数的地址。每个对象里有一个隐藏的虚指针(vptr),指向这个表。

vtable 内容
Shape Shape::draw() 地址
Circle Circle::draw() 地址
Square Square::draw() 地址

调用shape->draw()时,实际流程是:

  1. 通过shape指针找到对象的vptr
  2. 通过vptr找到类的vtable
  3. 在vtable中找到draw()的地址
  4. 调用该地址指向的函数

我的经验:虚函数表虽然方便,但会带来一点性能开销。每次调用虚函数,都要多一次间接寻址。不过在现代CPU上,这点开销基本可以忽略。除非你在写极度性能敏感的代码(比如游戏引擎的每帧循环),否则不用太担心。

避坑总结

  • 构造函数里别调虚函数——调了也是基类的版本,白费功夫
  • 析构函数要声明为虚函数——否则通过基类指针删除派生类对象时,可能不会调用派生类的析构函数,造成资源泄漏
  • override检查覆盖——编译器能帮你发现拼写错误或参数不匹配
  • 抽象类不能实例化——但可以定义指针和引用
  • 纯虚函数可以有实现——虽然不常用,但确实可以

多态是C++面向对象编程的灵魂。我个人觉得,理解虚函数表的工作原理,是掌握多态的关键。你不需要记住vtable的每个细节,但要知道它存在、知道它怎么工作。这样遇到奇怪的问题时,你至少知道往哪个方向排查。

一句话总结:虚函数让基类接口统一,派生类实现各异。动态绑定让程序在运行时自动选择正确的函数。抽象类强制派生类提供实现。这三者合在一起,就是C++多态的完整图景。

公众号:蓝海资料掘金营,微信 deep3321