多态——同一个指令,不同表现
较难16多态:同一个指令,千变万化的行为
你有用过万能遥控器吗?按一下“音量+”键,电视会增大音量,空调会调整风速,音响会变响。都是按同一个按钮,但不同设备用自己的方式来响应。在C++里,这种“同一个接口,多种实现”的特性叫做“多态”。再比如玩游戏时,你按“攻击”键,战士会挥剑,法师会施法,弓箭手会射箭——角色不同,攻击表现完全不同,但触发的指令都是“attack()”。
多态让程序变得更灵活、更易扩展。当你要新增一种“形状”时,只需要写一个新的子类,写出自己的 area() 计算方法,原来调用 printArea() 的代码完全不用修改。这就是面向对象编程中“开闭原则”的体现。
1. 多态的实现条件
要让多态生效,必须同时满足三个条件:
- 继承:子类从基类派生。
- 虚函数:在基类中用关键字
virtual声明函数。 - 基类的指针或引用:通过基类指针或引用来调用那个虚函数。
当满足这些条件时,程序在运行时(而非编译时)会根据对象的实际类型来决定调用哪个函数,这叫动态绑定或迟绑定。反之,如果直接用对象名调用(如 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. 新手易犯的错误
-
忘记在基类加
virtual
如果基类函数没有virtual,子类重写会变成“隐藏”,通过基类指针调用时始终调用基类的版本,没有多态。 -
虚析构函数忘记写
virtual
用delete删除基类指针指向的子类对象时,子类析构函数不会被调用,造成资源泄漏。 -
在构造函数或析构函数中调用虚函数
构造函数和析构函数执行时,对象的虚表指针可能不完整(正在构造或已销毁),调用虚函数不会触发动态绑定,而是调用当前类自己的版本。所以不要在构造/析构中依赖多态。 -
用对象而不是指针/引用调用
Circle c; Shape s = c; s.area()—— 这里s是Shape类型的对象,发生了切片(只复制了基类部分),调用的永远是Shape::area(如果是纯虚函数则直接报错)。要使用引用或指针。 -
重写时函数签名不一致
比如忘记加const,或者参数类型不同。编译器会认为子类定义了一个新函数(隐藏),而不是重写。使用override关键字能直接发现这个问题。
8. 相关知识点指引
- 继承:多态的基础,理解公有继承、保护继承、私有继承的区别。
- 虚函数与虚函数表:深入理解动态绑定的底层实现。
- 抽象类与接口:pure virtual function 的设计,在 C++ 中模拟接口。
- RTTI(运行时类型识别):
dynamic_cast和typeid用于在运行时获取对象的实际类型。 - 设计模式:多态是实现策略模式、模板方法模式等的重要工具。
- 动态内存管理:配合多态使用时,注意用
delete释放,考虑使用智能指针(如unique_ptr)自动管理资源。
掌握多态,你就掌握了面向对象编程的核心“武器”。它让代码能够优雅地应对变化——当新需求来时,只需添加新的子类,原有系统几乎不需要改动。就像万能遥控器不断加入新设备,而遥控器本身还是那个遥控器。
例题精讲
以下哪个选项不是C++中实现多态的必要条件?
在C++中,通过基类指针调用虚函数时,编译器会根据指针的静态类型决定调用哪个函数。
以下代码希望实现多态:通过基类指针调用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;
}关于虚函数表(vtable)的描述,下列哪项是正确的?
在C++中,如果基类的析构函数不是虚函数,通过基类指针delete派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。