程序语言和设计模式是两个维度上的事情,设计模式更像是「画设计图的方法」,程序语言更像是「把图纸生产生工件的手艺」。不过在软件工程上来说,二者的关系会更加紧密一些。
记得有一段时间,软件产品飞速发展,那个时候软件工程理论和设计模型在业内被疯狂推崇,一度成为了的「标杆」。与之对应的「面向对象的设计理论」也是如雷贯耳。但后来逐渐趋于平静,一方面是大家开始发现,国内的软件行业发展与国外的软件行业发展起点不同,链路也不同。我们似乎是直接跳过了「传统软件阶段」,直接进入了「互联网项目阶段」,互联网项目的特点在于,一开始不会有特别完整而详细的计划,随着业务和需求的变化再不断完善和迭代。
但是,一些项目在经历成百上千次迭代后,就会出现各种各样的问题,包括历史遗留问题,前后不统一问题,代码杂糅问题等等。因为每次迭代都是小步的,而主要又会以业务交付为第一目标,所以在开发时更容易「图快」而不是「图稳」。所以可能就会在原本有「一点点」不整洁的代码上添「一丢丢」的杂乱。但日积月累这个问题点就会被逐渐放大,甚至可能会「埋雷」,当有一天爆发的时候,我们再去非常痛苦地定位和修复。
因此,「设计」的重要性就愈发重要。我们要的是,能够适应快速变化业务的设计,当业务功能发生改变时能够快速响应,同时还不能引入新的杂乱。所以各种各样的设计方法、理论模型、模板框架就开始百家争鸣,令人眼花缭乱。不过最终,我们还是要根据自己的业务特点来选择最合适的设计方式,设计没有「唯一正确的」,只有「最合适的」。
但我们在追求好的设计的同时,也要考虑另一个实际问题,就是如何把这种设计沉淀下来,成为实际的软件产品。一个团队不能只有架构师,开发人员一定是不可或缺的,但不仅「把产品抽象成模型」是一门学问,「把模型编写成代码」也同样是一门学问,但是人们更容易忽视后者,这就是笔者写这个系列文章的初衷,一个好的设计,要经历好的开发,才能变成好的产品。
本系列文章着重介绍的是如何用C++实现一种设计模式,并不会过多讨论「如何设计」。也就是说,前提是你已经确定好用这种设计方式了,然后我们来讨论如何把它变成C++代码。(这里提到的「设计模式」并不特指「OOP设计模式」,也不受限于其他资料中对这个词语的定义,读者可以将其理解为「用C++来实现各种设计」)
有以下几点需要说明:
- 设计模式到C++代码的映射方式一定不是唯一的,本文不可能100%覆盖。
- 受限于语言特性,有一些设计模式原本就不适合C++,可能这种情况换其他语言会很优雅,但硬用C++来做会显得很别扭,希望读者可以理解,笔者也会在出现这种情况的时候给予说明。
- 本系列文章中的示例主要用C++17标准,不会区分C++98/11/14/17的特性,对于使用C++20标准会有比较明显改善的例子会再附加C++20标准的写法,并给予说明。
- 本系列文章涉及面比较广,也比较杂,并不会有明确的脉络,笔者会慢慢总结和整理,因此会持续慢更!慢更!
面向对象可以说是一切现代软件设计方法的理论基础。在面向对象理论中,提出了抽象、实例、属性、行为等概念,还有抽象之间的继承、实现、组合、依赖等关系。那么这一章我们就来研究一下,如何用C++实现这些基础的OO关系。
另外这里需要着重说明的一点是,在接下来的描述中,大家一定要区分两个概念,一个是「OO理论中的概念」,另一个是「C++语言的语法现象」。在这篇文章中,我们着重研究的就是二者之间的关系,因此一定要将其区分开。
比如说OO理论中的「继承」指的是「A is a B」这种抽象关系,而C++语法中的「继承」指的是class A : B
的这种语法,二者本是两个维度的事情,只不过,我们可以用C++类的继承来表示OO理论中的继承关系,我们要研究的就是二者之间如何联系。所以希望读者一定要将概念区分开。
另外,希望读者可以抛开固有印象,理论概念与实际的语言沉淀的映射关系绝非一对一,因此希望读者可以看到多种实现,比较其优劣。
抽象指的就是对某个实体的行为特点进行分析提取,落地成为「属性」和「行为」。用C++语言来表示,「属性」原本就是实体对应的数据,因此就可以成为「变量」;而「行为」原本就是实体可以进行的操作(包括修改数据,或是与其他实体之间的交互),因此就可以成为「函数」。
举一个简单的例子,我们的抽象是「数量」,数量这个抽象自然只有一个属性,就是数量的值。行为有「增加」「减少」「清零」「读取」。那么按照上面所说「变量」和「函数」,就应该这样来写:
using Amount = int;
void Add(int &Amount, int delta) {
amount += delta;
}
void Reduce(int &Amount, int delta) {
amount -= delta;
}
void Clear(int &Amount) {
amount = 0;
}
Amount GetAmount(const int &Amount) {
return amount;
}
与此同时,我们还需要有一个用于「创建」实例的函数:
Amount CreateAmount(int origin_value) {
return Amount{origin_value};
}
我相信有读者看到这里可能会一脸懵逼,甚至会在心里「问候」笔者的家人……你写的这代码是来凑行数的吧?到底有啥意义呢?谁会这么写呢?别急,笔者用的是一个简单的例子,为了暴露出问题的本质。接下来我们就在这个基础上继续复杂。
上面展示的是只有1个属性的情况,但如果属性有多个呢?比如说,我们的抽象是「平面上的点」,那它应该有「横坐标」和「纵坐标」两个属性。我们要求它要实现一个「计算平面上2点之间的距离」的行为。如果按照刚才的写法,那么应该是这样:
#include
using PointX = double;
using PointY = double;
double Distance(const PointX &x1, const PointY &y1, const PointX &x2, const PointY &y2) {
return std::sqrt(std::pow(x1 - x2, 2) + std::pow(y1 - y2, 2));
}
// 但是这个创建的函数怎么写?
我们发现,如果用之前的写法,最大的问题在于,同一个实体的多个属性无法统一管理,就比如说上面的x1
和y1
明明属于同一个实体,但这里却用了2个变量。同时也造成了我们不容易编写「创建点实例」的函数。
因此,我们想到,可以用结构体来表示「抽象」的「属性」,原因很简单,因为「属性」往往不止一个,而是一个集合,因此需要归集起来。那么,我们就可以把刚才的代码改写成这样:
#include
// 用结构体表示属性集合
struct Point {
double x, y;
};
// 创建点
Point CreatePoint(double x, double y) {
return Point{x, y};
}
// 计算两点间距离
double Distance(const Point &p1, const Point &p2) {
return std::sqrt(std::pow(p1.x - p2.x, 2) + std::pow(p1.y - p2.y, 2));
}
因此,用C++表示「抽象」的方式,(至少现阶段)就是用「结构体」来表示属性,用「函数」来表示行为。
上一节中,我们已经可以用C++来表示「抽象」了,但它暂时还不能满足OO中「封装」的要求。在OO理论中,「封装」有2个要求,一个是抽象的属性和行为应当作为一个整体,也就是在外部看来,某一个抽象应该已经包含了内部的所有属性和行为;另一个是抽象应当只对外提供必要的接口,也就是说,一部分属性和行为应当在外部无感知的,外部对于这些细节是不可见的。
这里举一个例子,我们这次的抽象是「咖啡机」。我们在用咖啡机的时候,并不关心里面是怎么磨咖啡豆,怎么冲水的,只需要「补充咖啡豆」「补充水」「按按钮出咖啡」即可。所以按照这个思路,其中的「磨咖啡豆」「烧水」「冲泡咖啡」这些操作都应该是外部不感知的。另外像是「咖啡豆余量」「水余量」这些属性,也不应该是外部直接可以操作的,而是要通过「补充咖啡豆」「补充水」等操作来完成。
第一步,我们先按照刚才的方式来编写代码:
#include
#include
struct CoffeeMaker {
size_t coffee_bean_amount;
size_t water_amount;
};
CoffeeMaker CreateCoffeeMaker() {
return CoffeeMaker{};
}
// 补充咖啡豆
void AddCoffeeBean(CoffeeMaker &cm, size_t amount) {
cm.coffee_bean_amount += amount;
}
// 补充水
void AddWater(CoffeeMaker &cm, size_t amount) {
cm.water_amount += amount;
}
// 对外告警
void ReportWarning(const CoffeeMaker &cm, const std::string_view msg) {
// 这里用控制台输出代替告警
std::cout << msg << std::endl;
}
// 磨咖啡豆
void GrindBean(CoffeeMaker &cm) {
if (cm.coffee_bean_amount == 0) {
ReportWarning(cm, "咖啡豆不足,请补充!");
throw;
}
cm.coffee_bean_amount -= 1; // 假设一次消耗1个单位的咖啡豆
}
// 烧水
void HeatUpWater(CoffeeMaker &cm) {
if (cm.water_amount == 0) {
ReportWarning(cm, "水不足,请补充!");
throw;
}
cm.water_amount -= 1; // 假设一次消耗1个单位的水
}
// 冲泡咖啡
void BrewingCoffee(CoffeeMaker &cm) {
// 省略这里的实际步骤
}
// 按按钮出咖啡
void MakeCoffee(CoffeeMaker &cm) {
try {
GrindBean(cm);
HeatUpWater(cm);
BrewingCoffee(cm);
} catch(...) {}
}
接下来就是说,我们要实现这里「封装」的要求,要把「属性」和「行为」放到一起,并且还要进行权限的管理(就是说指定哪些是外部可用,哪些是只有内部可用)。因此这里我们用结构体的成员函数、成员权限的功能来实现OO中的「封装」:
#include
#include
struct CoffeeMaker {
public:
// 创建函数转构造函数
CoffeeMaker();
// 操作函数转成员函数,操作对象参数转this隐式传递
void AddCoffeeBean(size_t amount);
void AddWater(size_t amount);
void MakeCoffee();
private: // 不希望外露的用私有修饰
// 操作对象参数为入参的只读动作,转为const修饰的成员函数(其实就是修饰隐式参数this)
void ReportWarning(const std::string_view msg) const;
void GrindBean();
void HeatUpWater();
void BrewingCoffee();
private:
size_t coffee_bean_amount;
size_t water_amount;
};
CoffeeMaker::CoffeeMaker() {
}
// 补充咖啡豆
void CoffeeMaker::AddCoffeeBean(size_t amount) {
this->coffee_bean_amount += amount;
}
// 补充水
void CoffeeMaker::AddWater(size_t amount) {
this->water_amount += amount;
}
// 对外告警
void CoffeeMaker::ReportWarning(const std::string_view msg) const {
// 这里用控制台输出代替告警
std::cout << msg << std::endl;
}
// 磨咖啡豆
void CoffeeMaker::GrindBean() {
if (this->.coffee_bean_amount == 0) {
this->ReportWarning("咖啡豆不足,请补充!"); // 隐式参数转方法调用方,f(obj, arg)转obj->f(arg)
throw;
}
this->coffee_bean_amount -= 1;
}
// 烧水
void CoffeeMaker::HeatUpWater() {
if (this->water_amount == 0) {
this->ReportWarning("水不足,请补充!");
throw;
}
this->.water_amount -= 1;
}
// 冲泡咖啡
void CoffeeMaker::BrewingCoffee() {
// 省略这里的实际步骤
}
// 按按钮出咖啡
void CoffeeMaker::MakeCoffee() {
try {
this->GrindBean();
this->HeatUpWater();
this->BrewingCoffee();
} catch(...) {}
}
而因为C++中class
是struct
的一个语法糖(更改了默认成员权限属性),因此上面也可以改成class
,这就变成了我们最常见的C++class
来进行「封装」的语法。
下一篇继续研究OO理论的其他概念的实现方式。
慢更系列文章~请持续期待~