面向对象 - 设计模式

一、总论

设计模式是一个工程问题,它不能为某个问题提供具体的答案,它只能让答案写得好看一些。

更进一步地说,设计模式是用于解决代码复杂度高(不是算法复杂度)的问题,这会引发难以阅读,难以维护等伴生问题。当代码量小,复杂度低,没有什么维护需求的时候(比如说科研代码),其实是没有必要采用规范的设计模式的。

我个人学习设计模式,并不是为了写代码(因为我写的代码就是科研代码),而是为了更好的阅读代码,因为大型工程代码都会或多或少遵循设计模式的思路。

很多设计模式解决的复杂度问题,是客观存在的,认识这些问题有助于设计出更加简洁健壮的架构;但是有些问题可能只是类似 Java 等语言表述能力不够所导致的,不用太较真。


二、设计原则

设计原则的目的同样是降低代码的复杂度。它可以看作是设计模式的 “基本原理”,每个设计模式都或多或少贯彻了这些原则。

我们会介绍一些这些原则的具体内容,并简单描述一下这些原则是如何发挥作用的。总的来说,它们都是在描述 “一个好的抽象” 或者说 “一个好的接口”,应该具有哪些性质(简洁、稳定、精妙、短小)。

2.1 单一职责原则

单一职责原则(Single Responsibility Principle)指的是每一个类仅负责一项职责,当然如果更普适一些的表述,是每一个代码模块(OOP 中的类,某个函数,某个库)只负责一项职责。

这是因为当代码模块以 “功能” 为单位划分的时候,就符合了 “高内聚,低耦合” 的思想,每个模块其实以一个抽象职责的实现。代码开发者通过付出 “冥思苦想提炼抽象” 的代价,获得了 “模块化” 的优势。

当我们说 “六大原则” 的时候,其实是将单一职责原则剔除的,是因为这个原则在语义表述上,一点也不 “单一职责”,它的思想

2.2 开闭原则

开闭原则(Open Close Principle)指的是代码需要保证对于拓展开发,而对于修改关闭。说白了,就是只能增加功能,不能删除和修改功能。

代码开发者通过付出 “维护一个可能更大且更冗余的代码量” 的代价,获得了 “向后兼容性” 的优势。如何尽量规避这种代价呢?那就需要在设计之初,就尽可能避免设计出一些稳定性不强、可发展性不强的接口,避免日后为了维持这个接口而付出过大的代价。

2.3 里氏代换原则

里氏代换原则(Liskov Substitution Principle, LSP)指的是任何基类可以出现的地方,子类一定可以出现。也就是说,基类和子类是 “接口 - 实现” 的关系。

里氏规则明确了接口和实现的分离关系,这样无论实现怎么变化(多种接口),接口都可以保持稳定。

里氏规则要求写出来的代码遵循这样的一种原则 “如果鸟(基类)是会飞的,鸵鸟是一种鸟,那么鸵鸟就应该会飞”。而不幸的是,鸵鸟真的不会飞。所以基类(其实就是接口)的实际人员,就要避免给接口设计出像 “飞” 这样的非普适功能,或者避免将 “鸵鸟” 加入 “鸟类”。

2.4 依赖倒置原则

所谓的依赖倒置,就是一个代码模块,是它的接口决定了它如何实现,而不是它的实现决定了它的接口。实现依赖接口,而非反过来。

这是因为实现细节具有多变性,而接口需要相对稳定。

2.5 接口隔离原则

接口隔离原则(Interface Segregation Principle)指的是接口之间的功能都要彼此隔离而不是耦合在一起(其实也是单一职责原则的一种体现)。这是因为耦合对于维护是不利的,耦合会让依赖增多。

2.6 最小知道原则

最小知道原则(Demeter Principle)指的是一个接口应该与尽量少的其他接口发生相互作用。也是减少依赖的方式。

2.7 合成复用原则

合成复用原则(Composite Reuse Principle)指的是要尽量使用组合的方式来拓展功能,而不是采用继承的方式拓展功能。因为继承方式在某种意义上,是将基类形成了一种接口,而有些基类并没有经过良好的设计(它实现的时候是别人的子类),这种继承会引入性质不优良的接口。

而组合只是拓展了功能,并没有引入新的抽象。


三、设计模式

正如前所述,设计模式是针对特定场景来优化代码复杂度的方案,也就是哪里复杂了,哪里才需要设计模式。设计模式可以分成 “创建型,结构型,行为型” 三类,分别对应一个模块在创建时、静态结构上、动态运行逻辑上产生复杂度时的应对方案。

