设计模式的六大原则

前言

最近在看「设计模式之禅」这本书,想将里面的一些关键点,结合自己平常的开发经验,写点流水账。本文主要罗列下设计模式的六大原则。

单一职责原则

定义

单一职责原则的英文名称为 Single Responsibility Principle,简称 SRP。这个概念最早是由 Robert C. Martin 提出,作者本人对该原则的解释为「A class should have only one reason to change」,翻译过来就是「一个类应当仅有一个原因引起它的变化」。

解读

这句话很好翻译,但这样翻译反而更不易于理解。我更倾向于这样去理解:如果一个类将来可能需要修改,且修改原因都能归属到一类,那么它是符合单一职责原则的,反之则不是。

从上面的解释中,应该很容易发现槽点:原因归类没有标准。所以类的职责划分也没有量化标准,什么时候该对类拆分没有绝对客观的答案。

若职责划分过粗,项目中会有许多巨无霸类,后期修改如履薄冰;若职责划分过细,导致类的数目剧增,项目不易维护。所以这个原则遵循的程度要根据项目实际情况做调整,非常考验设计人员的编码经验和项目大局观。

延伸

单一职责原则不仅仅适用于类(和接口),同样也适用于方法,一个方法应尽可能只做一件事情。

里氏替换原则

定义

里氏替换原则(Liskov Substitution Principle)是对子类的特别约束,它由 芭芭拉·利斯科夫(Barbara Liskov)在 1987 年在一次会议上名为「数据的抽象与层次」的演说中首先提出。

该原则的内容可以描述为「派生类(子类)对象可以在程式中代替其基类(超类)对象」。

解读

更通俗一点的解释:只要父类能出现的地方,其子类就可以出现,而且将父类替换为子类也不会产生任何错误或异常。可能你会有疑问「为什么要有这样的原则?」。

假设有这样一个场景:有一个已经在使用的计算器类 Cal,能够计算简单的加减乘除;然而因为某处业务逻辑的调整,需要能够进行一些高级的计算(比如求余数)。

针对这个场景,我们很容易想到解决方案:因为原 Cal 类已经被多处使用,我们不应该在 Cal 上修改。所以我们应当选择继承的方式,扩展出一个科学计算器类 SciCal 并实现业务需要的功能。同时还应该保证将代码中的 Cal 类替换为 SciCal 类之后,不对其它业务代码造成影响。

可以看到,为了尽可能地减少修改以及修改带来的安全隐患,我们采用了这样的解决方案,而它其实就是里氏替换原则的具体实践。

鲁迅曾经说过「地上本没有路,走的人多了,也便成了路」。其实这些所谓的设计原则也一样,它是开发人员根据自己血与泪的开发经验总结出来的最佳实践方针。

依赖倒置原则

定义

在面向对象编程领域中,依赖倒置原则(Dependency inversion principle,DIP)是指一种特定的解耦形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。原则规定:

  1. 高层模块不应该依赖低层模块,两者都应该依赖抽象接口
  2. 抽象接口不应该依赖具体实现
  3. 具体实现应该依赖抽象接口

解读

图 1 中,高层对象 A 依赖于底层对像 B 的实现,不符合依赖倒置原则。再看图 2,将高层对象 A 的依赖抽象为一个接口 A,底层对象 B 实现了接口 A,符合依赖倒置原则。

Dependency_inversion

举个实在点的例子,Web 应用开发通常会用到缓存,缓存系统有多种(比如 Redis 和 Memcached),每个缓存系统都有读写删的操作。

一种符合依赖倒置原则的做法,则是将对缓存系统的操作抽象为一个接口,再根据不同的缓存系统具体实现接口中定义的方法。

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
52
53
54
55
56
57
58
59
60
61
62
interface CacheInterface
{
public function read(string $key);
public function write(string $key, string $value);
public function delete(string $key);
}

class Redis implements CacheInterface
{
public function read(string $key)
{
//...
}

public function write(string $key, string $value)
{
//...
}

public function delete(string $key)
{
//...
}
}

