现在位置: 首页 > C++ 教程 > 正文

C++ 多态

多态(Polymorphism)是 C++ 面向对象编程的重要特性之一。简单来说,就是同一个接口,面对不同对象,可以表现出不同的行为。

例如,我们让不同的动物发声:狗会汪汪叫,猫会喵喵叫。虽然动作都叫"发声",实际执行的行为却不同。

在 C++ 中,我们可以先定义动物基类 Animal,然后创建 Dog 和 Cat 两个派生类。只需要调用同一个 sound() 接口,就能让不同对象执行各自的实现。

C++ 多态主要分为两种:

  • 编译时多态(静态多态):在编译阶段确定调用的函数,例如函数重载、运算符重载和模板。
  • 运行时多态(动态多态):通过虚函数和继承机制,在运行时根据对象的实际类型选择函数实现。

本章节我们重点学习运行时多态,通过可运行代码和四张示意图,逐步理解虚函数、动态绑定、override、纯虚函数、抽象类、虚析构函数及常见的对象切片问题。

多态的基本原理

先看这张关系图:Animal 定义统一的 sound() 接口,Dog 和 Cat 分别实现自己的声音。

C++ 多态继承关系图:Animal 基类和 Dog、Cat 派生类重写 sound 函数

为了观察派生类自己的实现被调用这一效果,我们通常需要:

  1. 存在基类与派生类的继承关系。
  2. 基类声明了虚函数(virtual)。
  3. 派生类提供了对应虚函数的重写实现(override)。如果没有重写,则可以使用继承的基类实现。
  4. 通过基类指针或引用调用虚函数。

例如:

Animal* animal = &dog;
animal->sound();

虽然 animal 是 Animal* 类型,但它实际指向 Dog 对象,因此调用的是 Dog 类中的 sound()。

这种在运行时根据对象的动态类型选择虚函数实现的机制,称为动态绑定(Dynamic Binding)。

动物发声:第一个多态实例

下面通过一个完整实例,演示如何使用虚函数实现多态。

实例

#include <iostream>
using namespace std;

// 动物基类
class Animal {
public:
    // 定义虚函数
    virtual void sound() const {
        cout << "Animal makes a sound" << endl;
    }

    // 虚析构函数
    virtual ~Animal() = default;
};

// 狗类
class Dog : public Animal {
public:
    // 重写基类虚函数
    void sound() const override {
        cout << "Dog barks" << endl;
    }
};

// 猫类
class Cat : public Animal {
public:
    void sound() const override {
        cout << "Cat meows" << endl;
    }
};

int main() {
    Dog dog;
    Cat cat;

    // 基类指针指向不同的派生类对象
    Animal* animal = &dog;
    animal->sound();

    animal = &cat;
    animal->sound();

    return 0;
}

程序执行结果:

Dog barks
Cat meows

代码分析

定义基类

Animal 类定义了虚函数 sound(),允许派生类提供不同的实现。

virtual void sound() const {
    cout &lt;&lt; "Animal makes a sound" &lt;&lt; endl;
}

派生类重写虚函数

Dog 和 Cat 都继承自 Animal,并使用 override 重写 sound()。

通过基类指针调用函数

当基类指针指向 Dog 对象时,调用 Dog::sound();当它指向 Cat 对象时,调用 Cat::sound()。

因此,即使调用代码相同,程序仍然可以根据对象的实际类型执行不同的函数。

注意:实例使用栈上创建的对象,不需要通过 new 和 delete 手动管理内存。

形状面积:统一接口处理不同对象

我们也可以将多态用在计算图形面积的任务中。矩形面积和三角形面积的计算方式不同,却可以使用相同的 area() 接口。

C++ 多态形状面积图:矩形宽10高7面积70,三角形底10高5面积25

多态的一个重要用途,是通过统一接口处理不同类型的对象。

例如,矩形和三角形都需要计算面积,但它们的计算公式不同。

我们可以定义一个 Shape 基类,提供 area() 接口,再由不同的形状类重写具体计算方法。

实例

#include <iostream>
using namespace std;

// 形状基类
class Shape {
protected:
    double width;
    double height;

public:
    Shape(double w, double h)
        : width(w), height(h) {}

    virtual double area() const {
        return 0;
    }

    virtual ~Shape() = default;
};