3.1 创建型模式

3.1.1 工厂方法

工厂方法解决的问题是,对于一个普通的对象创建,我们一般直接调用构造器方法,而这种方式有时的表达能力是不够强的。

比如说对于很多常量,我们并不需要每次都创建一个新的对象,我们可以共享对象,反正没人可以修改这些共享对象。但是只要一调用构造器,那么就会产生一个新的对象。再比如,我们希望采用批处理的形式构造对象,那么使用构造器就没有办法做到。

为了实现这些更多的语义,一个比较自然的想法就是在创建对象的上下文中加上一些代码,但是因为对象的创建可能弥散在各个地方,这些代码也会被复制很多份,就很复杂。所以更好的方法就是定义一个 “工厂方法”,将这些语义集成进去,就更好维护了。

我觉得工厂方法是唯一值得介绍的创建型方法,因为只有它在将复杂的构造代码提炼出一个新的抽象。其他模式都可以看作是在工厂方法上做出的改进。

3.1.2 抽象工厂

也就是存在多个工厂,这些工厂有一个共同的父类 “抽象工厂”,这样我们就可以用不同的工厂(相当于具体的实现)来实现不同的构造策略。

3.1.3 生成器

它指的是将构造过程拆分成多个步骤,每个步骤使用一个工厂方法。这种方法的好处除了可以让代码模块更加小以外,也更加灵活了。我们可以通过组合不同的工厂,来满足比较复杂的构造需求。

比如说对于 “两轮车,三轮车,四轮车” 的生产,我们可以简单的构造 “两轮车工厂,三轮车工厂,四轮车工厂”。当然我们也可以只维护 “车壳工厂” 和 “车轮工厂” 两个工厂来满足这种复杂的构造需求。

生成器本质是对于构造功能的解耦和自由组合。

3.1.4 原型

当我们存在复制一个对象的需求的时候,其实是有两种思路的,方案 1 :

class A {
int x;
int y;
};

A a, b;
// copy a to b
b.x = a.x;
b.y = a.y;

这种方案是有复制需求的一方在完成复制过程。

方案 2 :

class A {
int x;
int y;
class A clone() {
A b;
b.x = this->x;
b.y = this->y;
}
};

A a;
A b = a.clone();

这种方案是有复制需求的一方只是调用 clone 方法,具体的方式是交给原型来做的。

当这种复制操作比较多的时候,显然第二种方式就比较好了;此外,当被复制的一方有私有数据时,也是第二种方式比较好。

3.1.5 单例

就是全局只有一个对象的情况,其实是和 Java 具体的语法联系比较紧密了,没有什么设计上的借鉴价值。

3.2 结构型模式

3.2.1 适配器

适配器模式是 Adapter,也称 Wrapper,是指如果一个接口需要 B 接口,但是待传入的对象却是 A 接口,怎么办?

那么就在 A 外面套一层满足 B 接口的适配器就可以了。

3.2.2 桥接

其实这个模式很简单,它就是我们理解的 “组合”,也就是每个部分只负责一些小的互相独立的功能,利用多个独立功能来组合完成复杂功能。

但是我很喜欢 Bridge 的概念,桥似乎在计算机术语中,描述的就是一种将多个功能模块组合在一起的概念,比如说芯片中的南桥和北桥芯片,网络中的桥接模式。

桥接设计模式

它更像是一种 “中间人” 或者 “平台” 的概念,与汉语中的 “连接和沟通两地” 的概念有些出入。

3.2.3 组合

这里说的组合并不是和 “继承” 有对立关系的那个 “组合”,而是一种树形结构,并且采用了递归处理:

public interface Node {
private String name;
private List<Node> list = new ArrayList<>();
public String toXml() {
String start = "<" + name + ">\n";
String end = "</" + name + ">\n";
StringJoiner sj = new StringJoiner("", start, end);
list.forEach(node -> {
sj.add(node.toXml() + "\n");
});
return sj.toString();
}
}

总的来说就是树形结构的那些优点呗,局部的,递归的。

3.2.4 装饰器

装饰器(Decorator)模式,是一种在运行期动态给某个对象的实例增加功能的方法。

从实践角度来说,一些功能可能并不适合集成到类内部。比如说对某个类的 “机械打印方法”,就是打印这个类的所有数据域(用于 debug)。这种方法显然不适合在类内部实现,不然每个类都要有这样的一个愚蠢的方法了。

