
多态概述
多态 (Polymorphism)是面向对象程序设计(OOP)的三大核心特性之一(另两个是封装和继承)。
它的字面意思是"多种形态",在 C++ 中,多态允许同一段代码调用,根据对象的实际类型执行不同的操作,从而使程序更具灵活性和可扩展性。
C++ 中的多态主要分为两大类:
- 编译时多态(静态多态) :在编译阶段就确定函数调用地址,主要通过函数重载 、运算符重载 和函数/类模板实现。
- 运行时多态(动态多态) :在程序执行期间(运行时)才确定调用哪个函数,依赖于继承 和虚函数。
编译时多态(静态多态)
静态多态之所以称为"静态",是因为所有决策都在编译期完成,不涉及运行时类型识别。它的优势是调用迅速(无间接寻址开销),并且可以完全内联优化。
函数重载
概念
在同一作用域内,可以定义多个同名函数 ,但要求它们的参数列表不同(参数的类型、个数或顺序不同)。编译器根据调用时传入的实参,在编译期选择最匹配的重载版本。
重要规则
- 仅返回值类型不同不能构成重载。
- 参数类型不同(包括 const / volatile 修饰)可以重载,但顶层 const(修饰参数本身)不影响签名;底层 const(修饰指针指向的对象)会影响重载。
- 重载解析会进行隐式类型转换、默认参数匹配等操作。
示例:数学运算函数的多种重载
cpp
#include <iostream>
#include <string>
class Calculator {
public:
// 重载:处理整数
int add(int a, int b) {
std::cout << "int version" << std::endl;
return a + b;
}
// 重载:处理浮点数
double add(double a, double b) {
std::cout << "double version" << std::endl;
return a + b;
}
// 重载:处理字符串拼接
std::string add(const std::string& a, const std::string& b) {
std::cout << "string version" << std::endl;
return a + b;
}
};
int main() {
Calculator calc;
calc.add(1, 2); // int version
calc.add(1.5, 2.7); // double version
calc.add("Hello, ", "World!"); // string version
}
底层实现
C++ 编译器通过**名字修饰(name mangling)**技术,将函数名与参数类型信息编码成唯一的内部符号。例如 add(int, int) 可能被修饰为 _Z3addii,add(double, double) 修饰为 _Z3adddd,这样链接器就能区分不同的重载版本。
运算符重载
概念
运算符重载允许为自定义类型(如类、结构体)定义运算符的行为,使其像内置类型一样使用运算符(+、-、*、/、<<、[] 等)。
实现方式
- 成员函数形式 :
Complex operator+(const Complex& rhs) const; - 非成员函数形式 (通常声明为友元):
friend Complex operator+(const Complex& lhs, const Complex& rhs);
限制
- 不能创建新的运算符,只能重载 C++ 已有的运算符。
- 不能改变运算符的优先级、结合性和操作数个数。
- 某些运算符不能重载(如
::、.、.*、?:)。 - 重载后不能改变运算符用于内置类型时的原有含义。
示例:重载下标运算符 [] 实现安全的数组访问
cpp
#include <iostream>
#include <stdexcept>
class SafeArray {
private:
int data[10];
public:
SafeArray() {
for (int i = 0; i < 10; ++i) data[i] = i;
}
// 重载 [](读/写)
int& operator[](int index) {
if (index < 0 || index >= 10)
throw std::out_of_range("Index out of range");
return data[index];
}
// 重载 [](只读,用于 const 对象)
const int& operator[](int index) const {
if (index < 0 || index >= 10)
throw std::out_of_range("Index out of range");
return data[index];
}
};
int main() {
SafeArray arr;
std::cout << arr[5] << std::endl; // 5
arr[5] = 100; // 修改
std::cout << arr[5] << std::endl; // 100
// arr[20] = 0; // 抛出异常
}
运算符重载的本质
运算符重载实际上是函数调用的语法糖。c1 + c2 会被编译器解释为 c1.operator+(c2) 或 operator+(c1, c2)。因此它也是静态多态的一种形式。
函数模板(泛型多态)
函数模板是一种泛型编程技术,通过参数化类型,使同一段代码能处理不同类型的数据。它也是一种编译时多态,编译器会根据实参类型生成具体的函数实例。
示例:通用的 max 函数
cpp
template<typename T>
T myMax(T a, T b) {
return (a > b) ? a : b;
}
int main() {
std::cout << myMax(3, 5) << std::endl; // T 推断为 int
std::cout << myMax(3.14, 2.72) << std::endl; // T 推断为 double
std::cout << myMax('a', 'z') << std::endl; // T 推断为 char
}
模板与重载的比较
- 模板可实现类型无关的算法,一次编写,多次实例化。
- 重载则针对不同参数类型分别编写,更适用于差异较大的处理逻辑。
运行时多态(动态多态)
运行时多态是面向对象编程的精髓。它允许你使用基类的指针或引用来操作派生类对象 ,并在运行时根据对象的实际类型调用对应的派生类函数。这一特性依赖于虚函数 和继承。
继承与虚函数
核心要素
- 继承:派生类继承基类的成员(非私有)。
- 虚函数:在基类中使用
virtual关键字声明的成员函数,允许在派生类中重写(override)。 - 基类指针/引用指向派生类对象。
- 通过该指针/引用调用虚函数。
示例:动物叫声的多态
cpp
#include <iostream>
#include <vector>
class Animal {
public:
virtual void speak() const { // 虚函数
std::cout << "Animal makes a sound." << std::endl;
}
virtual ~Animal() {} // 虚析构函数,稍后解释
};
class Dog : public Animal {
public:
void speak() const override { // 重写虚函数(override 可选但推荐)
std::cout << "Dog barks: Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
void speak() const override {
std::cout << "Cat meows: Meow!" << std::endl;
}
};
void makeSound(const Animal& animal) {
animal.speak(); // 运行时多态:实际调用 Dog::speak 或 Cat::speak
}
int main() {
Dog dog;
Cat cat;
makeSound(dog); // Dog barks: Woof!
makeSound(cat); // Cat meows: Meow!
std::vector<Animal*> zoo;
zoo.push_back(new Dog);
zoo.push_back(new Cat);
for (Animal* a : zoo) {
a->speak(); // 输出: Woof! Meow!
delete a;
}
}
重写虚函数的条件
- 派生类中的函数与基类虚函数完全相同(函数名、参数列表、返回类型,协变返回类型除外)。
- 基类函数必须是虚函数(不写
virtual则隐藏而非重写)。 - 使用
override关键字可让编译器检查是否符合重写条件(C++11 起)。
协变返回类型
如果基类虚函数返回某个类(或指针/引用),而派生类重写函数返回该类的派生类(或指针/引用),则允许返回类型不同,称为协变。
cpp
class Base { public: virtual Base* clone() { return new Base(*this); } };
class Derived : public Base {
public:
virtual Derived* clone() override { return new Derived(*this); }
};
纯虚函数与抽象类
纯虚函数 :在基类中用 = 0 声明的虚函数,如 virtual void draw() = 0;。
抽象类 :含有纯虚函数的类,无法实例化,只能作为接口或基类使用。
作用:定义规范,强制派生类实现该函数。
示例:图形接口
cpp
class Shape {
public:
virtual double area() const = 0; // 纯虚函数
virtual ~Shape() {}
};
class Circle : public Shape {
double r;
public:
Circle(double radius) : r(radius) {}
double area() const override {
return 3.14159 * r * r;
}
};
class Rectangle : public Shape {
double w, h;
public:
Rectangle(double w, double h) : w(w), h(h) {}
double area() const override {
return w * h;
}
};
void printArea(const Shape& s) {
std::cout << "Area: " << s.area() << std::endl;
}
虚析构函数
当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,派生类的资源将无法释放,导致内存泄漏。因此,只要一个类有可能作为基类被继承,就应该将析构函数声明为虚函数。
反例(未定义虚析构函数):
cpp
class Base { ~Base() {} };
class Derived : public Base {
int* p;
public:
Derived() { p = new int[100]; }
~Derived() { delete[] p; }
};
Base* b = new Derived;
delete b; // 仅调用 Base::~Base,Derived 的资源未被释放!
正确做法 :给基类添加 virtual ~Base() = default;。
底层原理:虚函数表(vtable)与虚指针(vptr)
运行时多态的实现依赖于 C++ 编译器为每个包含虚函数的类建立的虚函数表 以及每个对象内部的虚指针。
虚函数表(vtable)
- 是一个存储函数指针的数组(通常放在只读数据段)。
- 每个包含虚函数的类都有一个自己的虚函数表,表中按声明顺序存放该类所有虚函数的地址。
- 派生类如果重写了某个虚函数,则虚函数表中对应条目会被替换为派生类的函数地址;如果没有重写,则保留基类的地址。
- 通常虚函数表末尾带有一个标记(如 RTTI 相关信息)。
虚指针(vptr)
- 每个对象实例在内存开头(通常是前8/4个字节)会有一个隐藏的指针,指向所属类的虚函数表。
- 当通过基类指针调用虚函数时,程序会:
- 通过对象的 vptr 找到虚函数表。
- 从虚函数表中取出对应槽位的函数指针。
- 间接调用该函数。
单继承下的对象内存布局示例(32位系统):
class Base {
public:
virtual void f();
virtual void g();
int a;
};
class Derived : public Base {
public:
virtual void f() override; // 重写 f
virtual void h(); // 新增虚函数
int b;
};
内存布局(以 Visual C++ 风格为例,其他编译器类似但可能不同):
Derived 对象:
+--------+
| vptr | --> 指向 Derived vtable
+--------+
| a | (Base::a)
+--------+
| b | (Derived::b)
+--------+
Derived vtable:
+-----------------------+
| &Derived::f | (覆盖 Base::f)
+-----------------------+
| &Base::g | (未重写,指向基类函数)
+-----------------------+
| &Derived::h | (新增虚函数,扩展表)
+-----------------------+
| ... (RTTI) |
+-----------------------+
构造函数与析构函数中的虚函数
在构造和析构期间,对象的 vptr 会随着构造/析构的进行而改变,指向当前正在构造/析构的类的虚函数表。此时通过对象调用的虚函数不会表现多态,而是调用当前构造函数/析构函数所属类中的版本。这是需要特别注意的陷阱。
cpp
class Base {
public:
Base() { f(); } // 构造时调用 Base::f,不会调用派生类重写版本
virtual void f() { std::cout << "Base::f\n"; }
};
class Derived : public Base {
public:
Derived() { f(); } // 此时 vptr 已指向 Derived 表,调用 Derived::f
void f() override { std::cout << "Derived::f\n"; }
};
int main() {
Derived d;
// 输出:Base::f Derived::f
}
多继承下的虚函数表
当类从多个基类继承,且这些基类都有虚函数时,情况变得复杂。派生类对象会包含多个虚指针 ,分别指向对应的基类子对象的虚函数表(或者指向一个整合的表,具体取决于编译器实现)。同时,可能还会产生调整 this 指针 的操作,以确保不同基类视角下能正确访问成员。这部分原理较为深奥,但我们可以记住一个简单结论:慎用多继承,尤其在涉及虚函数时,推荐优先使用单一继承加接口类。
动态多态的性能与适用场景
优点
- 代码可扩展性强,符合开闭原则(对扩展开放,对修改关闭)。
- 非常适合框架设计、插件系统、以及需要通用接口的场景。
代价
- 一次虚函数调用相当于:两次内存读取(vptr + 函数指针)+ 一次间接调用,相比普通函数调用有微小但可测量的开销。
- vptr 占用对象额外内存(4/8 字节)。
- 虚函数通常不能内联,因为调用地址在编译时未知。
- 虚函数表占据静态存储区。
适用建议
- 如果性能敏感且调用频繁,考虑静态多态(CRTP 模式)或模板。
- 如果层次清晰、扩展性更重要,动态多态是合适的选择。
C++11 特性:override 与 final
C++11 引入了两个上下文关键字,极大地改善了虚函数重写的代码安全性和可读性。
override 说明符
作用 :显式标记派生类中的虚函数是对基类虚函数的重写。
好处:
- 增加代码可读性,明确告知读者这是一个重写函数。
- 让编译器进行签名检查:若基类中没有相同签名的虚函数,或签名不匹配(如参数类型、const 限定、协变不符),编译器报错,避免因拼写错误导致意外隐藏而非重写。
错误示例(不加 override 可能隐藏错误):
cpp
class Base {
public:
virtual void show(int x) {}
};
class Derived : public Base {
public:
void show(double x) {} // 不同参数类型,并未重写,而是隐藏基类的 show
};
int main() {
Derived d;
Base* p = &d;
p->show(5); // 调用 Base::show(int),不是多态!
}
使用 override 暴露问题:
cpp
class Derived : public Base {
public:
void show(double x) override {} // 编译错误:没有基类虚函数 void show(double)
};
final 说明符
作用:
- 用于虚函数:阻止派生类继续重写该虚函数。
- 用于类:阻止该类被继承。
示例:禁止重写
cpp
class Base {
public:
virtual void keyFunction() final; // 派生类不可重写 keyFunction
};
class Derived : public Base {
void keyFunction() override; // 错误:无法重写 final 函数
};
示例:禁止继承
cpp
class Sealed final { /* ... */ };
class InheritFromSealed : public Sealed { }; // 错误:Sealed 是 final,不可继承
final 和 override 可以同时使用(void func() override final;),表示这是一个重写函数,并且禁止后续重写。
多态的设计思想与应用
多态并不仅仅是一种语法特性,更是一种程序设计方法论。它体现了以下核心设计原则:
-
针对接口编程,而非针对实现编程
多态使得我们可以依赖抽象的基类(接口),而不是具体的派生类。客户代码只需操作抽象基类,增加新类型时无需修改原有代码。
-
开闭原则(Open-Closed Principle)
对扩展开放,对修改关闭。通过多态,新增功能只需添加新的派生类,而不用改动已存在的稳定代码。
-
依赖倒置原则
高层模块不依赖低层模块的具体实现,二者都依赖抽象。多态是实现依赖注入、控制反转的基础。
经典应用场景
- 图形渲染系统 :
Shape抽象基类,派生Circle、Rectangle、Triangle,渲染器统一调用draw()。 - 策略模式:定义一系列算法,每个算法封装在派生类中,通过多态使算法可相互替换。
- 工厂方法模式:通过基类指针创建具体产品对象,实现创建逻辑与使用的解耦。
- 访问者模式:借助双重分发(多态)处理不同元素类型的操作。
多态与继承的组合
在实际工程中,应优先使用组合而非继承。当确实需要"is-a"关系和接口统一时,才使用继承和多态。过度使用继承会导致脆弱的基类问题(Fragile Base Class Problem)。