// 矩形类
class Rectangle : public Shape {
public:
    Rectangle(double w, double h)
        : Shape(w, h) {}

    double area() const override {
        return width * height;
    }
};

// 三角形类
class Triangle : public Shape {
public:
    Triangle(double w, double h)
        : Shape(w, h) {}

    double area() const override {
        return width * height / 2;
    }
};

int main() {
    Rectangle rec(10, 7);
    Triangle tri(10, 5);

    Shape* shape = &rec;
    cout << "Rectangle Area: " << shape->area() << endl;

    shape = &tri;
    cout << "Triangle Area: " << shape->area() << endl;

    return 0;
}

程序执行结果:

Rectangle Area: 70
Triangle Area: 25

程序分析:

  • Shape:形状基类,声明虚函数 area()。
  • Rectangle:矩形类,通过 width * height 计算面积。
  • Triangle:三角形类,通过 width * height / 2 计算面积。
  • Shape*:基类指针,可以指向不同的派生类对象。
  • override:明确表示派生类正在重写基类的虚函数。

当我们增加圆形等新类型时,可以让它继承 Shape 并实现 area()。原先通过 Shape 指针或引用调用面积接口的代码通常不需要因为新增类型而改变。

需要注意,以上 Shape 类并不是抽象类,因为它的 area() 是具有普通实现的虚函数,而不是纯虚函数。


虚函数:virtual 的作用

虚函数(Virtual Function)是在基类中使用 virtual 关键字声明的非静态成员函数。

派生类可以重写虚函数,使程序在通过基类指针或引用调用该函数时,根据对象的动态类型选择对应的实现。

例如:

class Base {
public:
    virtual void show() {
        // 基类实现
    }
};

虚函数具有以下特点:

  • 可以在基类中提供默认实现。
  • 派生类可以选择是否重写。
  • 通过基类指针或引用调用时,可以发生动态绑定。
  • 派生类中的对应重写函数仍然是虚函数,即使没有再次写出 virtual。

没有 virtual 会怎样?

这是理解多态最重要的对比:同样是 Animal* 指向 Dog 对象,基类函数是否声明为 virtual,会影响通过基类指针调用函数时选择哪个版本。

C++ virtual 对比图:没有 virtual 调用 Animal sound,有 virtual 调用 Dog sound

如果基类成员函数没有声明为虚函数,那么通过基类指针调用该函数时,通常根据指针的静态类型确定调用哪个版本。

实例

#include <iostream>
using namespace std;

class Animal {
public:
    void sound() const {
        cout << "Animal sound" << endl;
    }
};

class Dog : public Animal {
public:
    void sound() const {
        cout << "Dog barks" << endl;
    }
};

int main() {
    Dog dog;
    Animal* animal = &dog;

    // 通过基类指针调用
    animal->sound();

    // 直接调用派生类函数
    dog.sound();

    return 0;
}

执行结果:

Animal sound
Dog barks

因为基类中的 sound() 不是虚函数,所以 animal->sound() 调用的是基类版本。

如果在基类的 sound() 前添加 virtual,那么通过基类指针调用时,就可以选择派生类的重写版本。

override 关键字

从 C++11 开始,可以使用 override 明确声明派生类函数用于重写基类虚函数。

例如:

class Base {
public:
    virtual void show() const {}
};

class Derived : public Base {
public:
    void show() const override {}
};

override 不是实现动态绑定的必要条件,但建议在重写虚函数时使用。

如果函数签名不匹配,编译器会报告错误。例如:

class Base {
public:
    virtual void show() const {}
};

class Derived : public Base {
public:
    // 编译错误:缺少 const,不能重写基类函数
    void show() override {}
};

这样可以减少因为参数、const 限定符等不匹配而导致的错误。

final 关键字

从 C++11 开始,可以使用 final 禁止派生类进一步重写某个虚函数,或者禁止其他类继续继承某个类。

class Base {
public:
    virtual void show() {}
};

class Derived : public Base {
public:
    void show() final {}
};

以上代码中,Derived::show() 不能被更下层的派生类重写。

如果将 final 用于类:

class Dog final {
public:
    void sound() {}
};

那么 Dog 不能再作为其他类的基类。

动态绑定与静态绑定

动态绑定(Dynamic Binding),也称为运行时绑定,是指虚函数调用的具体实现可以在程序运行时根据对象的动态类型确定。