所以我们可以将这个打印功能单独抽离出来,然后加到(装饰)所需要的类上,至于加的方法是基类、元编程还是什么其他手段,这就因实现而定了。

3.2.5 外观

外观模式,即 Facade ,它指的是:如果客户端要跟许多子系统打交道,那么客户端需要了解各个子系统的接口,比较麻烦。如果有一个统一的 “中介”,让客户端只跟中介打交道,中介再去跟各个子系统打交道,对客户端来说就比较简单。

3.2.6 享员

享元(Flyweight)的核心思想很简单:如果一个对象实例一经创建就不可变,那么反复创建相同的实例就没有必要,直接向调用方返回一个共享的实例就行,这样即节省内存,又可以减少创建对象的过程,提高运行速度。

3.2.7 代理

代理模式,即 Proxy 。它和 Adapter 模式很类似,它们都是在原有类上在封装一层。

我个人觉得区别是,Adapter 是为了让功能可以正常使用,而 Proxy 视为了拓展功能的使用。比如说工厂方法就可以看作是构造方法的一种代理。


3.3 行为型模式

3.3.1 责任链

行为型模式都是为了解决复杂的业务流而提出的。责任链的概念有些类似于流水线,每个人只负责处理一小部分业务逻辑。我个人感觉其实它更像是一堆筛网的集合,液体从中流过,滤出去了不同的东西。

这种模式下,增添一个筛网或者调整筛网的顺序,都是非常容易的,我甚至品出了流处理的精神。

3.3.2 命令模式

这说的是,对于每个操作,我们都让他们继承自一个叫做 Command 的基类,这样我们就可以对于这些操作进行统一的管理,比如我们可以 undo, redo 这些操作,至于我们为什么要对于这些操作进行统一的管理,可能这就是具体的情景要求了(比如一个编辑器中这种操作很常见)。

我个人觉得这种思想其实有些类似于 “事务” 或者 “函数式” 的思想了,将一段可执行代码当成一个类来操作。

3.3.3 解释器

其实就是对于一些特定的问题,通用的编程语言会存在冗余繁复等问题,所以可以开发 DSL 来描述业务。解释器说的就是 DSL 的方法。

那么 DSL 的解释器的开发和维护难度呢?我觉得肯定是比不开发要难的,那我们为什么还需要 DSL 呢?我觉得是因为业务逻辑本身十分复杂且有规律,所以我们将原本的 “业务逻辑复杂度” 拆分成了 “DSL 代码复杂度 + DSL 解释器复杂度” 两个部分,或许这种拆分要比之前的复杂度要低。

3.3.4 迭代器

迭代器在 C++ Primer 中已经讨论滥了,它忽略了不同数据结构之间的差异,让我们可以以统一的方式遍历不同数据结构中的元素。也就是说,这是一种对于 “迭代” 这个操作的统一抽象。

3.3.5 中介

中介模式(Mediator)是通过引入一个中介对象,把多边关系变成多个双边关系,从而简化系统组件的交互耦合度。

说穿了,中介就是一个全局看板。

3.3.6 备忘录

备忘录模式(Memento),主要用于捕获一个对象的内部状态,以便在将来的某个时候恢复此状态。

我是觉得它和 “原型” 差不多。

3.3.7 观察者

我觉得就是发布 - 订阅模式,是一种一对多的通知机制,使得双方无需关心对方,只关心通知本身。

3.3.8 状态

状态模式类似于状态机的理念,也就是根据状态产生不同的行为。

在具体的代码实现中,有一个 State 基类,它定义了一个 process() 纯虚方法,不同的状态继承这个基类并覆盖了 process 方法。

3.3.9 策略

策略模式:Strategy,是指,定义一组算法,并把其封装到一个对象中。然后在运行时,可以灵活的使用其中的一个算法。

3.3.10 模板方法

模板方法(Template Method)是一个比较简单的模式。它的主要思想是,定义一个操作的一系列步骤,对于某些暂时确定不下来的步骤,就留给子类去实现好了,这样不同的子类就可以定义出不同的步骤。

因此,模板方法的核心在于定义一个 “骨架”。

3.3.11 访问者

访问者模式(Visitor)是一种操作一组对象的操作,它的目的是不改变对象的定义,但允许新增不同的访问者,来定义新的操作。

有一说一非常复杂,我看懂了,但是我觉得没有什么特殊的。