$ cat ~ / posts /c++ /oop6 3.7k Words ~ 15 Mins
cover.png
面向对象程序设计06

#面向对象程序设计06

exdoubled Lv5

同一个调用,不同的行为

先看最典型的 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); // Woof
makeSound(cat); // Meow
}

makeSound 的参数类型是 const Animal&

从函数签名看,它只知道自己拿到的是一个 Animal 引用,但运行时它会调用 Dog::speakCat::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(); // Animal,不是 Dog

普通成员函数在编译时就确定调用目标,ref 的静态类型是 Animal&,所以调用 Animal::type

只有虚函数才会根据动态类型分派

vptr 与 vtable

当一个类包含虚函数时,编译器通常会做两件事:

  • 在对象中插入一个隐藏指针 vptr
  • 为每个多态类生成一张虚函数表 vtable

vptr 指向该对象动态类型对应的 vtablevtable 是一个函数指针数组,里面存放虚函数的实际入口地址

AnimalDog 为例:

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 vtableeat 的槽位仍然指向 Animal::eatDog 覆盖了 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,插件系统中通过接口调用不同实现

overridefinal

派生类覆盖虚函数时,应始终写 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 {
// draw circle
}

private:
double radius_{};
};

只要一个类含有至少一个纯虚函数,它就是抽象类,不能直接实例化:

1
2
Shape s;      // 编译错误
Circle c(2); // 正确

抽象类的作用是定义接口契约

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(); // 输出 Derived::bar

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 被调用,Dog::~Dog 被跳过

通过基类指针删除派生类对象时,如果基类析构函数不是虚函数,析构调用会静态绑定到 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; // 切片:Dog 独有部分消失
a.name(); // Animal

Animal a = d 会创建一个新的 Animal 对象,只复制 Animal 那一部分

breed_ 被丢弃,vptr 也指向 Animal vtable

正确方式是使用引用或指针:

1
2
3
4
5
Animal& ref = d;
ref.name(); // Dog

Animal* ptr = &d;
ptr->name(); // Dog

函数参数也是一样:

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 具体类,具体实现可以在运行时由工厂函数决定

第二,DataPipelineunique_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 接口

用户可以直接调用 inserteraseoperator[],绕过栈“只能操作栈顶”的语义约束

正确做法是组合:

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 接口
  • 对象处于性能敏感内层循环
  • 继承层次已经变深,修改影响难以预测

如果需要的是“这个对象可以被当作某种接口使用”,考虑虚函数和接口继承

如果需要的是“这个对象内部用到了另一个对象的功能”,使用组合

$ discussion
# Comments
waline