与之相对的是静态绑定(Static Binding),即在编译阶段确定函数调用的目标。

对比项 静态绑定 动态绑定
确定时机 编译阶段 运行阶段
常见场景 普通成员函数、函数重载 通过基类指针或引用进行虚函数调用
是否需要虚函数 不需要 通常需要
主要特点 调用目标通常可以直接确定 根据对象动态类型选择实现

需要注意,虚函数调用不一定都必须在运行时进行动态查找。编译器在能够确定目标的情况下,也可能进行去虚拟化(Devirtualization)等优化。

虚析构函数

如果一个类作为多态基类使用,并且允许通过基类指针删除派生类对象,通常应该将基类析构函数声明为 virtual。

这样可以确保删除派生类对象时,正确执行派生类和基类的析构过程。

实例

#include <iostream>
using namespace std;

class Animal {
public:
    virtual void sound() const {
        cout << "Animal sound" << endl;
    }

    // 虚析构函数
    virtual ~Animal() {
        cout << "Animal destroyed" << endl;
    }
};

class Dog : public Animal {
public:
    void sound() const override {
        cout << "Dog barks" << endl;
    }

    ~Dog() override {
        cout << "Dog destroyed" << endl;
    }
};

int main() {
    Animal* animal = new Dog();

    animal->sound();

    // 通过基类指针删除派生类对象
    delete animal;

    return 0;
}

执行结果:

Dog barks
Dog destroyed
Animal destroyed

调用 delete animal 时,会先执行 Dog 的析构函数,再执行 Animal 的析构函数。

注意:如果基类析构函数不是虚函数,通过基类指针删除派生类对象通常会产生未定义行为,而不只是简单地导致内存泄漏。

在现代 C++ 中,通常建议优先使用局部对象或智能指针管理资源,减少手动使用 new 和 delete。


纯虚函数与抽象类

纯虚函数(Pure Virtual Function)是在虚函数声明后使用 = 0 指定的函数。

纯虚函数通常用于定义基类接口,要求具体派生类提供自己的实现。

基本语法:

virtual 返回类型 函数名(参数列表) = 0;

例如:

class Shape {
public:
    virtual double area() const = 0;
};

包含尚未实现的纯虚函数的类是抽象类(Abstract Class),不能直接创建对象。

例如:

Shape shape; // 编译错误:不能实例化抽象类

不过,可以声明指向抽象类的指针或引用,用于操作具体的派生类对象。

抽象类与纯虚函数实例

下面将前面形状类中的 area() 改为纯虚函数,使不同的形状类必须提供计算面积的方法,才能实例化对象。

实例

#include <iostream>
using namespace std;

// 抽象基类
class Shape {
public:
    // 纯虚函数
    virtual double area() const = 0;

    virtual ~Shape() = default;
};

// 矩形
class Rectangle : public Shape {
private:
    double width;
    double height;

public:
    Rectangle(double w, double h)
        : width(w), height(h) {}

    double area() const override {
        return width * height;
    }
};

// 三角形
class Triangle : public Shape {
private:
    double width;
    double height;

public:
    Triangle(double w, double h)
        : width(w), height(h) {}

    double area() const override {
        return width * height / 2;
    }
};

int main() {
    Rectangle rec(10, 5);
    Triangle tri(10, 6);

    // 基类引用可以绑定派生类对象
    Shape& shape1 = rec;
    Shape& shape2 = tri;

    cout << "Rectangle Area: " << shape1.area() << endl;
    cout << "Triangle Area: " << shape2.area() << endl;

    return 0;
}

程序执行结果:

Rectangle Area: 50
Triangle Area: 30

程序分析:

  • Shape:抽象基类,定义纯虚函数 area()。
  • Rectangle:提供矩形面积的计算实现。
  • Triangle:提供三角形面积的计算实现。
  • Shape&:基类引用,可以绑定派生类对象并调用其虚函数。
  • override:检查派生类函数是否正确重写了基类虚函数。

如果派生类没有实现继承的全部纯虚函数,那么该派生类仍然是抽象类,不能直接实例化。

此外,纯虚函数也可以在类外提供函数定义,但这不会改变类的抽象性质。纯虚析构函数是一个特殊情况,即使声明为 = 0,仍然必须提供定义。

