同一个调用,不同的行为
先看最典型的 Animal / Dog / Cat 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| #include <iostream> #include <memory> #include <vector>
class Animal { public: virtual void speak() const { std::cout << "...\n"; }
virtual ~Animal() = default; };
class Dog : public Animal { public: void speak() const override { std::cout << "Woof\n"; } };
class Cat : public Animal { public: void speak() const override { std::cout << "Meow\n"; } };
void makeSound(const Animal& animal) { animal.speak(); }
int main() { Dog dog; Cat cat;
makeSound(dog); makeSound(cat); }
|
makeSound 的参数类型是 const Animal&
从函数签名看,它只知道自己拿到的是一个 Animal 引用,但运行时它会调用 Dog::speak 或 Cat::speak
这就是动态绑定:调用目标不是编译时固定死的,而是在运行时根据对象的实际类型决定
静态类型与动态类型
静态类型是编译器在代码中看到的类型,动态类型是对象运行时真正的类型
1 2 3 4
| Dog dog; Animal& ref = dog;
ref.speak();
|
这里 ref 的静态类型是 Animal&,动态类型是 Dog
因为 speak 是虚函数,所以调用时使用动态类型,最终执行 Dog::speak
如果函数不是虚函数,则使用静态绑定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class Animal { public: void type() const { std::cout << "Animal\n"; } };
class Dog : public Animal { public: void type() const { std::cout << "Dog\n"; } };
Dog dog; Animal& ref = dog; ref.type();
|
普通成员函数在编译时就确定调用目标,ref 的静态类型是 Animal&,所以调用 Animal::type
只有虚函数才会根据动态类型分派
vptr 与 vtable
当一个类包含虚函数时,编译器通常会做两件事:
- 在对象中插入一个隐藏指针
vptr - 为每个多态类生成一张虚函数表
vtable
vptr 指向该对象动态类型对应的 vtable,vtable 是一个函数指针数组,里面存放虚函数的实际入口地址
以 Animal 和 Dog 为例:
1 2 3 4 5 6 7 8 9 10 11
| class Animal { public: virtual void speak() const; virtual ~Animal(); };
class Dog : public Animal { public: void speak() const override; ~Dog() override; };
|
概念上,一个 Dog 对象的布局可以理解为:
1 2 3 4 5 6 7
| Dog object +----------------+ | vptr ----------+----> Dog vtable +----------------+ +-------------------+ | Dog data ... | | &Dog::speak | +----------------+ | &Dog::~Dog | +-------------------+
|
一个 Animal 对象的 vptr 指向 Animal vtable,一个 Dog 对象的 vptr 指向 Dog vtable
这就是同一个 Animal& 调用可以表现不同的原因:引用或指针指向的对象不同,对象头部的 vptr 就不同
虚函数调用过程
对下面的调用:
1 2 3
| void makeSound(const Animal& animal) { animal.speak(); }
|
编译器生成的逻辑可以近似理解为:
1 2 3 4
| auto vptr = animal.__vptr; auto fn = vptr[speak_slot]; fn(&animal);
|
实际过程是:
- 读取对象中的
vptr - 在
vptr 指向的 vtable 中找到 speak 对应槽位 - 取出函数指针
- 间接调用该函数
槽位偏移在编译期确定,函数地址在运行时由对象的 vptr 决定
虚函数机制的本质就是用一次间接寻址换取运行时多态
vtable 如何被派生类改写
派生类的 vtable 可以理解为先复制基类的表,再把被 override 的槽位替换成派生类实现。
1 2 3 4 5 6 7 8 9 10 11
| class Animal { public: virtual void speak() const { std::cout << "...\n"; } virtual void eat() const { std::cout << "eat\n"; } virtual ~Animal() = default; };
class Dog : public Animal { public: void speak() const override { std::cout << "Woof\n"; } };
|
Dog 没有覆盖 eat,所以 Dog vtable 中 eat 的槽位仍然指向 Animal::eat;Dog 覆盖了 speak,所以 speak 槽位指向 Dog::speak
这解释了 override 的真实含义:明确告诉编译器“我要覆盖基类的虚函数”,如果签名不匹配,编译器应该报错,而不是悄悄生成一个新函数
静态绑定与动态绑定的代价
普通函数调用是静态绑定:
1
| 编译时确定调用目标 -> 生成直接跳转 -> 可能内联
|
虚函数调用是动态绑定:
1
| 运行时读取 vptr -> 查 vtable -> 间接调用 -> 通常难以内联
|
动态绑定主要代价:
第一是直接代价:多一次内存读取和一次间接调用
第二是隐性代价:编译器通常无法内联虚函数调用。对于性能敏感的小函数,例如向量点积、矩阵运算、图形内层循环,不能内联的损失可能比查表本身更大
1 2 3 4 5 6 7 8 9 10 11
| class Vector3 { public: double dot(const Vector3& other) const { return x_ * other.x_ + y_ * other.y_ + z_ * other.z_; }
private: double x_{}; double y_{}; double z_{}; };
|
像 dot 这种高频、短小、性能敏感的操作,不适合为了“面向对象”而强行做成虚函数
但在需要运行时多态的地方,虚函数的代价通常是合理的:例如图形系统中统一处理不同 Shape,序列化系统中统一使用不同 Serializer,插件系统中通过接口调用不同实现
override 与 final
派生类覆盖虚函数时,应始终写 override:
1 2 3 4 5 6 7 8 9 10 11 12
| class Base { public: virtual void foo(int x); virtual void bar(); virtual ~Base() = default; };
class Derived : public Base { public: void foo(int x) override; void bar() override final; };
|
override 解决的是签名错误问题:
1 2 3 4
| class Derived : public Base { public: void foo(double x); };
|
如果没有 override,这段代码可以通过编译,但动态绑定时仍然调用 Base::foo(int)
加上 override 后,编译器会立刻报错
final 表示“到此为止”
它可以用于虚函数,禁止后续派生类继续覆盖;也可以用于类,禁止继续继承
1 2 3 4
| class Concrete final : public Base { public: void foo(int x) override; };
|
override 表达“我确实覆盖了基类接口”,final 表达“这个扩展点关闭”
纯虚函数与抽象类
接口继承常用纯虚函数表达
纯虚函数没有默认实现,派生类必须实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| class Shape { public: virtual double area() const = 0; virtual double perimeter() const = 0; virtual void draw() const = 0; virtual ~Shape() = default; };
class Circle : public Shape { public: explicit Circle(double r) : radius_(r) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }
double perimeter() const override { return 2.0 * 3.141592653589793 * radius_; }
void draw() const override { }
private: double radius_{}; };
|
只要一个类含有至少一个纯虚函数,它就是抽象类,不能直接实例化:
抽象类的作用是定义接口契约
Shape 表示所有图形都必须能计算面积、周长,并能绘制
调用方可以依赖这个稳定接口,而不关心具体是圆、矩形还是三角形
接口继承与实现继承
继承有两种常见用法:接口继承和实现继承
接口继承的重点是定义能力和契约
例如 Shape 定义 area()、perimeter()、draw(),派生类负责实现
调用方通过 Shape& 或 Shape* 使用对象,具体类型可以在运行时变化
实现继承的重点是复用基类代码。它更危险,因为基类实现和派生类覆盖之间可能产生隐式耦合
看下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class Base { public: void foo() { bar(); }
virtual void bar() { std::cout << "Base::bar\n"; }
virtual ~Base() = default; };
class Derived : public Base { public: void bar() override { std::cout << "Derived::bar\n"; } };
Derived d; d.foo();
|
foo 是基类的普通函数,但它内部调用了虚函数 bar
成员函数内部调用 bar() 实际上等价于 this->bar(),而 this 指向的是 Derived 对象,所以发生动态绑定,最终调用 Derived::bar
这是实现继承危险的地方:派生类作者以为自己只改了 Derived::bar,但实际上也改变了从基类继承来的 Base::foo 的行为
基类作者修改实现细节,也可能意外破坏派生类。这类问题常被称为脆弱基类问题
因此,继承不应该被当作“复用代码的快捷方式”
虚函数是解耦工具,继承是语义承诺
虚析构函数
多态基类必须有虚析构函数
只要类有虚函数,就应该有虚析构函数
错误示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class Animal { public: virtual void speak() const = 0;
~Animal() { std::cout << "~Animal\n"; } };
class Dog : public Animal { public: void speak() const override { std::cout << "Woof\n"; }
~Dog() { std::cout << "~Dog\n"; }
private: std::string name_; };
Animal* a = new Dog(); delete a;
|
通过基类指针删除派生类对象时,如果基类析构函数不是虚函数,析构调用会静态绑定到 Animal::~Animal,派生类部分不会正确析构
若派生类持有资源,就会泄漏
正确写法:
1 2 3 4 5
| class Animal { public: virtual void speak() const = 0; virtual ~Animal() = default; };
|
这样 delete Animal* 时会通过 vtable 找到实际类型的析构函数,先调用 Dog::~Dog,再调用 Animal::~Animal,析构链完整
更现代的写法通常避免裸 new / delete,使用 unique_ptr 管理多态对象:
1 2
| std::unique_ptr<Animal> animal = std::make_unique<Dog>(); animal->speak();
|
即使使用智能指针,基类析构函数仍然必须是虚函数
因为 unique_ptr<Animal> 最终仍然会通过 Animal* 删除对象
对象切片
多态对象必须通过指针或引用使用,不能按值传递
按值传递会发生对象切片:派生类部分被截掉,只保留基类子对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class Animal { public: virtual std::string name() const { return "Animal"; }
virtual ~Animal() = default;
int weight_{}; };
class Dog : public Animal { public: std::string name() const override { return "Dog"; }
std::string breed_; };
Dog d; d.weight_ = 30; d.breed_ = "Labrador";
Animal a = d; a.name();
|
Animal a = d 会创建一个新的 Animal 对象,只复制 Animal 那一部分
breed_ 被丢弃,vptr 也指向 Animal vtable
正确方式是使用引用或指针:
1 2 3 4 5
| Animal& ref = d; ref.name();
Animal* ptr = &d; ptr->name();
|
函数参数也是一样:
1 2
| void bad(Animal animal); void good(const Animal& animal);
|
如果需要保存一组多态对象,不能写 std::vector<Animal>,应写:
1 2 3
| std::vector<std::unique_ptr<Animal>> animals; animals.push_back(std::make_unique<Dog>()); animals.push_back(std::make_unique<Cat>());
|
这里 unique_ptr 负责所有权,虚函数负责动态绑定
所有权与多态协作
继承与上一章的所有权并不是分离的。多态对象经常需要通过智能指针管理生命周期。
接口基类 + 工厂函数 + unique_ptr:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| class Serializer { public: virtual std::string serialize(const std::string& data) const = 0; virtual std::string deserialize(const std::string& raw) const = 0; virtual ~Serializer() = default; };
class JsonSerializer : public Serializer { public: std::string serialize(const std::string& data) const override { return "{\"data\":\"" + data + "\"}"; }
std::string deserialize(const std::string& raw) const override { const std::string prefix = "{\"data\":\""; const std::string suffix = "\"}";
if (raw.size() < prefix.size() + suffix.size()) { throw std::invalid_argument("invalid json"); }
return raw.substr(prefix.size(), raw.size() - prefix.size() - suffix.size()); } };
std::unique_ptr<Serializer> createSerializer(const std::string& type) { if (type == "json") { return std::make_unique<JsonSerializer>(); }
throw std::invalid_argument("unknown serializer type"); }
class DataPipeline { public: explicit DataPipeline(std::unique_ptr<Serializer> serializer) : serializer_(std::move(serializer)) {}
void process(const std::string& data) { auto encoded = serializer_->serialize(data); auto decoded = serializer_->deserialize(encoded);
if (decoded != data) { throw std::runtime_error("roundtrip failed"); } }
private: std::unique_ptr<Serializer> serializer_; };
|
关键设计:
第一,DataPipeline 依赖 Serializer 接口,不依赖 JsonSerializer 具体类,具体实现可以在运行时由工厂函数决定
第二,DataPipeline 用 unique_ptr<Serializer> 表达唯一所有权,序列化器属于 pipeline,pipeline 析构时序列化器自动析构
这就是“虚函数负责行为解耦,智能指针负责生命周期”的协作方式
组合优先于继承
继承必须表示真正的 is-a 关系
Dog is-a Animal 合理,Circle is-a Shape 合理;但 Stack is-a Vector 不合理,Logger is-a File 也不合理
错误示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Stack : public std::vector<int> { public: void push(int x) { push_back(x); }
void pop() { pop_back(); }
int top() const { return back(); } };
|
这个设计的问题是,Stack 继承了 vector 的所有 public 接口
用户可以直接调用 insert、erase、operator[],绕过栈“只能操作栈顶”的语义约束
正确做法是组合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class Stack { public: void push(int x) { data_.push_back(x); }
void pop() { data_.pop_back(); }
int top() const { return data_.back(); }
bool empty() const { return data_.empty(); }
private: std::vector<int> data_; };
|
组合的优势是可以完全控制对外接口
Stack 使用 vector 实现,但不暴露 vector 的全部能力,继承会暴露基类 public 接口,组合只暴露自己选择的接口
可以用下面的问题判断是否该继承:
- 是否存在真正的
is-a 关系? - 派生类能否完全替代基类使用?
- 是否需要通过基类指针或引用进行运行时多态?
- 基类是否专门设计为被继承,包含虚析构函数和清晰接口?
- 继承层次是否足够浅,通常不超过两层?
如果只是为了复用代码,优先使用组合
深继承层次的问题
过深的继承层次会让理解成本迅速上升:
1
| Entity -> MovableEntity -> LivingEntity -> Character -> Player
|
要理解 Player,就必须理解前面所有层的状态、接口、虚函数覆盖关系和构造析构顺序
任何一层的实现变化都可能影响更下面的类。
更清晰的设计通常是浅层接口继承:
1 2
| Shape -> Circle Shape -> Rectangle
|
或者使用组合把能力拆成成员:
1 2 3 4 5 6 7
| class Player { private: Transform transform_; Health health_; Inventory inventory_; Controller controller_; };
|
继承表达分类关系,组合表达“拥有某种能力或部件”
设计判断
虚函数和继承适合以下场景:
- 同一段代码需要处理多种具体类型
- 调用方只应该依赖稳定接口,不应该依赖具体实现
- 具体实现需要在运行时选择或替换
- 基类接口稳定,不会频繁变化
- 性能开销可以接受
- 派生类和基类之间有真正的
is-a 关系
不适合使用继承的场景:
- 只是为了复用几个函数
- 派生类并不能完全替代基类
- 不希望暴露基类的所有 public 接口
- 对象处于性能敏感内层循环
- 继承层次已经变深,修改影响难以预测
如果需要的是“这个对象可以被当作某种接口使用”,考虑虚函数和接口继承
如果需要的是“这个对象内部用到了另一个对象的功能”,使用组合