写在前面 :本篇文章为个人 C++ 学习与工业实践的整合复习笔记。上一期我们探讨了 std::function<>、Lambda 闭包、成员函数绑定以及回调机制,并理解了如何通过回调让底层模块与上层业务进行解耦。
本篇将继续往 C++ 工程设计的方向深入,内容主要围绕 继承、虚函数、函数重写、抽象类、纯虚函数与运行时多态 展开,以日志中部分代码的实现为例子,并结合实际开发中的接口设计进行理解。
在实际项目中,我们经常会遇到这样一种情况:
某一类对象在"能力"上具有统一性,但具体行为又各不相同。
例如,我们希望不同对象都具备同一个接口:run()
但不同对象的 Run() 实现方式完全不同。
更具体实际的来说,一个日志模块可能存在:输出到终端,写入文件,写入远程服务器等功能。
它们本质上都在完成"日志输出"这件事,但具体的输出方式并不一致。如果直接在代码中通过大量 if...else 判断不同类型,再分别执行不同逻辑,随着功能不断增加,代码会逐渐变得难以维护。
因此我们需要解决的问题是:如何让多个不同类型的对象遵守统一的调用方式,同时又允许它们拥有各自不同的实现?
这正是 C++ 中继承与多态所解决的核心问题之一。
一、为什么 C++ 需要多态
如果目前觉得工业界的日志系统稍微有些抽象,不妨把这个场景换成日常生活中更直观的例子------水果加工厂。
假设我们开了一家甜品店,后台系统需要对各种各样的水果进行加工:苹果可以榨成苹果汁,芒果可以做成芒果千层,草莓则用来做草莓蛋糕。 从上游业务(店长或收银台)的视角来看,我们其实根本不关心当前拿到的究竟是什么具体水果,我们只希望下达一个统一的指令:process()(处理它),然后每种水果会自动根据自己的特性去完成对应的加工。
如果没有多态,我们的代码可能得写成一长串丑陋的判断:
cpp
if (fruit.type == APPLE) {
makeAppleJuice(fruit);
} else if (fruit.type == MANGO) {
makeMangoCake(fruit);
} else if (fruit.type == STRAWBERRY) {
makeStrawberrySmoothie(fruit);
}
// 以后店里每进一种新水果,这里就要疯狂加新的 else if...
这显然违背了面向对象设计中的"开闭原则"(对扩展开放,对修改关闭)。而 C++ 的多态机制,就是为了解决这种痛点而生的------它允许我们定义一个统一的抽象接口,让不同的子类去各自实现具体的行为,从而让上层调用者彻底从繁琐的类型判断中解脱出来。
接下来,我们就来看看 C++ 是如何通过继承、虚函数和多态优雅地解决这个问题的。
二、继承:先解决"这些对象属于同一类体系"
在上一节我们聊到,如果有一堆行为相似但各具特色的对象(比如苹果、芒果、草莓),我们希望用一套统一的指令去操控它们。但在写代码之前,摆在我们面前的第一个现实问题是:这些对象之间存在着大量的共性。
如果没有继承,当我们定义 Apple、Mango 和 Strawberry 时,可能会写出这样的代码:
cpp
class Apple {
private:
string name;
double weight;
string origin; // 产地
public:
void wash(); // 洗水果
void peel(); // 削皮
void makeJuice(); // 特有行为
};
class Mango {
private:
string name;
double weight;
string origin; // 产地重复了!
public:
void wash(); // 洗行为重复了!
void peel(); // 削皮重复了!
void makeCake(); // 特有行为
};
你会发现,name、weight、origin 以及 wash()、peel() 这些基础属性和行为,在每个水果类里都被完整复制了一遍。这不仅违反了软件工程中著名的 DRY原则(Don't Repeat Yourself),而且当我们需要统一管理这些水果时,它们在类型上没有任何继承关系,无法直接通过同一个父类类型进行统一管理。
那么如何解决呢?
1.继承的本质与定义
继承你可以先把它理解成一句话:
让一个新的类,在已有类的基础上继续扩展。
比如我们先有一个"水果"类:
cpp
class Fruit
{
public:
void wash()
{
std::cout << "Wash fruit" << std::endl;
}
};
现在我们要写苹果。
苹果本身也是水果,所以苹果理应拥有水果共有的能力,比如清洗、称重、入库。这时没必要重新写一遍。
可以直接让 Apple 继承 Fruit:
cpp
class Apple : public Fruit
{
};
于是:
cpp
Apple apple;
apple.wash();
可以直接调用父类中的 wash()。
这就是继承最基础的作用:复用已有类的成员,同时建立类型关系。
2.继承的基本语法
C++ 中继承的语法:
cpp
class 子类 : 继承方式 父类
{
};
3.继承的适用场景:什么时候该用继承?
结合前面的例子来看。
在水果加工系统中,Apple、Mango、Strawberry 虽然是不同的具体类型,但它们都属于 Fruit,同时也具有一部分共同的属性与基础行为,例如 name、weight、origin,以及 wash()、peel() 等操作。
因此,我们可以把这些公共内容提取到 Fruit 父类中,再让不同的水果子类继承它。这样一来,父类负责维护所有水果共有的部分,子类则只需要关注自身特有的加工逻辑,例如 makeJuice()、makeCake() 等。
从这个例子中,我们可以进一步总结出继承比较典型的使用场景:
当多个类之间存在明确的 Is-A 关系,同时又拥有一部分可以复用的公共属性或行为时,就可以考虑建立继承体系,将共性放到父类中,将差异保留给各个子类。
这个思路并不只适用于水果这样的教学示例。
在实际工程中的日志系统里,也存在非常类似的关系。例如,输出到终端的 ConsoleLog、写入本地文件的 FileLog、发送到远程服务器的 RemoteLog,虽然具体的日志输出方式各不相同,但它们本质上都可以被看作一种"日志输出器"。
它们通常会共享一些公共信息,例如日志等级 level、输出格式 format,以及部分通用的配置、格式化和校验逻辑。因此,我们同样可以设计一个日志基类,将这些共有内容统一管理,再让不同的日志实现类继承它,并保留各自特有的输出方式。
接下来,我们仍然会先通过水果加工这个更直观的例子继续理解函数重写、虚函数、抽象类以及运行时多态等知识点。
等这些机制真正理解之后,再回到日志系统中,就会发现它们的设计思路几乎完全一致:水果例子负责帮助我们理解 C++ 的语言机制,而日志系统则是把这些机制真正应用到工程代码中的一次举一反三。
三、函数重写:让子类拥有自己的行为
我们通过对继承的学习解决了一个基础问题:Apple、Mango、Strawberry 都属于 Fruit 这一统一类型体系,同时还能复用父类中已有的属性和基础行为。
但实际中,
对于 wash() 这种行为,不同水果的处理方式可能差别不大,因此直接复用父类实现完全没有问题。然而 process() 就不同了。苹果的加工方式可能是榨成苹果汁,芒果则是制作芒果蛋糕。
它们都具备"加工"这个能力,但具体的加工方式明显不同。
如果所有子类都只能使用父类中这一份:
cpp
void process()
{
std::cout << "Process fruit" << std::endl;
}
那么父类只能描述一种非常笼统的默认行为,却无法体现不同水果自身的业务差异。
因此,我们还需要一种机制:父类先提供一种统一的行为名称,而子类可以根据自己的特点重新定义这个行为的具体实现。
这就引出了函数重写。
1.什么是函数重写
函数重写可以理解为:
基类先声明一个虚函数,派生类再提供具有相同函数签名的实现,从而为同一个行为提供属于自己的版本。
例如:
cpp
class Fruit
{
public:
virtual void process()
{
std::cout << "Process fruit" << std::endl;
}
};
苹果继承 Fruit 之后,可以重新定义 process():
cpp
class Apple : public Fruit
{
public:
void process() override
{
std::cout << "Make apple juice" << std::endl;
}
};
芒果也可以拥有自己的版本:
cpp
class Mango : public Fruit
{
public:
void process() override
{
std::cout << "Make mango cake" << std::endl;
}
};
此时 Apple 和 Mango 虽然都继承自 Fruit,但它们可以对同一个行为 process() 提供不同的具体实现。
这正是继承之后经常出现的需求:父类负责定义共性,子类负责补充差异。
2.函数重写的基本形式(语法)
要实现函数重写(Overriding),在语法上有一些非常明确的规定。如果不满足这些条件,编译器就无法把派生类函数认定为对基类虚函数的正确重写。
一个标准的函数重写需要满足以下条件:
-
基类中的成员函数需要声明为 virtual****虚函数 ,派生类通常使用
override显式标记重写关系,让编译器帮助检查。 -
函数名必须相同。
-
参数列表必须相同 ,包括参数类型、个数和顺序;成员函数的
const、引用限定符等也需要正确匹配。 -
返回值类型通常保持相同,某些类的指针或引用返回类型允许协变返回,初学阶段保持完全一致即可。
其写法基本形式:
cpp
class 父类
{
public:
virtual void func()
{
// 父类实现
}
};
class 子类 : public 父类
{
public:
void func() override
{
// 子类自己的实现
}
};
例如:
cpp
class Fruit
{
public:
virtual void process()
{
std::cout << "Process fruit" << std::endl;
}
};
class Apple : public Fruit
{
public:
void process() override
{
std::cout << "Make apple juice" << std::endl;
}
};
3. 什么时候需要函数重写?
结合水果加工的例子,可以总结出一个非常典型的场景:
当父类和多个子类之间已经存在明确的继承关系,并且某个行为在"概念上相同",但不同子类需要提供不同实现时,就可以考虑让子类重新定义这个行为。
例如:
cpp
Fruit
process()
对于所有水果来说,"加工"都是一个共同能力。
但具体到不同水果:
cpp
Apple -> 榨汁
Mango -> 做蛋糕
Strawberry -> 做奶昔
因此,process() 这个行为适合由父类统一定义名称,再由不同子类提供各自的实现。
这种设计在工程代码中同样非常常见。
例如日志系统中,不同日志对象都具备"输出日志"这个行为:
cpp
Log
output()
但不同子类的输出方式不同:
cpp
ConsoleLog -> 输出到终端
FileLog -> 写入文件
RemoteLog -> 发送到远程服务器
从业务含义来看,它们执行的都是同一种能力------日志输出,但具体实现方式各不相同。
因此,日志系统也非常适合通过"父类定义统一行为,子类提供不同实现"的方式进行设计。
不过到这里,我们目前解决的还只是:
不同子类可以拥有不同版本的同一个虚函数
如果我们真正希望上层代码只面对统一的父类接口,并在运行时自动调用正确的子类实现,就必须继续引入 C++ 中最核心的机制之一:
虚函数 virtual**。**
四、虚函数:让调用真正根据对象类型发生变化
1.为什么需要虚函数?
在实际工程中,经常会看到这样的写法:
cpp
Fruit* fruit = new Apple;
fruit->process();
这里使用 Fruit* 来指向具体的 Apple 对象,并不是多此一举。
这样做最大的好处,是让上层代码只依赖统一的父类类型 Fruit,而不用关心当前拿到的究竟是 Apple、Mango 还是 Strawberry。
例如:
cpp
std::vector<Fruit*> fruits;
fruits.push_back(new Apple);
fruits.push_back(new Mango);
fruits.push_back(new Strawberry);
之后就可以统一处理:
cpp
for (Fruit* fruit : fruits)
{
fruit->process();
}
这样一来,后续即使增加新的水果类型,上层的遍历和调用逻辑也基本不需要修改。
但这种写法也带来了一个新的问题。
假设父类和子类中都定义了 process(),但父类的 process() 还没有声明为虚函数:
cpp
class Fruit
{
public:
void process()
{
std::cout << "Process fruit" << std::endl;
}
};
class Apple : public Fruit
{
public:
void process()
{
std::cout << "Make apple juice" << std::endl;
}
};
此时虽然 fruit 实际指向的是 Apple 对象,但由于 fruit 本身的静态类型是 Fruit*,普通成员函数会按照这个类型进行绑定,因此最终调用的是:
cpp
Fruit::process()
而我们的真实需求显然是:
cpp
指向 Apple -> 调用 Apple::process()
指向 Mango -> 调用 Mango::process()
指向 Strawberry -> 调用 Strawberry::process()
也就是说,我们既想保留"使用父类指针统一管理不同子类"的好处,又希望函数调用能够根据对象实际的类型自动选择对应实现。
为了让这套设计真正成立,C++ 提供了虚函数机制 virtual。
cpp
class Fruit
{
public:
virtual void process()
{
std::cout << "Process fruit" << std::endl;
}
};
此时:
cpp
Fruit* fruit = new Apple;
fruit->process();
调用的就是:
cpp
Apple::process()
因此,虚函数解决的核心问题可以概括为:在使用父类指针或引用统一管理不同子类对象时,让函数调用能够在运行时根据对象的真实类型选择对应实现。
这就是 C++ 运行时多态的基础。
2.虚函数的语法
虚函数的语法本身并不复杂,只需要在基类成员函数前加上 virtual 关键字即可:
cpp
class Fruit
{
public:
virtual void process()
{
std::cout << "Process fruit" << std::endl;
}
};
当派生类需要提供自己的实现时,可以重新定义这个函数:
cpp
class Apple : public Fruit
{
public:
void process() override
{
std::cout << "Make apple juice" << std::endl;
}
};
这里需要注意两个关键字:
cpp
virtual
override
virtual 写在基类中,用来声明这个成员函数是虚函数,表示该函数允许派生类重新实现,并且在通过基类指针或引用调用时,可以参与运行时多态。
override 写在派生类中,用来明确告诉编译器:当前这个函数是对基类虚函数的重写。
虚函数最常见的基本形式可以总结为:
cpp
class Base
{
public:
virtual 返回值类型 func(参数列表)
{
// 基类默认实现
}
};
class Derived : public Base
{
public:
返回值类型 func(参数列表) override
{
// 派生类自己的实现
}
};
小结:类之间的继承关系由 class Derived : public Base****建立,而 virtual****修饰的是基类中的成员函数。一个成员函数被声明为虚函数后,派生类可以对它进行重写;当程序通过基类指针或引用调用这个函数时,C++ 可以在运行时根据对象的真实类型选择对应的派生类实现,从而形成运行时多态。
五、纯虚函数
接上文,如果你仔细琢磨基类中那个默认的实现:
cpp
virtual void process() {
std::cout << "Process fruit" << std::endl;
}
会发现它在工程上其实显得有些"鸡肋"甚至莫名其妙。因为"水果"本身是一个极其抽象的概念,世界上根本不存在一个仅仅叫"水果"而不具体是苹果、芒果还是草莓的实体。既然如此,Fruit 根本就不应该提供什么默认的 process() 实现------因为压根不知道怎么处理。
我们真正想要表达的语义是:"所有水果都必须具备加工能力,但我不管你具体怎么加工,每个子类必须自己给出实现!"
为了在语法上完美表达这种"只规定接口,不提供默认实现"的设计,C++ 提供了 纯虚函数(Pure Virtual Function) 和 抽象类(Abstract Class)。
1.什么是纯虚函数?
纯虚函数本质上是一种"只声明规则,不提供普通默认实现"的虚函数。
它的语法是在虚函数声明末尾添加 = 0:
cpp
class Fruit
{
public:
virtual void process() = 0;
};
这里的:
= 0
并不是给函数赋值,而是在告诉编译器:process() 是一个纯虚函数,Fruit 只负责规定这个接口存在,具体实现交给派生类完成。
因此,Fruit 此时表达的语义就非常清晰:任何一种 Fruit,都必须具备 process() 这个能力。
但至于如何加工,则由具体水果自己决定。
此时整个继承体系的职责就变得非常明确:
cpp
Fruit
|
|-- 规定:必须实现 process()
|
+-- Apple -> 榨汁
+-- Mango -> 做蛋糕
+-- Strawberry -> 做奶昔
父类负责制定统一规则,派生类负责提供具体行为。
需要补充一个 C++ 语法上的细节:纯虚函数虽然以 = 0****声明,但语法上仍然允许在类外提供函数定义;即使提供了定义,它依然保持纯虚函数属性,所在类也仍然是抽象类。 在本文这种接口设计场景中,我们主要使用它来表达"由派生类负责提供具体实现"的语义。
2.什么是抽象类?
当一个类中包含至少一个纯虚函数时,这个类就会成为抽象类。
例如:
cpp
class Fruit
{
public:
virtual void process() = 0;
};
由于 Fruit 中存在纯虚函数,所以 Fruit 是一个抽象类。
抽象类最大的特点之一,就是不能直接创建对象:
cpp
Fruit fruit; // 编译错误
原因也很好理解。
Fruit 只规定了水果必须能够 process(),却没有真正告诉程序:process() 到底应该做什么。
因此,一个行为尚未完整定义的类型,本身就不应该被直接实例化。
但是,我们依然可以使用抽象类的指针或引用:
cpp
Fruit* fruit = new Apple;
fruit->process();
此时调用的就是:
cpp
Apple::process()
这也是抽象类在工程设计中的核心用途之一:自身不能被直接实例化,但可以作为统一接口,让上层代码通过父类指针或引用管理不同的派生类对象。
3.派生类必须实现纯虚函数吗?
如果一个派生类想成为可以正常实例化的具体类,就必须实现基类中尚未实现的纯虚函数。
所以可以把这个过程理解成:
cpp
Fruit
process() = 0
↓
要求子类实现
↓
Apple::process()
↓
Apple 成为完整的具体类
4.普通虚函数和纯虚函数有什么区别?
普通虚函数和纯虚函数都可以参与运行时多态,但它们在设计语义上有所不同。
普通虚函数:
cpp
virtual void process()
{
std::cout << "Process fruit" << std::endl;
}
表达的是父类已经提供了一份默认行为,派生类可以根据需要重新实现。
而纯虚函数:
cpp
virtual void process() = 0;
表达的是:父类主要负责规定这个能力必须存在,具体实现通常由派生类负责。
因此,两者可以简单对比:
| 对比项 | 普通虚函数 | 纯虚函数 |
|---|---|---|
| 语法 | virtual void func() |
virtual void func() = 0 |
| 是否强调默认实现 | 可以提供 | 主要用于规定接口 |
| 派生类是否必须重写 | 不一定 | 想成为具体类时需要实现 |
| 所在类能否直接实例化 | 通常可以 | 含纯虚函数时不可以 |
| 常见用途 | 提供默认行为,同时支持多态 | 定义统一接口和抽象规范 |
5.为什么工程中经常使用纯虚函数?
纯虚函数最大的价值,在于它能够把"接口"和"实现"分离开。
以水果加工为例:
上层业务只需要依赖:Fruit*,并调用:fruit->process();
至于真正执行的是哪个水果自己的 process(),全部交给派生类决定。
这样,上层代码依赖的是一个稳定的抽象接口,而具体实现可以不断扩展。
未来新增一种水果:
cpp
class Orange : public Fruit
{
public:
void process() override
{
std::cout << "Make orange juice" << std::endl;
}
};
只要它遵守 Fruit 规定的接口,就可以直接加入原来的体系中,而上层调用逻辑无需因为新增一种水果而重新设计。
这正是抽象接口在工程设计中的重要价值。
小结:
这种"由抽象基类通过纯虚函数定义统一接口,再由不同派生类分别提供具体实现"的设计方式,在很多工程场景中都会出现。
如果这些不同实现代表的是一组可以相互替换的"算法"或"行为策略",那么这样的多态体系就已经具备了**策略模式(Strategy Pattern)**的核心基础。后续当一个上层对象持有这个策略接口,并把具体行为委托给当前策略对象执行时,就形成了完整的策略模式结构。
接下来,我们就把前面水果加工中的这些知识迁移回真实的日志系统,看看继承、纯虚函数和运行时多态是如何真正参与工程设计的。
六.从水果加工再回到日志系统(实践)
前面我们通过水果加工的例子,轻松理解了继承、函数重写、虚函数以及纯虚函数。现在,让我们把这些语言机制真正拉回到我们一开始提到的日志系统(Log System)中。
在实际开发中,一个成熟的日志模块往往需要支持多种不同的输出方式,例如:
-
输出到终端(Console)
-
写入本地磁盘文件(File)
-
发送远端服务器(Remote / Kafka 等)
1. 如果不用多态:失控的 if...else(多态的使用场景判断)
如果直接从业务逻辑出发,不使用面向对象的设计,代码很容易写成这样:
cpp
void writeLog(int logType, const std::string& message) {
if (logType == TYPE_CONSOLE) {
std::cerr << message << std::endl;
} else if (logType == TYPE_FILE) {
// 打开文件,写入日志...
} else if (logType == TYPE_REMOTE) {
// 通过 Socket 发送到远程...
}
// 以后每增加一种日志方式,这里就要疯狂加新的 else if...
}
这种写法在功能较少时看起来很直接,但随着系统演进,问题会迅速暴露:日志的调用逻辑和具体的输出实现被紧紧绑在了一起。 每新增一种输出渠道,就要修改核心调度代码,违背了"开闭原则"。
而仔细观察这些不同的日志方式,会发现它们和前面的水果加工完全一致:它们都在完成同一种能力------"输出日志",属于同一个抽象体系,但具体的输出方式完全不同。
2. 用纯虚函数打造日志策略(LogStrategy)
既然如此,我们就可以把"日志输出"抽象成一个统一的接口。结合我们前面学到的纯虚函数,代码可以这样设计:
cpp
#include <iostream>
#include <string>
#include <fstream>
#include <filesystem>
// 1. 定义抽象的日志策略接口
class LogStrategy {
public:
// 虚析构函数(非常重要的工程细节,后面会单独说)
virtual ~LogStrategy() = default;
// 纯虚函数:只规定"必须具备同步写日志的能力",不给默认实现
virtual void SyncLog(const std::string &message) = 0;
};
// 2. 终端输出策略
class ConsoleStrategy : public LogStrategy {
public:
void SyncLog(const std::string &message) override {
// 多线程环境下加锁保护(此处用伪代码 LockGuard 示意)
// LockGuard lockguard(_mutex);
std::cerr << message << std::endl;
}
~ConsoleStrategy() {}
private:
// Mutex _mutex;
};
const std::string defaultpath = "./log";
const std::string defaultfilename = "log.txt";
// 3. 文件输出策略
class FileLogStrategy : public LogStrategy {
public:
FileLogStrategy(const std::string &path = defaultpath,
const std::string &name = defaultfilename)
: _logpath(path), _logfilename(name)
{
// 自动检查并创建日志目录
try {
if (!std::filesystem::exists(_logpath)) {
std::filesystem::create_directories(_logpath);
}
} catch (const std::filesystem::filesystem_error &e) {
std::cerr << e.what() << '\n';
}
if (!_logpath.empty() && _logpath.back() != '/') {
_logpath += "/";
}
}
void SyncLog(const std::string &message) override {
// LockGuard lockguard(_mutex);
std::string targetlog = _logpath + _logfilename;
// 写日志必须以追加方式 (std::ios::app) 写入
std::ofstream ofs(targetlog, std::ios::app);
if (ofs.is_open()) {
ofs << message << std::endl;
}
}
private:
std::string _logpath;
std::string _logfilename;
// Mutex _mutex;
};
到这里,我们已经定义好了统一的策略接口 LogStrategy,以及 ConsoleStrategy、FileLogStrategy 这些具体策略。
如果后续 Logger 持有一个 LogStrategy 指针或智能指针,并把真正的日志输出行为交给当前策略对象:
cpp
_strategy->SyncLog(message);
那么 Logger 就不需要关心当前使用的是控制台策略还是文件策略。不同日志行为可以被独立替换,这时整个结构就构成了典型的策略模式。
七、虚析构函数:多态体系里非常容易踩坑的工程细节
如果你回看刚才写下的 LogStrategy 代码,可能会注意到这样一个细节:
cpp
class LogStrategy {
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0;
};
为什么析构函数前面也需要 virtual?
原因和我们前面一直在使用的"父类指针指向子类对象"有关。例如:
cpp
LogStrategy* strategy = new FileLogStrategy();
此时 strategy 的静态类型是 LogStrategy*,但它实际指向的是 FileLogStrategy 对象。等对象不再使用时,我们通常会:
cpp
delete strategy;
如果基类析构函数不是虚函数,那么通过基类指针删除派生类对象会产生未定义行为,派生类自己的析构过程无法得到正确保证。
而如果基类析构函数声明为虚函数:
cpp
virtual ~LogStrategy() = default;
那么执行:
cpp
delete strategy;
时,程序会根据对象的真实类型正确执行完整的析构过程:
cpp
FileLogStrategy 析构
↓
LogStrategy 析构
这在工程代码中非常重要。因为派生类内部可能管理文件句柄、动态内存、Socket、锁等资源,如果派生类析构函数没有被正确执行,就可能造成资源泄漏。
因此,在多态体系中可以记住一个非常实用的原则:只要一个类会作为多态基类,并且可能通过基类指针删除派生类对象,就应该把基类析构函数声明为 virtual**。**
所以抽象接口中经常会看到这样的组合:
cpp
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string& message) = 0;
};
其中,纯虚函数负责规定"派生类必须实现什么能力",虚析构函数则负责保证这些派生类对象以后能够通过基类指针被安全销毁。
总结
经过前面的水果加工和日志策略两个例子,可以把多态的典型使用场景总结为:当多个对象属于同一个抽象体系,拥有相同的行为接口,但具体实现各不相同,并且上层代码希望通过统一方式管理和调用这些对象时,就适合考虑使用多态。
例如日志系统中:
cpp
LogStrategy
|
|-- ConsoleStrategy -> 输出到终端
|-- FileLogStrategy -> 写入文件
|-- RemoteStrategy -> 发送到远程服务器
上层只需要面对统一的 LogStrategy 接口,具体执行哪一种日志策略则交给当前的策略对象决定。当这些不同实现代表一组可以相互替换的行为方案,并由 Logger 这样的上层对象持有策略接口、把行为委托给具体策略执行时,就形成了典型的策略模式(Strategy Pattern)。
前面几个核心知识点也可以分别用一句话概括:
-
继承(Inheritance):建立类之间的 Is-A 关系,把多个相关类型纳入同一个类型体系,并复用基类中的公共成员。
-
函数重写(Override):派生类针对基类中的虚函数提供自己的实现,让同一个行为在不同子类中拥有不同表现。
-
虚函数(Virtual Function):让通过基类指针或引用进行的函数调用,可以在运行时根据对象的真实类型选择正确的派生类实现。
-
纯虚函数(Pure Virtual Function):基类主要负责规定"必须具备什么能力",由派生类负责完成具体行为。
把它们串起来就是:
继承
↓
建立统一的类型体系
函数重写
↓
让不同子类拥有不同实现
虚函数
↓
让程序在运行时选择正确实现
纯虚函数
↓
进一步把基类变成统一的抽象接口
最终达到的效果就是:上层依赖抽象接口,底层负责具体实现;新增实现时尽量扩展新的派生类,而无需反复修改原有调用逻辑。