0%

面向对象-设计模式

一、总论

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

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

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

很多设计模式解决的复杂度问题,是客观存在的,认识这些问题有助于设计出更加简洁健壮的架构;但是有些问题可能只是类似 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)是一种操作一组对象的操作,它的目的是不改变对象的定义,但允许新增不同的访问者,来定义新的操作。

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