CC++ & Algorithm

多态——同一个指令,不同表现

较难16
语言版本:C++Python
概述:用父类指针指向不同子类对象,调用同一个函数却执行不同的代码,就像按下遥控器,不同电器的反应不同。

多态:同一个指令,千变万化的行为

你有用过万能遥控器吗?按一下“音量+”键,电视会增大音量,空调会调整风速,音响会变响。都是按同一个按钮,但不同设备用自己的方式来响应。在C++里,这种“同一个接口,多种实现”的特性叫做“多态”。再比如玩游戏时,你按“攻击”键,战士会挥剑,法师会施法,弓箭手会射箭——角色不同,攻击表现完全不同,但触发的指令都是“attack()”。

多态让程序变得更灵活、更易扩展。当你要新增一种“形状”时,只需要写一个新的子类,写出自己的 area() 计算方法,原来调用 printArea() 的代码完全不用修改。这就是面向对象编程中“开闭原则”的体现。

1. 多态的实现条件

要让多态生效,必须同时满足三个条件:

  1. 继承:子类从基类派生。
  2. 虚函数:在基类中用关键字 virtual 声明函数。
  3. 基类的指针或引用:通过基类指针或引用来调用那个虚函数。

当满足这些条件时,程序在运行时(而非编译时)会根据对象的实际类型来决定调用哪个函数,这叫动态绑定迟绑定。反之,如果直接用对象名调用(如 c.area()),调用在编译时就确定了,没有多态。

class Animal {
public:
    virtual void speak() const {          // 虚函数
        cout << "动物叫" << endl;
    }
};

class Dog : public Animal {
public:
    void speak() const override {         // 重写
        cout << "汪汪" << endl;
    }
};

class Cat : public Animal {
public:
    void speak() const override {
        cout << "喵喵" << endl;
    }
};

void makeSound(const Animal& a) {         // 基类引用
    a.speak();                            // 运行时决定调用哪个speak
}

int main() {
    Dog dog;
    Cat cat;
    makeSound(dog);     // 汪汪
    makeSound(cat);     // 喵喵
    return 0;
}

2. 虚函数的工作原理(简单理解)

编译器会为每个包含虚函数的类维护一张虚函数表(vtable),里面存放着虚函数的地址。每个对象内部有一个隐藏的指针(vptr)指向自己所属类的虚函数表。当通过基类指针调用虚函数时,程序会根据对象的 vptr 找到对应的虚函数表,然后从中取出正确的函数地址来执行。

可以这样想象:每个电器(对象)出厂时都附带一张“说明书”(虚函数表),遥控器(基类指针)按按钮时,电器自己查说明书来执行对应操作。不同电器说明书上“音量+”那项内容不同,所以表现不同。

3. override 关键字的作用

从 C++11 开始,可以在子类重写的函数后面加上 override。它告诉编译器:“我要重写父类的虚函数,帮我检查一下函数签名是否完全一致。” 如果不小心把 area() 写成了 are(),编译器会直接报错,避免隐藏父类函数造成的 bug。

class Rectangle : public Shape {
public:
    double area() const override {   // 正确
        ...
    }
    // double area() override { ... }   // 错误!签名不一致(少const),编译器报错
};

4. 虚析构函数的重要性

当通过基类指针 delete 一个派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而派生类的析构函数不会被调用,导致派生类中申请的资源(如动态内存)无法释放,造成内存泄漏。

规则:只要类中定义了虚函数,就应该把析构函数也声明为虚函数(即使它什么都不做)。

class Base {
public:
    virtual ~Base() { cout << "~Base()" << endl; }
};

class Derived : public Base {
private:
    int* data;
public:
    Derived() { data = new int[100]; }
    ~Derived() override { delete[] data; cout << "~Derived()" << endl; }
};

int main() {
    Base* p = new Derived();
    delete p;   // 先调用 ~Derived(),再调用 ~Base()
    return 0;
}

5. 纯虚函数与抽象类

有时基类无法给虚函数一个有意义的默认实现(比如“形状”的面积根本无法计算),这时可以将它声明为纯虚函数,在函数原型后面加上 = 0。包含纯虚函数的类称为抽象类,不能创建它的对象,只能作为基类被继承。子类必须重写所有纯虚函数,否则子类也会变成抽象类。

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

// Shape s;  // 错误!不能实例化抽象类

把之前的 Shape::area() 改成纯虚函数才是更规范的做法,因为基类根本不应该有“默认面积”。

6. 完整可运行的代码示例

下面是把之前例子升级后的完整代码,展示了多态、虚析构和纯虚函数的使用:

#include <iostream>
#include <cmath>
using namespace std;

class Shape {
public:
    virtual double area() const = 0;   // 纯虚函数,形状没有默认面积
    virtual ~Shape() {}                // 虚析构函数
};