class Memcached implements CacheInterface
{
public function read(string $key)
{
//...
}

public function write(string $key, string $value)
{
//...
}

public function delete(string $key)
{
//...
}
}

class Demo
{
private $cache;

public function __construct(CacheInterface $cacheInterface)
{
$this->cache = $cacheInterface;
}

public function demo()
{
$this->cache->write('test', 'test123');
echo $this->cache->read('test');
$this->cache->delete('test');
}
}

$demo = new Demo(new Memcached());
$demo->demo();

上面的代码中,Demo 类对 CacheInterface 接口依赖,Redis 类和 Memcached 类分别实现该抽象类。在实际使用时,可以根据需要将不同的缓存系统传递给 Demo 类。

这样的好处在于,类间的依赖关系减少了,无论以后需要的缓存系统有多少,都是依赖于同一个缓存接口。而且也有利于并行开发:一旦接口定义好,Demo 类的开发和具体缓存系统实现可以交给不同的开发人员。

接口隔离原则

定义

接口隔离原则(Interface-Segregation Principles,ISP)指明客户(Client)应该不依赖于它不使用的方法。

解读

直白地说,就是不要建立臃肿庞大的接口,应该尽可能细化接口,接口中的方法尽量少。需要注意的是,该原则虽然与单一职责乍一看一样,实则不然。

单一职责要求的是职责单一,是从业务逻辑上的划分,而接口隔离原则要求接口中的方法尽可能少,虽然一个接口中所有方法都是围绕一个职责,但是这个接口仍可能不符合接口隔离原则。

举个例子,假设有一个接口 A 包含了 10 个查询用户的方法,其中 5 个是普通查询方法,另外 5 个是给管理模块使用的高级查询方法。根据接口隔离原则,应该对接口 A 进行拆分,分成 A1 和 A2,分别包含普通查询方法和高级查询方法,给不同的模块使用。

这样的好处在于,模块的开发人员可以放心地使用接口暴露的方法,而接口开发方在日后维护的时候,也比较清楚其中的依赖关系,降低「牵一发而动全身」的 BUG 的出现概率。

延伸

应该首先根据单一职责原则,从业务上对接口进行细分,再根据接口使用方等条件,对接口进行细分,并压缩接口暴露的公有方法。

迪米特法则

定义

迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP)。虽然名字不同,但描述的是同一个规则:一个对象应该对其它对象有最少的了解。

通俗地讲,对于被依赖类而言,无论实现逻辑多复杂,都尽量地将逻辑封装在内部,对外除了提供公有方法,不对外泄露任何信息。

解读

迪米特法则还有另外一种解释:只和自己直接的朋友交流。首先,看一下什么是朋友和直接的朋友:

每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

以依赖倒置中的代码为例,一种不符合迪米特法则的写法如下。Demo 类中的 demo 方法中,Memcached 以局部变量的形式出现在 Demo 类中,所以它不是 Demo 类的直接朋友,所以违背了迪米特原则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
....
*/
class Demo
{
public function demo()
{
$cache = new Memcached();
$cache->write('test', 'test123');
echo $cache->read('test');
$cache->delete('test');
}
}

$demo = new Demo();
$demo->demo();

延伸

遵循迪米特法则可以从以下几个设计思路入手:

  • 不要将依赖类以局部变量的形式在类中使用
  • 依赖类尽可能少地公布公有方法
  • 如果一个方法放在本类中,既不增加类间关系,也不对本类产生负面影响,那就放置在本类中

开闭原则

定义

Software entities like classes, modules and functions should be open for extension but closed for modifications.

解读

翻译过来就是,一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。其含义是指,对于一个软件实体(类、模块、函数),应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。

开闭原则是上述五个原则的精神领袖,上述五个原则是对开闭原则在不同角度的解释和具体实施方式。遵循开闭原则的诸多好处,如对测试友好、提高代码复用性、提高代码可维护性等等就不展开阐述了(主要是懒得写了)。

欲知后事如何,请看下文分解!