C++ 多态
多态(Polymorphism)是 C++ 面向对象编程的重要特性之一,指同一个接口可以根据对象类型表现出不同的行为。
例如,狗和猫都属于动物,它们都有发出声音的行为,但狗会汪汪叫,猫会喵喵叫。
在 C++ 中,可以定义一个动物基类 Animal,再创建 Dog 和 Cat 派生类。通过基类提供的统一接口,就可以调用不同动物各自的行为。
C++ 多态主要分为两种:
- 编译时多态(静态多态):在编译阶段确定调用的函数,例如函数重载、运算符重载和模板。
- 运行时多态(动态多态):通过虚函数和继承机制,在运行时根据对象的实际类型选择函数实现。
本章节主要介绍运行时多态,包括虚函数、动态绑定、纯虚函数、抽象类以及虚析构函数。
多态的基本原理
在 C++ 中,实现运行时多态通常需要满足以下条件:
- 存在基类与派生类的继承关系。
- 基类声明了虚函数(
virtual)。 - 派生类重写了对应的虚函数。
- 通过基类指针或引用调用虚函数。
例如:
Animal* animal = &dog; animal->sound();
虽然 animal 是 Animal* 类型,但它实际指向 Dog 对象,因此调用的是 Dog 类中的 sound()。
这种在运行时根据对象的动态类型选择虚函数实现的机制,称为动态绑定(Dynamic Binding)。
实例 1:动物发声
下面通过一个完整实例,演示如何使用虚函数实现多态。
实例
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
代码分析
1、定义基类
Animal 类定义了虚函数 sound(),允许派生类提供不同的实现。
virtual void sound() const {
cout << "Animal makes a sound" << endl;
}
2、派生类重写虚函数
Dog 和 Cat 都继承自 Animal,并使用 override 重写 sound()。
3、通过基类指针调用函数
当基类指针指向 Dog 对象时,调用 Dog::sound();当它指向 Cat 对象时,调用 Cat::sound()。
因此,即使调用代码相同,程序仍然可以根据对象的实际类型执行不同的函数。
注意:实例使用栈上创建的对象,不需要通过 new 和 delete 手动管理内存。
实例 2:计算不同形状的面积
多态的一个重要用途,是通过统一接口处理不同类型的对象。
例如,矩形和三角形都需要计算面积,但它们的面积计算公式不同。
我们可以定义一个 Shape 基类,提供 area() 接口,再由不同的形状类重写具体计算方法。
实例
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 Function)是在基类中使用 virtual 关键字声明的非静态成员函数。
派生类可以重写虚函数,使程序在通过基类指针或引用调用该函数时,根据对象的动态类型选择对应的实现。
例如:
class Base {
public:
virtual void show() {
// 基类实现
}
};
虚函数具有以下特点:
- 可以在基类中提供默认实现。
- 派生类可以选择是否重写。
- 通过基类指针或引用调用时,可以发生动态绑定。
- 派生类中的对应重写函数仍然是虚函数,即使没有再次写出
virtual。
没有 virtual 会怎样?
如果基类成员函数没有声明为虚函数,那么通过基类指针调用该函数时,通常根据指针的静态类型确定调用哪个版本。
实例
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。
这样可以确保删除派生类对象时,正确执行派生类和基类的析构过程。
实例
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() 改为纯虚函数,使不同的形状类必须提供计算面积的方法,才能实例化对象。
实例
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; |
| 是否允许提供实现 | 允许 | 允许在类外提供定义 |
| 派生类是否必须重写 | 不必须 | 具体派生类必须提供非纯的最终重写版本 |
| 是否使类成为抽象类 | 不会仅因为普通虚函数而成为抽象类 | 若存在未实现的纯虚函数,则属于抽象类 |
| 主要用途 | 提供可重写的行为 | 定义必须由具体派生类实现的接口 |
虚函数表与实现机制
在很多 C++ 编译器中,运行时多态通过虚函数表(Virtual Table,简称 vtable)和虚函数指针(通常称为 vptr)实现。
虚函数表通常保存与虚函数动态分派有关的信息,对象则可能通过内部指针关联相应的虚函数表。
例如:
Animal* animal = &dog; animal->sound();
程序可以根据对象的动态类型找到对应的 sound() 实现。
注意:C++ 标准规定了虚函数调用的行为,但没有强制要求编译器必须使用特定的虚函数表或虚函数指针布局。
另外,普通虚函数动态分派不依赖 RTTI(运行时类型信息)功能。RTTI 主要用于 dynamic_cast、typeid 等运行时类型识别场景。
使用多态的注意事项
在实际 C++ 开发中,使用多态还需要注意以下问题:
- 优先使用 override:在派生类中重写虚函数时添加
override,让编译器检查函数签名。 - 正确设计析构函数:允许通过基类指针删除对象时,基类通常需要公共虚析构函数。
- 避免对象切片:将派生类对象按值赋给基类对象时,派生类特有部分可能被丢弃,通常应使用引用或指针保持多态行为。
- 避免不必要的动态分派:如果不需要运行时多态,可以考虑普通函数、函数重载或模板。
- 谨慎在构造和析构期间调用虚函数:此时不会分派到尚未构造或已经析构的更下层派生类部分。
多态适合不同类型对象需要通过统一接口处理的场景,例如图形系统、游戏对象、插件接口和设备驱动等。
但并不是所有继承关系都需要虚函数。应根据实际需求决定是否使用运行时多态。
总结
C++ 多态使不同类型的对象能够通过统一接口表现出不同的行为。
在运行时多态中,最重要的几个概念包括:
virtual:声明虚函数,使派生类能够重写并支持动态分派。override:显式声明函数重写关系,并由编译器进行检查。final:禁止继续重写虚函数或继承指定类。= 0:声明纯虚函数,使对应类成为抽象类。- 虚析构函数:保证通过适当的基类指针删除派生类对象时能够正确析构。
对于初学者,建议先掌握基类指针、虚函数和函数重写的关系,再进一步学习抽象类、纯虚函数和多态接口设计。