虚函数与纯虚函数的区别

对比项 虚函数 纯虚函数
声明方式 virtual void show(); virtual void show() = 0;
是否允许提供实现 允许 允许在类外提供定义
派生类是否必须重写 不必须 具体派生类必须提供非纯的最终重写版本
是否使类成为抽象类 不会仅因为普通虚函数而成为抽象类 若存在未实现的纯虚函数,则属于抽象类
主要用途 提供可重写的行为 定义必须由具体派生类实现的接口

虚函数表与实现机制

理解动态绑定后,我们可以进一步了解编译器通常如何实现它。下面的示意图展示了一种常见的 vptr 与 vtable 组织方式。

C++ 虚函数表 vtable 和虚函数指针 vptr 常见实现原理示意图

在很多 C++ 编译器中,运行时多态通过虚函数表(Virtual Table,简称 vtable)和虚函数指针(通常称为 vptr)实现。

虚函数表通常保存与虚函数动态分派有关的信息,对象则可能通过内部指针关联相应的虚函数表。

例如:

Animal* animal = &amp;dog;
animal->sound();

程序可以根据对象的动态类型找到对应的 sound() 实现。

注意:C++ 标准规定了虚函数调用的行为,但没有强制要求编译器必须使用特定的虚函数表或虚函数指针布局。

另外,普通虚函数动态分派不依赖 RTTI(运行时类型信息)功能。RTTI 主要用于 dynamic_cast、typeid 等运行时类型识别场景。

对象切片:为什么多态通常使用指针或引用

我们已经看到,基类指针和引用可以访问派生类对象。那如果我们把派生类对象按值赋给基类对象,结果会怎样?

这会发生对象切片(Object Slicing):新创建的基类对象只保留基类部分,不再是原来的派生类对象。

实例:对象切片与引用对比

#include <iostream>
using namespace std;

class Animal {
public:
    virtual void sound() const {
        cout << "Animal sound" << endl;
    }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void sound() const override {
        cout << "Dog barks" << endl;
    }
};

int main() {
    Dog dog;
    Animal copy = dog;     // 按值复制:对象切片
    Animal& ref = dog;     // 引用:保留动态类型

    copy.sound();         // Animal sound
    ref.sound();          // Dog barks
}

运行结果:

Animal sound
Dog barks

我们可以看到,copy 已经是一个独立的 Animal 对象,而 ref 仍然引用原来的 Dog 对象。

所以,在需要通过统一接口处理不同派生类对象时,我们通常使用基类指针或引用,避免无意间发生对象切片。


使用多态的注意事项

在实际 C++ 开发中,我们还要注意这些问题:

  • 优先使用 override:在派生类中重写虚函数时添加 override,让编译器检查函数签名。
  • 正确设计析构函数:允许通过基类指针删除对象时,基类通常需要公共虚析构函数。
  • 避免对象切片:将派生类对象按值赋给基类对象时,派生类特有部分可能被丢弃,通常应使用引用或指针保持多态行为。
  • 避免不必要的动态分派:如果不需要运行时多态,可以考虑普通函数、函数重载或模板。
  • 谨慎在构造和析构期间调用虚函数:此时不会分派到尚未构造或已经析构的更下层派生类部分。

多态适合不同类型对象需要通过统一接口处理的场景,例如图形系统、游戏对象、插件接口和设备驱动等。

但并不是所有继承关系都需要虚函数。应根据实际需求决定是否使用运行时多态。


动手练习

练习:增加一个新的动物类

我们可以在动物发声实例中新增 Bird 类,让它继承 Animal 并重写 sound()。然后通过 Animal* 调用,观察输出是否正确。

练习:为 Shape 增加圆形

我们可以新增 Circle 类,实现 area(),并尝试将 Shape 中的普通虚函数改为纯虚函数。圆的面积可以使用 3.14159 * r * r 计算。

练习:验证 override 的作用

将一个派生类重写函数末尾的 const 删掉,保留 override,编译并查看错误提示。然后恢复正确的声明。

练习:比较按值、指针和引用

运行对象切片实例,分别通过基类对象、基类指针和基类引用调用 sound(),对比结果,确认什么时候会保留运行时多态。

完成这些练习后,我们就能把 virtual、override、抽象类和虚析构函数真正用到自己的 C++ 项目中。