一、什么是多态?
1.1 先从现实世界理解多态
"多态"这个词本身并不复杂:
同一种操作,作用于不同对象,可以产生不同的行为。
例如,我们有一个"动物发声"的动作。
狗发声:
汪汪汪
猫发声:
喵喵喵
鸟发声:
叽叽喳喳
虽然执行的都是:
speak()
但是不同动物执行后产生的结果不同。
这就是多态最直观的思想:
同一个接口,不同的实现。
映射到C++:
Animal* animal = new Dog;
animal->speak();
虽然 animal 的类型是:
Animal*
但实际指向的是:
Dog
最终执行:
Dog::speak()
这就是C++运行时多态的核心。
二、为什么需要多态?
假设我们没有多态。
现在有:
class Dog
{
public:
void speak()
{
std::cout << "Dog: wang wang" << std::endl;
}
};
class Cat
{
public:
void speak()
{
std::cout << "Cat: miao miao" << std::endl;
}
};
如果需要让不同动物发声,我们可能写:
if (type == DOG)
{
dog.speak();
}
else if (type == CAT)
{
cat.speak();
}
else if (type == BIRD)
{
bird.speak();
}
动物越来越多:
Dog
Cat
Bird
Tiger
Lion
Horse
...
if-else 就会越来越长。
更麻烦的是:
每增加一种动物,都可能需要修改原来的业务代码。
而多态可以把问题转换成:
Animal* animal = ...;
animal->speak();
至于到底调用:
Dog::speak()
Cat::speak()
Bird::speak()
由对象自身决定。
这就是多态非常重要的工程价值:
调用方只依赖抽象接口,而不需要关心具体实现。
三、C++中的多态有哪些形式?
在C++学习和工程语境中,通常可以把多态分为:
C++多态
│
├── 静态多态
│ ├── 函数重载
│ └── 模板
│
└── 动态多态
├── 继承
├── virtual
└── 父类指针/引用
需要注意:
不同教材对于"静态多态"的具体分类可能存在差异。函数重载、模板等通常被归入编译期多态,而基于虚函数的继承体系通常称为运行时多态或动态多态。
四、静态多态
4.1 函数重载
例如:
#include <iostream>
void print(int x)
{
std::cout << "int: " << x << std::endl;
}
void print(double x)
{
std::cout << "double: " << x << std::endl;
}
void print(const char* str)
{
std::cout << "string: " << str << std::endl;
}
int main()
{
print(10);
print(3.14);
print("hello");
return 0;
}
预期输出:
int: 10
double: 3.14
string: hello
为什么能够调用不同函数?
因为编译器在编译阶段就可以根据参数类型确定:
print(10);
对应:
print(int)
而:
print(3.14);
对应:
print(double)
也就是说:
函数重载的选择主要发生在编译阶段。
因此通常将其归入静态多态。
五、模板与静态多态
模板也是典型的编译期泛型机制。
例如:
#include <iostream>
template <typename T>
void print(T value)
{
std::cout << value << std::endl;
}
int main()
{
print(10);
print(3.14);
print("hello");
return 0;
}
这里:
print(10);
编译器可以推导:
T = int
而:
print(3.14);
可以推导:
T = double
模板的核心思想是:
让代码适用于多种类型,同时尽可能在编译期完成类型相关的决策。
六、静态多态与动态多态的核心区别
| 对比项 | 静态多态 | 动态多态 |
|---|---|---|
| 决策时间 | 编译期 | 运行期 |
| 常见方式 | 重载、模板 | 继承 + virtual |
| 是否依赖继承 | 通常不需要 | 通常需要 |
| 是否需要虚函数 | 不需要 | 需要 |
| 典型特点 | 编译期确定 | 运行时决定 |
| 典型优势 | 编译期优化、泛型 | 灵活、扩展性强 |
一句话记忆:
静态多态:编译的时候决定。
动态多态:运行的时候决定。
七、动态多态
现在进入C++多态最核心的部分。
先看经典案例:
#include <iostream>
class Animal
{
public:
virtual void speak()
{
std::cout << "Animal speak" << std::endl;
}
};
class Dog : public Animal
{
public:
void speak() override
{
std::cout << "Dog wang wang" << std::endl;
}
};
class Cat : public Animal
{
public:
void speak() override
{
std::cout << "Cat miao miao" << std::endl;
}
};
现在:
Animal* p1 = new Dog;
Animal* p2 = new Cat;
p1->speak();
p2->speak();
delete p1;
delete p2;
如果 speak() 是虚函数,调用结果应当表现为:
Dog wang wang
Cat miao miao
这里出现了一个非常重要的问题:
p1明明是Animal*,为什么最终执行的是Dog::speak()?
这就是动态多态。
八、理解静态类型与动态类型
这是理解动态多态必须掌握的概念。
看:
Animal* p = new Dog;
这里有两个不同的信息。
8.1 静态类型
变量 p 声明的时候写的是:
Animal* p;
所以:
p的静态类型 = Animal*
也可以理解成:
编译器在分析代码时看到的类型。
8.2 动态类型
但是:
new Dog
创建出来的实际对象是:
Dog对象
所以:
p的动态类型所对应的实际对象 = Dog
因此:
Animal* p = new Dog;
可以理解成:
指针的静态类型:Animal*
↓
指向
↓
实际对象类型:Dog
这正是动态多态发挥作用的地方。
九、父类指针为什么可以指向子类对象?
因为C++支持继承体系中的向上转型。
例如:
class Animal
{
};
class Dog : public Animal
{
};
那么:
Dog dog;
Animal* p = &dog;
是合法的。
原因是:
Dog是一种Animal。
也就是说:
Dog is an Animal
所以:
Animal* p = &dog;
可以理解成:
使用父类接口来观察一个子类对象。
这也是面向对象多态的重要基础。
十、为什么必须使用virtual?
看一个反例。
#include <iostream>
class Animal
{
public:
void speak()
{
std::cout << "Animal speak" << std::endl;
}
};
class Dog : public Animal
{
public:
void speak()
{
std::cout << "Dog wang wang" << std::endl;
}
};
int main()
{
Dog dog;
Animal* p = &dog;
p->speak();
return 0;
}
预期输出:
Animal speak
为什么?
因为这里:
Animal* p
是 Animal*。
而 speak() 不是虚函数。
因此调用决策主要依据静态类型:
Animal*
↓
Animal::speak()
现在加入:
virtual
变成:
class Animal
{
public:
virtual void speak()
{
std::cout << "Animal speak" << std::endl;
}
};
再运行:
Animal* p = &dog;
p->speak();
预期输出:
Dog wang wang
这就是:
动态绑定。
十一、静态绑定与动态绑定
11.1 静态绑定
普通函数:
class Base
{
public:
void func()
{
std::cout << "Base" << std::endl;
}
};
调用:
Base* p = ...;
p->func();
函数调用关系在编译阶段就可以确定。
11.2 动态绑定
虚函数:
class Base
{
public:
virtual void func()
{
std::cout << "Base" << std::endl;
}
};
如果:
Derived d;
Base* p = &d;
p->func();
运行时可以根据实际对象决定调用:
Derived::func()
因此:
静态绑定
↓
编译阶段决定
动态绑定
↓
运行阶段根据实际对象决定
十二、动态多态成立的条件
要形成典型的C++动态多态,通常需要以下条件。
条件一:存在继承关系
例如:
class Derived : public Base
{
};
条件二:父类成员函数是virtual
class Base
{
public:
virtual void func();
};
条件三:子类正确重写
class Derived : public Base
{
public:
void func() override;
};
条件四:通过父类指针或引用调用
例如:
Derived d;
Base* ptr = &d;
ptr->func();
或者:
Base& ref = d;
ref.func();
十三、逐个破坏条件,看看会发生什么
13.1 去掉virtual
class Base
{
public:
void func()
{
std::cout << "Base" << std::endl;
}
};
此时不会形成典型的虚函数动态分派。
13.2 函数签名错误
父类:
class Base
{
public:
virtual void func(int)
{
std::cout << "Base" << std::endl;
}
};
子类:
class Derived : public Base
{
public:
void func(double)
{
std::cout << "Derived" << std::endl;
}
};
这里:
func(int)
和:
func(double)
并不是重写关系。
它们参数列表不同。
实际上子类定义了一个新的函数,并可能隐藏父类同名函数。
这也是为什么现代C++强烈推荐:
override
13.3 直接使用对象
例如:
Dog dog;
dog.speak();
这里根本不需要运行时多态。
因为对象本身已经是明确的:
Dog
编译器知道调用:
Dog::speak()
十四、重载、重写、隐藏
这是C++学习中的高频易错点。
| 概念 | 英文 | 发生位置 | 核心特点 |
|---|---|---|---|
| 重载 | Overload | 通常同一作用域 | 函数名相同、参数列表不同 |
| 重写 | Override | 父子类 | 子类重新实现虚函数 |
| 隐藏 | Hiding | 父子类 | 子类同名成员可能隐藏父类同名成员 |
十五、什么是重载?
void func(int x)
{
}
void func(double x)
{
}
它们:
函数名相同
参数列表不同
属于重载。
例如:
func(10);
func(3.14);
编译器根据参数类型选择对应函数。
十六、什么是重写?
父类:
class Base
{
public:
virtual void func()
{
std::cout << "Base" << std::endl;
}
};
子类:
class Derived : public Base
{
public:
void func() override
{
std::cout << "Derived" << std::endl;
}
};
这里:
Derived::func()
重写了:
Base::func()
关键条件包括:
-
基类函数是虚函数;
-
派生类函数与基类对应虚函数具有匹配的函数类型;
-
返回类型等也必须满足重写规则;
-
使用
override可以让编译器帮助检查。
十七、什么是隐藏?
看:
class Base
{
public:
void func(int)
{
std::cout << "Base int" << std::endl;
}
};
class Derived : public Base
{
public:
void func(double)
{
std::cout << "Derived double" << std::endl;
}
};
这里不是重写。
因为:
func(int)
和:
func(double)
参数不同。
但在派生类作用域中:
Derived::func
可能会隐藏基类同名的 func。
如果希望在派生类作用域重新引入基类重载,可以使用:
using Base::func;
例如:
class Derived : public Base
{
public:
using Base::func;
void func(double)
{
std::cout << "Derived double" << std::endl;
}
};
这样:
Derived d;
d.func(10);
d.func(3.14);
就可以分别找到相应的函数。
十八、一定要记住:重载、重写、隐藏不是一回事
可以这样记忆:
重载
↓
同名
不同参数
主要发生在同一作用域
重写
↓
父子类
虚函数
重新实现
隐藏
↓
父子类
同名成员
子类声明可能遮蔽父类成员
面试时千万不要把:
void func(int);
void func(double);
说成"重写"。
它们是:
重载。
十九、vtable与vptr
理解到这里,我们知道:
Animal* p = new Dog;
p->speak();
可以根据实际对象调用:
Dog::speak()
那么问题来了:
C++到底是怎么做到的?
这里就需要理解虚函数表和虚函数指针。
二十、先强调一个重要事实
必须先明确:
C++标准没有规定虚函数必须通过vtable和vptr实现。
也就是说:
vtable
vptr
属于主流C++编译器常见的实现机制,而不是C++语言标准强制规定的对象内部结构。
不同:
-
编译器;
-
ABI;
-
平台;
-
编译选项;
可能存在不同实现方式。
因此学习vtable时应该理解:
这是理解主流C++实现动态多态的一种模型,而不是C++标准规定的唯一实现。
二十一、什么是vtable?
可以把虚函数表简单理解成:
记录虚函数相关调用目标的一张表。
例如:
class Animal
{
public:
virtual void speak()
{
std::cout << "Animal" << std::endl;
}
};
class Dog : public Animal
{
public:
void speak() override
{
std::cout << "Dog" << std::endl;
}
};
可以用非常简化的模型理解:
Animal虚函数表
┌────────────────────┐
│ Animal::speak() │
└────────────────────┘
Dog对应的虚函数表可以理解成:
Dog虚函数表
┌────────────────────┐
│ Dog::speak() │
└────────────────────┘
二十二、什么是vptr?
如果某个对象属于具有虚函数的多态类体系,主流实现通常会在对象中存在某种用于找到虚函数相关信息的指针,常用术语称为:
vptr
可以简化理解为:
Dog对象
┌────────────────────┐
│ vptr ───────────────┼────→ Dog虚函数表
│ Dog的数据成员 │ │
│ ... │ ├── Dog::speak()
└────────────────────┘ └── ...
于是:
Animal* p = new Dog;
p->speak();
可以用非常简化的模型理解为:
p
↓
Dog对象
↓
找到虚函数相关表
↓
找到Dog::speak()
↓
调用
注意:
上述流程是帮助理解主流实现机制的简化模型,不应当理解为C++标准规定的具体机器级执行步骤。
二十三、vtable到底解决了什么问题?
核心就是:
运行时找到正确的函数实现。
假设:
Animal* p;
但是实际对象可能是:
Dog
Cat
Bird
运行时根据对象的实际类型找到对应实现:
Animal*
│
├── Dog对象 → Dog::speak()
│
├── Cat对象 → Cat::speak()
│
└── Bird对象 → Bird::speak()
这就是动态分派。
二十四、虚析构函数
这是多态学习中非常重要的内容。
先看:
class Base
{
public:
~Base()
{
std::cout << "Base destructor" << std::endl;
}
};
class Derived : public Base
{
public:
~Derived()
{
std::cout << "Derived destructor" << std::endl;
}
};
现在:
Base* ptr = new Derived;
delete ptr;
如果基类析构函数不是虚函数,就不能保证通过基类指针删除派生类对象时按多态方式正确调用派生类析构函数。
二十五、正确的多态基类析构函数
通常应该设计成:
class Base
{
public:
virtual ~Base()
{
std::cout << "Base destructor" << std::endl;
}
};
派生类:
class Derived : public Base
{
public:
~Derived() override
{
std::cout << "Derived destructor" << std::endl;
}
};
然后:
Base* ptr = new Derived;
delete ptr;
典型析构顺序为:
Derived destructor
Base destructor
这符合对象销毁的继承层次顺序:
先销毁派生类部分,再销毁基类部分。
二十六、为什么多态基类通常需要虚析构?
因为:
Base* ptr = new Derived;
意味着:
指针类型:Base*
实际对象:Derived
如果这个对象最终需要:
delete ptr;
那么程序需要能够正确销毁整个派生类对象。
因此,一个类如果:
可能通过基类指针删除派生类对象
那么基类析构函数通常应该设计为:
virtual ~Base() = default;
但是不要机械地理解为:
"所有基类析构函数无条件必须virtual。"
实际设计仍然应该结合类的使用方式和生命周期管理策略。
二十七、现代C++中的虚析构
推荐:
class Shape
{
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};
这样:
std::unique_ptr<Shape>
通过基类管理派生类对象时,可以正确进行多态销毁。
二十八、override
override 是现代C++非常重要的关键字。
例如:
class Base
{
public:
virtual void func(int)
{
}
};
class Derived : public Base
{
public:
void func(int) override
{
}
};
这里:
override
告诉编译器:
"我明确希望这个函数重写基类虚函数,请帮我检查。"
二十九、override可以避免什么错误?
例如:
class Base
{
public:
virtual void func(int)
{
}
};
class Derived : public Base
{
public:
void func(double) override
{
}
};
这里会产生编译错误。
因为:
Base::func(int)
与:
Derived::func(double)
并不是正确的重写关系。
如果没有 override:
void func(double)
程序可能可以编译,但你以为自己重写了函数,实际上并没有。
所以现代C++非常推荐:
只要明确是在重写虚函数,就使用
override。
三十、final
final 可以用于虚函数:
class Base
{
public:
virtual void func() final
{
}
};
表示:
派生类不能继续重写这个虚函数。
也可以用于类:
class Base final
{
};
表示:
这个类不能继续被继承。
例如:
class Dog final : public Animal
{
};
那么:
class BigDog : public Dog
{
};
将无法通过编译。
三十一、纯虚函数
纯虚函数:
virtual void draw() = 0;
例如:
class Shape
{
public:
virtual void draw() = 0;
};
这里:
= 0
表示这是一个纯虚函数。
它表达的是一种接口设计思想:
Shape知道"应该能够draw",但是不规定具体Shape如何draw。
不同图形自己实现:
class Circle : public Shape
{
public:
void draw() override
{
std::cout << "Draw Circle" << std::endl;
}
};
三十二、什么是抽象类?
包含至少一个纯虚函数的类通常称为抽象类。
例如:
class Shape
{
public:
virtual void draw() = 0;
};
不能直接:
Shape shape;
因为 Shape 是抽象类。
但是可以:
Circle circle;
Shape* ptr = &circle;
ptr->draw();
这正是抽象接口的意义:
Shape
↑
│
├── Circle
├── Rectangle
└── Triangle
调用方只需要知道:
Shape
而不需要知道具体实现。
三十三、抽象类与普通父类有什么区别?
普通父类:
class Animal
{
public:
virtual void speak()
{
std::cout << "Animal" << std::endl;
}
};
可以:
Animal a;
而抽象类:
class Animal
{
public:
virtual void speak() = 0;
};
不能直接创建:
Animal a; // 错误
抽象类更强调:
定义接口,而不是直接提供完整对象。
三十四、对象切片
这是C++多态中非常重要,同时又非常容易被忽略的知识。
看:
class Base
{
public:
virtual void func()
{
std::cout << "Base" << std::endl;
}
};
class Derived : public Base
{
public:
void func() override
{
std::cout << "Derived" << std::endl;
}
};
现在:
Derived d;
Base b = d;
发生了什么?
这里不是:
b指向d
而是:
把Derived对象中的Base部分复制到一个新的Base对象中。
因此:
b.func();
调用的是:
Base::func()
三十五、为什么叫对象切片?
可以想象:
Derived对象
┌──────────────────┐
│ Base部分 │
├──────────────────┤
│ Derived新增部分 │
└──────────────────┘
赋值:
Base b = d;
相当于只保留:
┌──────────────────┐
│ Base部分 │
└──────────────────┘
派生类新增的部分被"切掉"了。
因此称为:
Object Slicing,对象切片。
三十六、对象、引用、指针三种情况
假设:
Derived d;
第一种:对象
Base b = d;
发生对象切片。
第二种:引用
Base& ref = d;
不会创建新的Base对象。
而是:
ref
↓
Derived对象
如果函数是虚函数:
ref.func();
可以进行动态绑定。
第三种:指针
Base* ptr = &d;
同样不会复制对象。
只是:
Base*
↓
Derived对象
因此:
ptr->func();
可以触发动态多态。
三十七、三者核心对比
| 写法 | 是否产生新的Base对象 | 是否可能对象切片 | 是否支持动态多态 |
|---|---|---|---|
Base b = d |
是 | 是 | 否 |
Base& ref = d |
否 | 否 | 是 |
Base* ptr = &d |
否 | 否 | 是 |
所以理解多态时一定要记住:
多态通常依赖父类引用或父类指针,而不是把派生类对象直接复制成父类对象。
三十八、综合案例一:动物系统
#include <iostream>
#include <memory>
#include <vector>
class Animal
{
public:
virtual ~Animal() = default;
virtual void speak() const = 0;
};
class Dog : public Animal
{
public:
void speak() const override
{
std::cout << "Dog: wang wang" << std::endl;
}
};
class Cat : public Animal
{
public:
void speak() const override
{
std::cout << "Cat: miao miao" << std::endl;
}
};
int main()
{
std::vector<std::unique_ptr<Animal>> animals;
animals.push_back(std::make_unique<Dog>());
animals.push_back(std::make_unique<Cat>());
for (const auto& animal : animals)
{
animal->speak();
}
return 0;
}
预期输出:
Dog: wang wang
Cat: miao miao
这个案例体现了完整的现代C++多态结构:
Animal
↑
├── Dog
└── Cat
调用方:
animal->speak();
不需要:
if (type == Dog)
也不需要:
if (type == Cat)
新增:
class Bird : public Animal
{
public:
void speak() const override
{
std::cout << "Bird: chirp" << std::endl;
}
};
只需要把Bird放进容器即可。
这就是多态带来的扩展性。
三十九、案例二:图形系统
假设我们设计一个图形绘制系统:
Shape
├── Circle
├── Rectangle
└── Triangle
代码:
#include <iostream>
#include <memory>
#include <vector>
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
};
class Circle : public Shape
{
public:
void draw() const override
{
std::cout << "Draw Circle" << std::endl;
}
};
class Rectangle : public Shape
{
public:
void draw() const override
{
std::cout << "Draw Rectangle" << std::endl;
}
};
class Triangle : public Shape
{
public:
void draw() const override
{
std::cout << "Draw Triangle" << std::endl;
}
};
int main()
{
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Rectangle>());
shapes.push_back(std::make_unique<Triangle>());
for (const auto& shape : shapes)
{
shape->draw();
}
return 0;
}
预期输出:
Draw Circle
Draw Rectangle
Draw Triangle
注意这里:
std::vector<std::unique_ptr<Shape>>
保存的是:
一组不同的Shape对象。
它们实际可能分别是:
Circle
Rectangle
Triangle
但是调用方只需要:
shape->draw();
四十、为什么这里推荐unique_ptr?
传统写法可能是:
std::vector<Shape*> shapes;
然后:
shapes.push_back(new Circle);
shapes.push_back(new Rectangle);
最后还需要:
for (auto ptr : shapes)
{
delete ptr;
}
容易出现:
-
忘记delete;
-
异常情况下资源没有正确释放;
-
所有权关系不清晰。
现代C++更推荐:
std::unique_ptr<Shape>
配合:
std::make_unique
这样对象生命周期由RAII自动管理。
四十一、为什么不是shared_ptr?
如果对象只有一个明确所有者:
std::unique_ptr
通常更合适。
例如:
std::vector<std::unique_ptr<Shape>>
表达的是:
这个容器拥有这些Shape对象。
而:
std::shared_ptr
表达的是:
多个地方可能共同拥有这个对象。
因此不要因为"现代C++"就无脑使用:
shared_ptr
应该根据所有权关系选择。
四十二、案例三:支付系统
现在进入更接近真实工程的案例。
我们设计:
Payment
├── Alipay
├── WeChatPay
└── BankCard
定义抽象接口:
class Payment
{
public:
virtual ~Payment() = default;
virtual void pay(double money) = 0;
};
支付宝:
class Alipay : public Payment
{
public:
void pay(double money) override
{
std::cout << "Alipay: " << money << std::endl;
}
};
微信:
class WeChatPay : public Payment
{
public:
void pay(double money) override
{
std::cout << "WeChatPay: " << money << std::endl;
}
};
银行卡:
class BankCard : public Payment
{
public:
void pay(double money) override
{
std::cout << "BankCard: " << money << std::endl;
}
};
业务代码:
void processPayment(Payment& payment)
{
payment.pay(100.0);
}
调用:
Alipay alipay;
WeChatPay wechat;
BankCard card;
processPayment(alipay);
processPayment(wechat);
processPayment(card);
预期输出:
Alipay: 100
WeChatPay: 100
BankCard: 100
注意:
processPayment()
根本不需要知道具体支付方式。
它只知道:
Payment
这就是:
面向抽象编程,而不是面向具体实现编程。
四十三、如果没有多态会怎么样?
没有多态,我们可能写:
if (type == ALIPAY)
{
alipay.pay();
}
else if (type == WECHAT)
{
wechat.pay();
}
else if (type == BANK)
{
card.pay();
}
以后增加:
ApplePay
PayPal
某银行支付
数字钱包
就继续修改:
if
else if
else if
这会导致业务代码与具体实现高度耦合。
而多态让业务代码只依赖:
Payment
新增支付方式时:
class ApplePay : public Payment
{
public:
void pay(double money) override
{
// ApplePay实现
}
};
原有:
processPayment()
甚至不需要修改。
这就是经典的:
对扩展开放,对修改相对封闭。
四十四、多态的工程价值
多态并不是为了炫技。
真正重要的是:
1. 降低耦合
业务代码依赖:
抽象接口
而不是:
具体类
2. 提高扩展性
新增实现:
新增派生类
而不是修改大量旧代码。
3. 提高可维护性
代码职责更加清晰。
4. 支持插件式设计
很多大型系统可以把不同功能实现设计成统一接口。
例如:
Plugin
├── PluginA
├── PluginB
└── PluginC
系统只调用:
plugin->run();
四十五、多态与Linux/C++工程
在Linux/C++工程中,多态可以用于:
网络框架
日志系统
数据库驱动
文件系统抽象
插件系统
设备抽象
消息处理
协议处理
任务调度
GUI框架
游戏引擎
中间件
例如网络协议:
Protocol
├── HTTP
├── WebSocket
├── TCP
└── UDP
可以设计:
class Protocol
{
public:
virtual ~Protocol() = default;
virtual void process() = 0;
};
业务代码只需要:
Protocol& protocol
而不必大量判断:
if HTTP
else if WebSocket
else if TCP
四十六、多态与性能
有人看到虚函数之后会问:
"虚函数是不是很慢?"
这个说法过于绝对。
确实,动态多态通常可能涉及:
间接调用。
相比某些可以直接确定目标函数的调用,可能增加一定运行时成本。
此外,多态对象的实现通常可能涉及额外的对象布局信息。
但不能简单说:
"用了virtual,程序就会很慢。"
原因包括:
第一,现代编译器可以优化
在某些场景下,编译器能够进行:
-
内联;
-
去虚拟化;
-
分支预测相关优化;
-
其他优化。
具体效果取决于代码结构、编译器、优化选项和运行环境。
第二,真正的性能瓶颈往往不在虚函数
大型程序中:
网络IO
磁盘IO
缓存未命中
锁竞争
内存分配
算法复杂度
数据库访问
往往比一次虚函数调用更加值得关注。
所以工程上应该遵循:
先保证正确性、可维护性和设计合理性,再通过性能分析定位真正的瓶颈。
不要因为"虚函数可能有开销"就完全拒绝多态。
四十七、并不是所有地方都应该使用多态
多态很有价值,但并不是:
"用了继承+virtual就是高级C++。"
如果一个场景非常简单:
class Dog
{
public:
void speak();
};
直接:
Dog dog;
dog.speak();
完全没有必要为了多态增加:
基类
虚函数
派生类
vtable
智能指针
复杂度。
所以设计时要问:
这里是否真的需要运行时替换不同实现?
如果不需要,就不要为了使用多态而使用多态。
四十八、多态的常见错误
错误一:忘记virtual
class Base
{
public:
void func();
};
然后期待:
Base* p = new Derived;
p->func();
自动调用:
Derived::func();
这是错误理解。
错误二:子类函数签名写错
class Base
{
public:
virtual void func(int);
};
class Derived : public Base
{
public:
void func(double);
};
不是重写。
解决办法:
void func(double) override;
让编译器帮你检查。
错误三:忘记虚析构
如果存在:
Base* ptr = new Derived;
delete ptr;
基类析构函数需要根据设计考虑是否应该是virtual。
对于典型多态基类:
virtual ~Base() = default;
通常是合理设计。
错误四:对象切片
错误地写:
Base base = derived;
可能发生对象切片。
如果目的是使用多态,通常使用:
Base& ref = derived;
或者:
Base* ptr = &derived;
错误五:混淆重载和重写
void func(int);
void func(double);
这是:
重载。
而:
virtual void func();
父类和子类重新实现同一虚函数:
重写。
错误六:认为所有成员函数都是动态绑定
不是。
只有满足虚函数机制的调用场景才能形成典型的运行时动态分派。
普通成员函数并不会因为:
"它属于一个继承体系"
就自动变成动态绑定。
错误七:把vtable当成C++标准规定的东西
错误:
"C++标准规定每个含virtual的类都有vtable。"
正确:
C++标准规定的是语言语义,而不是要求必须使用vtable/vptr实现。主流编译器通常采用类似机制。
错误八:滥用动态内存
以前常见:
Base* ptr = new Derived;
...
delete ptr;
现代C++更推荐根据所有权关系使用:
std::unique_ptr
或者在确实存在共享所有权时考虑:
std::shared_ptr
四十九、一个非常重要的设计原则
学习多态之后,不要只记住:
virtual
更应该记住:
面向抽象编程,而不是面向具体实现编程。
例如:
错误的思维:
业务系统
↓
Alipay
更好的设计:
业务系统
↓
Payment接口
↓
├── Alipay
├── WeChatPay
└── BankCard
业务系统只依赖:
Payment
具体支付方式成为可以替换的实现。
五十、多态与"依赖具体实现"的区别
假设:
void process(Alipay& payment)
{
payment.pay();
}
这里:
process
↓
Alipay
强耦合。
如果:
void process(Payment& payment)
{
payment.pay();
}
那么:
process
↓
Payment
↑
├── Alipay
├── WeChatPay
└── BankCard
这样:
process(alipay);
process(wechat);
process(card);
都可以工作。
这就是抽象带来的价值。
五十一、综合案例:一个简单的支付框架
下面把前面的知识整合起来。
#include <iostream>
#include <memory>
#include <vector>
#include <string>
class Payment
{
public:
virtual ~Payment() = default;
virtual std::string name() const = 0;
virtual void pay(double money) const = 0;
};
class Alipay : public Payment
{
public:
std::string name() const override
{
return "Alipay";
}
void pay(double money) const override
{
std::cout << "Using Alipay to pay: "
<< money << std::endl;
}
};
class WeChatPay : public Payment
{
public:
std::string name() const override
{
return "WeChatPay";
}
void pay(double money) const override
{
std::cout << "Using WeChatPay to pay: "
<< money << std::endl;
}
};
class BankCard : public Payment
{
public:
std::string name() const override
{
return "BankCard";
}
void pay(double money) const override
{
std::cout << "Using BankCard to pay: "
<< money << std::endl;
}
};
class PaymentSystem
{
public:
void addPayment(std::unique_ptr<Payment> payment)
{
payments.push_back(std::move(payment));
}
void showPayments() const
{
for (const auto& payment : payments)
{
std::cout << payment->name() << std::endl;
}
}
void payAll(double money) const
{
for (const auto& payment : payments)
{
payment->pay(money);
}
}
private:
std::vector<std::unique_ptr<Payment>> payments;
};
int main()
{
PaymentSystem system;
system.addPayment(std::make_unique<Alipay>());
system.addPayment(std::make_unique<WeChatPay>());
system.addPayment(std::make_unique<BankCard>());
system.showPayments();
system.payAll(100.0);
return 0;
}
预期输出类似:
Alipay
WeChatPay
BankCard
Using Alipay to pay: 100
Using WeChatPay to pay: 100
Using BankCard to pay: 100
这个案例体现了:
抽象类
↓
纯虚函数
↓
继承
↓
virtual
↓
override
↓
unique_ptr
↓
vector
↓
动态多态
↓
面向抽象编程
这已经不是单纯的"语法练习",而是一个小型的面向对象设计。
五十二、编译方式
如果使用Linux环境,可以使用:
g++ -std=c++17 -Wall -Wextra -pedantic main.cpp -o main
运行:
./main
如果使用C++20:
g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main
Windows下使用Visual Studio、MinGW或其他支持现代C++的编译器也可以完成编译。
本文中的运行结果均标记为预期输出,除非特别说明,并非当前环境实际执行所得。
五十三、C++多态知识体系
学完之后,可以把整个知识体系整理成:
C++多态
│
├── 静态多态
│ ├── 函数重载
│ └── 模板
│
└── 动态多态
│
├── 继承
│
├── virtual
│
├── 父类指针
│
├── 父类引用
│
├── 动态绑定
│
├── 重写
│
├── vtable/vptr
│
├── 虚析构
│
├── override
│
├── final
│
├── 纯虚函数
│
├── 抽象类
│
└── 面向抽象编程
五十四、面试高频题
1. 什么是C++多态?
标准回答
C++多态是指:
同一个接口可以根据不同对象产生不同的行为。
常见分类包括:
静态多态
动态多态
静态多态通常通过:
重载
模板
等方式实现。
动态多态通常通过:
继承
virtual
父类指针或引用
实现运行时动态分派。
原理解释
动态多态的关键是:
同一个父类接口
+
不同派生类实现
+
运行时根据实际对象选择函数
常见误区
不能简单说:
"C++多态就是virtual。"
virtual 是实现典型运行时多态的重要机制,但多态这个概念本身比一个关键字更广。
五十五、2. C++多态有哪些形式?
标准回答
通常分为:
静态多态
动态多态
静态多态包括:
函数重载
模板等
动态多态主要基于:
继承 + virtual + 父类指针/引用
五十六、3. 动态多态的条件是什么?
标准回答
典型条件:
-
存在继承关系;
-
基类函数为virtual;
-
派生类正确重写;
-
通过基类指针或引用调用。
常见误区
直接:
Dog dog;
dog.speak();
并不能体现典型运行时多态,因为对象类型已经明确。
五十七、4. virtual有什么作用?
标准回答
virtual 用于声明虚函数,使得通过基类指针或引用调用该函数时,可以根据对象的实际类型进行动态分派。
五十八、5. 什么是动态绑定?
标准回答
动态绑定是指:
函数调用的具体目标在运行时根据对象的动态类型确定。
例如:
Animal* p = new Dog;
p->speak();
如果 speak() 是虚函数,则可以调用:
Dog::speak()
五十九、6. 什么是静态绑定?
标准回答
静态绑定是指函数调用目标在编译阶段即可确定。
普通非虚函数调用通常采用这种方式。
六十、7. 什么是vtable?
标准回答
vtable通常指虚函数表,是主流C++编译器实现运行时多态的一种常见机制,用于保存虚函数相关的调用目标信息。
重要补充
不能说:
"C++标准规定了vtable。"
正确说法:
C++标准规定了虚函数的语义,但没有规定必须使用vtable实现。
六十一、8. 为什么基类析构函数有时需要virtual?
如果对象可能:
Base* ptr = new Derived;
delete ptr;
那么基类析构函数通常应该设计成虚析构函数:
virtual ~Base() = default;
这样通过基类指针销毁派生类对象时,可以按照多态方式正确处理析构。
六十二、9. override有什么作用?
override 明确表示:
当前函数希望重写基类虚函数。
编译器会帮助检查函数是否真的构成正确的重写关系。
它可以有效避免:
func(int)
误写成:
func(double)
这种错误。
六十三、10. 什么是纯虚函数?
例如:
virtual void draw() = 0;
这就是纯虚函数。
它主要用于定义抽象接口。
包含纯虚函数的类通常成为抽象类,不能直接实例化。
六十四、11. 什么是抽象类?
包含至少一个纯虚函数的类通常称为抽象类。
例如:
class Shape
{
public:
virtual void draw() = 0;
};
不能:
Shape shape;
但可以:
Circle circle;
Shape& shape = circle;
shape.draw();
六十五、12. 什么是对象切片?
例如:
Derived d;
Base b = d;
这里会创建一个新的Base对象,只保留派生对象中的Base部分。
派生类新增的数据和行为不会作为Derived对象继续存在于这个新的Base对象中。
因此称为:
对象切片。
六十六、13. 重载、重写、隐藏有什么区别?
重载
func(int)
func(double)
同名、参数不同。
重写
Base::func()
Derived::func()
父子类中对应虚函数的重新实现。
隐藏
派生类声明同名成员后,可能隐藏基类同名成员。
六十七、14. 为什么父类指针可以指向子类对象?
因为继承关系满足:
Derived is a Base
例如:
Dog : public Animal
因此:
Dog dog;
Animal* ptr = &dog;
是合法的向上转型。
六十八、15. virtual函数一定很慢吗?
不应该这样简单判断。
虚函数调用通常可能产生间接调用,但现代编译器可以进行多种优化。
工程中应该:
先保证正确的抽象设计,再通过实际性能分析决定是否需要优化。
不要为了避免一次潜在的虚函数调用开销,就牺牲整个系统的可维护性。
六十九、最终总结
如果只记住本文最重要的内容,可以记住下面这张图:
C++多态
│
┌──────────┴──────────┐
↓ ↓
静态多态 动态多态
│ │
┌────┴────┐ ┌─────┴─────┐
↓ ↓ ↓ ↓
重载 模板 继承 virtual
│
↓
父类指针/引用
│
↓
动态绑定
│
↓
实际对象决定调用
而动态多态最经典的代码就是:
class Animal
{
public:
virtual ~Animal() = default;
virtual void speak() = 0;
};
class Dog : public Animal
{
public:
void speak() override
{
std::cout << "Dog" << std::endl;
}
};
Animal* animal = new Dog;
animal->speak();
delete animal;
真正应该理解的不是:
virtual
这个单独的关键字。
而是一整条知识链:
继承
↓
父类接口
↓
virtual
↓
子类重写
↓
父类指针/引用
↓
动态类型
↓
动态绑定
↓
运行时多态
↓
抽象接口
↓
降低耦合
↓
提高扩展性
再进一步:
多态
↓
面向抽象编程
↓
接口与实现分离
↓
更好的软件设计
所以,C++多态真正值得学习的核心,不是"会写virtual",而是理解为什么程序需要让"同一个接口对应不同实现",以及如何利用这种机制设计出低耦合、可扩展、可维护的C++系统。
七十、学习练习题
第一组:基础理解
1.
下面哪一个最符合多态的核心思想?
A. 一个类拥有多个构造函数
B. 同一个接口面对不同对象产生不同实现
C. 一个变量保存多个数据
D. 一个函数只能调用一次
答案:B
2.
下面哪个通常属于静态多态?
A. 虚函数
B. 函数重载
C. 虚析构
D. 父类指针
答案:B
3.
下面哪个是典型动态多态的关键机制?
A. virtual
B. sizeof
C. typedef
D. namespace
答案:A
七十一、第二组:代码分析
4.
分析:
class Base
{
public:
virtual void func()
{
std::cout << "Base";
}
};
class Derived : public Base
{
public:
void func() override
{
std::cout << "Derived";
}
};
int main()
{
Derived d;
Base* p = &d;
p->func();
}
问题:
最终调用哪个函数?
答案:
Derived::func()
原因:
func是virtual
+
p指向Derived对象
+
通过Base指针调用
形成动态绑定。
5.
如果删除:
virtual
结果会有什么变化?
请自己分析:
静态类型是什么?
动态对象是什么?
函数是否还是虚函数?
调用目标如何确定?
七十二、第三组:对象切片练习
分析:
Derived d;
Base b = d;
Base& ref = d;
Base* ptr = &d;
分别回答:
-
哪一种发生对象切片?
-
哪一种创建了新的Base对象?
-
哪一种直接引用原对象?
-
哪一种保存的是对象地址?
-
哪几种情况下可以体现动态多态?
七十三、第四组:工程设计练习
尝试自己设计:
Logger
├── ConsoleLogger
├── FileLogger
└── NetworkLogger
要求:
Logger
作为抽象基类。
设计:
virtual void log(const std::string& message) = 0;
然后使用:
std::vector<std::unique_ptr<Logger>>
保存不同日志实现。
最终实现:
logger->log("Hello");
让不同Logger自动执行不同实现。
七十四、最终学习路线
如果你已经学完C++:
基础语法
↓
函数
↓
指针/引用
↓
类和对象
↓
构造/析构
↓
继承
那么多态应该按照:
多态概念
↓
静态多态
↓
动态多态
↓
virtual
↓
父类指针/引用
↓
动态绑定
↓
重载/重写/隐藏
↓
虚析构
↓
override/final
↓
纯虚函数
↓
抽象类
↓
vtable/vptr
↓
对象切片
↓
智能指针
↓
工程设计
↓
面试题
一步一步学习。
当你能够真正理解下面这段代码:
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Rectangle>());
shapes.push_back(std::make_unique<Triangle>());
for (const auto& shape : shapes)
{
shape->draw();
}
并且能够解释:
为什么vector可以保存不同类型?
为什么使用unique_ptr?
为什么Shape必须是抽象接口?
为什么draw需要virtual?
为什么Circle能够调用自己的draw?
为什么基类析构函数需要virtual?
运行时大致如何找到正确的函数?
这种设计解决了什么工程问题?