class Circle : public Shape {
private:
    double radius;   // 半径
public:
    Circle(double r) : radius(r) {}
    double area() const override {
        return M_PI * radius * radius;
    }
};

class Rectangle : public Shape {
private:
    double width, height;   // 宽,高
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    double area() const override {
        return width * height;
    }
};

class Triangle : public Shape {
private:
    double base, height;   // 底边,高
public:
    Triangle(double b, double h) : base(b), height(h) {}
    double area() const override {
        return 0.5 * base * height;
    }
};

void printArea(const Shape& s) {       // 参数是基类引用
    cout << "面积 = " << s.area() << endl;
}

int main() {
    Circle c(5.0);
    Rectangle r(4.0, 3.0);
    Triangle t(6.0, 4.0);
    printArea(c);   // 输出圆的面积
    printArea(r);   // 输出矩形的面积
    printArea(t);   // 输出三角形的面积

    // 也可以使用指针数组演示多态
    Shape* shapes[3];                // 基类指针数组
    shapes[0] = new Circle(5.0);     // 指针指向圆对象
    shapes[1] = new Rectangle(4.0, 3.0);
    shapes[2] = new Triangle(6.0, 4.0);
    for (int i = 0; i < 3; ++i) {
        cout << "面积 = " << shapes[i]->area() << endl;   // 多态调用
        delete shapes[i];          // 通过虚析构正确释放
    }
    return 0;
}

运行结果:

面积 = 78.5398
面积 = 12
面积 = 12
面积 = 78.5398
面积 = 12
面积 = 12

7. 新手易犯的错误

  1. 忘记在基类加 virtual
    如果基类函数没有 virtual,子类重写会变成“隐藏”,通过基类指针调用时始终调用基类的版本,没有多态。

  2. 虚析构函数忘记写 virtual
    delete 删除基类指针指向的子类对象时,子类析构函数不会被调用,造成资源泄漏。

  3. 在构造函数或析构函数中调用虚函数
    构造函数和析构函数执行时,对象的虚表指针可能不完整(正在构造或已销毁),调用虚函数不会触发动态绑定,而是调用当前类自己的版本。所以不要在构造/析构中依赖多态。

  4. 用对象而不是指针/引用调用
    Circle c; Shape s = c; s.area() —— 这里 sShape 类型的对象,发生了切片(只复制了基类部分),调用的永远是 Shape::area(如果是纯虚函数则直接报错)。要使用引用或指针。

  5. 重写时函数签名不一致
    比如忘记加 const,或者参数类型不同。编译器会认为子类定义了一个新函数(隐藏),而不是重写。使用 override 关键字能直接发现这个问题。

8. 相关知识点指引

  • 继承:多态的基础,理解公有继承、保护继承、私有继承的区别。
  • 虚函数与虚函数表:深入理解动态绑定的底层实现。
  • 抽象类与接口:pure virtual function 的设计,在 C++ 中模拟接口。
  • RTTI(运行时类型识别)dynamic_casttypeid 用于在运行时获取对象的实际类型。
  • 设计模式:多态是实现策略模式、模板方法模式等的重要工具。
  • 动态内存管理:配合多态使用时,注意用 delete 释放,考虑使用智能指针(如 unique_ptr)自动管理资源。

掌握多态,你就掌握了面向对象编程的核心“武器”。它让代码能够优雅地应对变化——当新需求来时,只需添加新的子类,原有系统几乎不需要改动。就像万能遥控器不断加入新设备,而遥控器本身还是那个遥控器。

例题精讲

1单选题

以下哪个选项不是C++中实现多态的必要条件?

A存在继承关系
B派生类重写基类的虚函数
C使用基类指针或引用调用虚函数
D基类的析构函数必须为虚函数
2判断题

在C++中,通过基类指针调用虚函数时,编译器会根据指针的静态类型决定调用哪个函数。

3填空题
以下代码希望实现多态:通过基类指针调用speak()函数,输出派生类的内容。请填空补全基类定义。

class Animal {
public:
    ___ void speak() { cout << "Animal sound"; }
};
class Dog : public Animal {
public:
    void speak() override { cout << "Woof"; }
};
int main() {
    Animal* ptr = new Dog();
    ptr->speak(); // 期望输出 Woof
    delete ptr;
    return 0;
}
4单选题

关于虚函数表(vtable)的描述,下列哪项是正确的?

A每个对象都有一个独立的虚函数表
B虚函数表是在编译时生成,存储在代码区
C每个类只有一张虚函数表,该类的所有对象共享
D虚函数表只能包含虚函数的地址,不能包含普通函数
5判断题

在C++中,如果基类的析构函数不是虚函数,通过基类指针delete派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。