Melo's Blog

面向对象六大原则和设计模式

写在前面:

最近这段时间,无论是写文章的频率,还是新知识的汲取,都不如以往有热情。总是拿工作忙当借口,但是心里明白还是懒和拖延作祟。静下心来反思了最近的状态,还是要及时止住惰性,保持一个良好的、有节奏的学习步调。

本文的内容来自 Android 进阶书籍《从小工到专家》,六大原则和设计模式章节。读过之后觉得非常受用,所以为大家整理出来,之后也会带来 设计模式单元测试 以及 代码重构 的介绍,希望我们能早日从码农变成一个开发工程师。话不多说,下面带来书中原汁原味的内容。

在工作的初期,我们可能会经常有这样的感受,自己的代码接口设计混乱、代码耦合较为严重、一个类的代码过多等等,当自己回头再看这些代码时可能会感慨,怎么能写成这个鸟样。再看那些知名的开源库,它们大多有整洁的代码、清晰简单的接口、职责单一的类,这个时候我们会通常会捶胸顿足而感慨:什么时候老夫才能写出这样的代码!

在做开发的这些年中,我渐渐的感觉到,其实国内的一些初、中级工程师写的东西不规范或者说不够清晰的原因是缺乏一些指导规则。他们手中挥舞着面向对象的大旗,写出来的东西却充斥着面向过程的气味。也许是他们不知道有这些规则,也许是他们知道但是不能很好的运用到实际的代码中,亦或是他们没有在实战项目中体会到这些原则能够带来的优点,以至于他们对这些原则并没有足够的重视。

本章没有详细介绍 OOP 六大原则、设计模式、反模式等内容,只是对它们做了一些简单的介绍。并不是因为它们不重要,而是由于它们太重要,因此我们必须阅读更详尽的书籍来涉入这些知识,设计模式可以参考《设计模式之禅》、《设计模式:可复用面向对象软件的基础》以及《Android源码设计模式解析与实战》,反模式的权威书籍则为《反模式:危机中软件、架构和项目的重构》一书。

(打字好累…)

面向对象六大原则

在此之前,有一点需要大家知道,熟悉这些原则并不是说你写出的程序就一定灵活、清晰,只是为你优秀的代码之路铺上了一层栅栏,在这些原则的指导下,你才能避免陷入一些常见的代码泥沼,从而让你写出优秀的东西。

单一职责原则

单一职责原则的英文名称是 Single Responsibility Principle,简称是 SPR,简单地说就是一个类只做一件事,这个设计原则备受争议却又极其重要。只要你想和别人争执、怄气或者是吵架,这个原则是屡试不爽的。因为单一职责的划分界限并不是如马路上的行车道那么清晰,很多时候都是需要个人经验来界定。当然,最大的问题就是对职责的定义,什么是类的职责,以及怎么划分类的职责。

试想一下,如果你遵守了这个原则,那么你的类就会划分的很细,每个类都有比较单一的职责,这不就是高内聚、低耦合么!当然,如何界定类的职责就需要你的个人经验了。

我们定义一个网络请求的类,来体现 SRP 的原则,来执行网络请求的接口,代码如下:

1
2
3
4
5
6
public interface HttpStack {
/**
* 执行 Http 请求,并且返回一个 Response
*/
public Response performRequest(Request<?> request);
}

从上述程序中可以看到,HttpStack 只有一个 performRequest 函数,它的职责就是执行网络请求并且返回一个 Response,它的职责很单一,这样在需要修改执行网络请求的相关代码时,只需要修改实现 HttpStack 接口的类,而不会影响其他类的代码。如果某个类的职责包含有执行网络请求、解析网络请求、进行 gzip 压缩、封装请求参数等,那么在你修改某处代码时就必须谨慎,以免修改的代码影响了其它的功能。当你修改的代码能够基本上不影响其他功能。这就一定程度上保证了代码的可维护性。注意,单一职责原则并不是一个类只能有一个函数,而是说这个类中的函数所做的工作是高度相关的,也就是高内聚。 HttpStack 抽象了执行网络请求的具体过程,接口简单清晰,也便于扩展。

优点:

  • 类的复杂性降低,实现什么职责都有清晰明确的定义。
  • 可读性提高,复杂性降低,那当然可读性提高了。
  • 可维护性提高,可读性提高了,那当然更容易维护了。
  • 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。

里氏替换原则

面向对象的语言的三大特点是继承、封装、多态,里氏替换原则就是依赖于继承、多态这两大特性。里氏替换原则简单来说就是所有引用基类、接口的地方必须能透明地使用其子类的对象。通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何报错或者异常,使用者可能根本就不需要知道是子类还是父类。但是,反过来就不行了,有子类出现的地方,父类未必就能使用。

还是以 HttpStack 为例, HttpStack 来表示执行网络请求这个抽象概念。在执行网络请求时,只需要定义一个 HttpStack 对象,然后执行 performRequest 即可,至于 HttpStack 的具体实现由更高层的调用者指定。这部分代码在 RequestQueue 类中,示例如下:

1
2
3
4
5
6
7
8
/**
* @param coreNums 核心线程数
* @param httpStack http 执行器
*/
protected RequestQueue(int coreNums, HttpStack httpStack) {
mDispatcherNums = coreNums;
mHttpStack = httpStack != null ? httpStack : HttpStackFactory.createHttpStack();
}

HttpStackFactory 类的 createHttpStack 函数负责根据 API 版本创建不同的 HttpStack,实现代码如下:

1
2
3
4
5
6
7
8
9
10
/**
* 根据 sdk 版本选择 HttpClient 或者 HttpURLConnection
*/
public static HttpStack createHttpStack() {
int runtimeSDKApi = Build.VERSION.SDK_INT;
if (runtimeSDKApi >= GINGERBREAD_SDK_NUM) {
return new HttpUrlConnStack();
}
return new HttpClientStack();
}

上述代码中, RequestQueue 类中依赖的是 HttpStack 接口,而通过 HttpStackFactory 的 createHttpStack 函数返回的是 HttpStack 的实现类 HttpClientStack 或 HttpUrlConnStack。这就是所谓的里氏替换原则,任何父类、父接口出现的地方子类都可以出现,这不就保证了可扩展性吗!

任何实现 HttpStack 接口的类的对象都可以传递给 RequestQueue 实现网络请求的功能,这样执行网络请求的方法就有很多种可能性,而不是只有 HttpClient 和 HttpURLConnection。例如,用户想使用 OkHttp 作为新的网络搜索执行引擎,那么创建一个实现了 HttpStack 接口的 OkHttpStack 类,然后在该类的 performRequest 函数中执行网络请求,最终将 OkHttpStack 对象注入 RequestQueue 即可。

细想一下,很多应用框架不就是这样实现的吗?框架定义一系列相关的逻辑骨架和抽象,使得用户可以将自己的实现注入到框架中,从而实现变化万千的功能。

优点:

  • 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性。
  • 提高代码的重用性。
  • 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,很多开源框架的扩展接口都是通过继承父类来完成的。
  • 提高产品或项目的开放性。

缺点:

  • 继承是侵入性的。只要继承,就必须拥有父类所有的属性和方法。
  • 降低了代码的灵活性。子类必须继承父类的属性和方法,让子类自由的世界中多了些约束。
  • 增强了耦合性。当父类的常量、变量和方法被修改时,必须要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的后果—大量的代码需要重构。

依赖倒置原则

依赖倒置原则这个名字看起来有点不好理解,“依赖”还有“倒置”,这到底是什么意思?依赖倒置原则的几个关键点如下。

  • 高层模块不应该依赖底层模块,两者都应该依赖其抽象。
  • 抽象不应该依赖细节。
  • 细节应该依赖抽象。

在 Java 语言中,抽象就是指接口或者抽象类,两者都是不能直接被实例化的。细节就是实现类、实现接口或者继承抽象类而产生的类,其特点就是可以直接被实例化,也就是可以加上一个关键字 new 产生一个对象。依赖倒置原则是 Java 语言中的表现就是:模块间的依赖通过抽象发生,实现类之间不发生直接依赖的关系,其依赖关系是通过接口或者抽象类产生的。软件先驱们总是喜欢将一些理论定义得很抽象,弄得不是那么容易理解,其实就是一句话:面向接口编程,或者说是面向抽象编程,这里的抽象是指抽象类或者是接口。面向接口编程是面向对象精髓之一。

采用依赖倒置原则可以减少类之间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。

在前面我们的例子中, RequestQueue 实现类依赖于 HttpStack 接口(抽象),而不依赖于 HttpClientStack 与 HttpUrlConnStack 实现类(细节),这就是依赖倒置原则的体现。如果 RequestQueue 直接依赖了 HttpClientStack ,那么 HttpUrlConnStack 就不能传递给 RequestQueue 了。除非 HttpUrlConnStack 继承自 HttpClientStack 。但这么设计显然不符合逻辑,他们两个之间是同等级的“兄弟”关系,而不是父子的关系,因此,正确的设计就是依赖于 HttpStack 抽象,HttpStack 只是负责定义规范,而 HttpClientStack 和 HttpUrlConnStack 分别实现具体的功能。这样一来也同样保证了扩展性。

优点:

  • 可扩展性好
  • 耦合度低

开闭原则

开闭原则是 Java 世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。开闭原则的定义是:一个软件实体类,模块和函数应该对扩展开放,对修改关闭。在软件的生命周期内,因为变化、升级和维护等原因,需要对软件原有的代码进行修改时,可能会给旧代码引入错误。因此,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。

在软件开发过程中,永远不变的就是变化。开闭原则是使我们的软件系统拥抱变化的核心原则之一。对扩展开放,对修改关闭这样的高层次概括,即在需要对软件进行升级、变化时应该通过扩展的形式来实现,而非修改原有代码。当然这只是一种比较理想的状态,是通过扩展还是通过修改旧代码需要依据代码自身来定。

在我们封装的网络请求模块中,开闭原则体现的比较好的就是 Request 类族的设计。我们知道,在开发 C/S 应用时,服务器返回的数据多种多样,有字符串类型、xml、Json 等。而解析服务器返回的 Response 的原始数据类型则是通过 Request 类来实现的,这样就使得 Request 类对于服务器返回的数据格式有良好的扩展性,即 Request 的可变性太大。

例如,返回的数据格式是 Json,那么使用 JsonRequest 请求来获取数据,它会将结果转成 JsonObject 对象,我们看看 JsonRequest 的核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 返回的数据格式为 Json 的请求,Json 对应的对象类型为 JSONObject
public class JsonRequest extends Request<JSONObject> {
public JsonRequest(HttpMethod method, String url,
RequestListener<JSONObject> listener) {
super(method, url, listener);
}
// 将 Response 的结果转化为 JSONObject
@Override
public JSONObject parseResponse(Response response) {
String jsonString = new String(response.getRawData());
try {
return new JSONObject();
} catch (JSONException e) {
e.printStackTrace();
}
return null;
}
}

JsonRequest 通过实现 Request 抽象类的 parseResponse 解析服务器返回的结果,这里将结果转换为 JSONObject,并且封装到 Response 类中。

例如,我们的网络框架中,添加对图片请求的支持,即要实现类似 ImageLoader 的功能。这个时候我的请求返回的是 Bitmap 图片,因此,我需要在该类型的 Request 中得到的结果是 Request,但支持一种新的数据格式不能通过修改源码的形式,这样可能会为旧代码引入错误,但是,你又必须实现功能扩展。这就是开闭原则的定义:对扩展开放,对修改关闭。我们看看应该如何做:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ImageRequest extends Request<Bitmap> {
public ImageRequest(HttpMethod method, String url,
RequestListener<Bitmap> listener) {
super(method, url, listener);
}
// 将 Response 的结果转化为 Bitmap
@Override
public Bitmap parseResponse(Response response) {
return BitmapFactory.decodeByteArray(response.rawData, 0, response.rawData.length);
}
}

ImageRequest 类的 parseResponse 函数中将 Response 中的原始数据转换成为 Bitmap 即可,当我们需要添加其他数据格式的时候,只需要继承自 Request 类,并且在 parseResponse 方法中将数据转换为具体的形式即可。这样通过扩展的形式来应对软件的变化或者说用户需求的多样性,既避免了破坏原有系统,又保证了软件系统的可维护性。依赖于抽象,而不依赖于具体,使得对扩展开放,对修改关闭。开闭原则与依赖倒置原则,里氏替换原则一样,实际上都遵循一句话:面向接口编程。

优点:

  • 增加稳定性
  • 可扩展性高

接口隔离原则

客户端应该依赖于它不需要的接口:一个类对另一个类的依赖应该建立在最小的接口上。根据接口隔离原则,当一个接口太大时,我们需要把它分离成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。

可能描述起来不是很好理解,我们还是以示例来加强理解吧。
我们知道,在网络框架中,网络队列中是会对请求进行排序的。内部使用 PriorityBlockingQueue 来维护网络请求队列,PriorityBlockingQueue 需要调用 Request 类的排序方法就可以了,其他的接口他根本不需要,即 PriorityBlockingQueue 只需要 compareTo 这个接口,而这个 compareTo 接口就是我们所说的最小接口,而是 Java 中的 Comparable 接口,但我们这里是指为了学习,至于哪里定义的无关紧要。

在元素排序时,PriorityBlockingQueue 只需要知道元素是个 Comparable 对象即可,不需要知道这个对象是不是 Request 类以及这个类的其他接口。它只需要排序,因此,只要知道它是实现了 Comparable 对象即可,Comparable 就是它的最小接口,也是通过 Comparable 隔离了 PriorityBlockingQueue 类对 Request 类的其他方法的可见性。

优点:

  • 降低耦合性
  • 提升代码的可读性
  • 隐藏实现的细节

迪米特原则

迪米特法则也成为最少知识原则(Least Knowledge Principle),虽然名字不同,但是描述的是同一个原则,一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或者调用的类知道得最少,这有点类似于接口隔离原则中的最小接口的概念。类的内部如何实现、如何复杂都与调用者或者依赖者没有关系,调用者或者依赖者只需要知道它需要它需要的方法即可,其他的一概不关心。类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。

迪米特原则还有一个英文解释是:Only talk to your immedate friends(只与直接的朋友通信)。什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多例如组合、聚合、依赖等。

例如在本例中,网络缓存中的 Response 缓存接口的设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 请求缓存接口
*
* @param <K> key 的类型
* @param <V> value 的类型
*/
public interface Cache<K, V> {
public V get(K key);
public void put(K key, V value);
public void remove(K key);
}

Cache 接口定义了缓存类型需要实现的最小接口,依赖缓存类的对象只需要知道该接口即可。例如,需要将 Http Response 缓存到内存中,并且按照 LRU 的规则进行存储。我们需要 LruCache 类实现这个功能。代码如下:

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
// 将请求结果缓存到内存中
public class LruMemCache implements Cache<String, Response> {
/**
* Response LRU 缓存
*
* @param key
* @return
*/
private LruCache<String, Response> mResponseCache;
public LruMemCache() {
//计算可使用的最大内存
final int maxMemory=(int) (Runtime.getRuntime().maxMemory() / 1024);
//取八分之一的可用最大内存为缓存
final int CacheSize = int maxMemory / 8;
mResponseCache = new LruCache<String, Response>(int CacheSize) {
@Override
protected int SizeOf(String key, Response response) {
return response.rawData.length / 1024;
}
};
}
@Override
public Response get(String key) {
return mResponseCache.get(key);
}
@Override
public void put(String key, Response value) {
mResponseCache.get(key, value);
}
@Override
public void remove(String key) {
mResponseCache.remove(key);
}
}

在这里,网络请求框架的直接朋友就是 Cache 或者 LruMemCache,间接朋友就是 LruCache 类。它只需要跟 Cache 类交互即可,并不需要知道 LruCache 类的存在,即真正实现了缓存功能的是 LruCache。这就是迪米特原则,尽量少地知道对象的信息,只与直接的朋友交互。

优点:

  • 降低复杂度
  • 降低耦合性
  • 增加稳定性

设计模式

在软件工程中,设计模式是对软件设计中普遍存在、反复出现的各种问题所提出的通用解决方案。这个术语是由 Erich Gamma 等人在1990 年从建筑设计领域引入到软件工程领域,从此设计模式在面向对象设计领域逐渐被重视起来。

设计模式并不直接用来完成代码的编写,而是描述在各种情况下要如何解决软件设计问题。面向对象设计模式通常以类或对象来描述其中的关系和相互作用,他们的相互作用能够使软件系统具有高内聚、低耦合的特性,并且使软件能够应对变化。

模式的4个要素

模式名称
模式名称用一两个词来描述模式的问题、解决防范和效果。基于一个模式词汇表,同行、同事之间就可以通过它们进行交流,文档中也可以通过模式名来代表一个设计。模式名可以帮助我们思考,便于我们与其他人交流设计思想以及设计结果。

问题
描述了应该在什么情况使用设计模式。它解释了设计问题和问题存在的前因后果,它可能描述了特定的设计问题,例如,某个设计不具备良好的可扩展性等,也可能描述了导致不灵活设计的类或者对象结构。

解决方案
描述了设计的组成成分,它们之间的相互关系以及各自的职责和协作方式。因为模式就像一个模板,可应用于多种不同的场合,所以解决方案并不描述一个具体的设计或者实现,而是提供设计问题的抽象描述和怎样用一个具有一般意义的类或者对象组合来解决这个问题。

效果
描述了模式应用的效果及使用模式应权衡的问题。尽管我们描述设计决策时,并不总提到模式效果,但它们对于评价设计选择和理解使用模式的代价及好处具有重要意义。软件效果大多关注对时间和空间的衡量,它们也表述了语言和实现问题。因为复用是面向对象的设计要素之一。所以模式效果包括对它系统的灵活性、扩充性或可移植性的影响,显式地列出这些效果对理解和评价这些模式很有帮助。

设计模式为反复出现的局部软件设计问题指出了通用的解决方案,在很大程度上促进了面向对象软件工程的发展。它将这些常见的设计问题一一总结,将大师们的经验、教训、设计经验分享给了所有人,使得即便是刚刚入行的工程师,也能够设计出可扩展、灵活的软件系统,大大提升了软件质量。关于设计模式领域的书籍大家可以参考《设计模式之禅》、《Android 源码设计模式解析与实战》。

避免掉进过度设计的怪圈

当你掌握一些设计模式或者方法之后,比较容易出现的问题就是过度设计。有的人甚至在一个应用中一定要将 23 种常见的设计模式运用上,这就本末倒置了。设计模式的四大要素中就明确指出,模式的运用应该根据软件系统所面临的问题来决定是否需要使用现有的设计。也就是说,再出现问题或者你预计会出现那样的问题时,才推荐使用特定的设计模式,而不是将各种设计模式套进你的软件中。

不管在设计、实现、测试之剑有多少时间都应该避免过度设计,它会打破你的反馈回路,使你的设计得不到反馈,从而慢慢陷入危险中。所以你只需要保持简单的设计,这样就有时间来测试该设计是否真的可行,然后作出最后的决策。

当设计一款软件时,从整体高度上设定一种架构模式,确定应用的整体架构,然后再分析一些重要米快的设计思路,并且保证他们的简单性、清晰性,如果有时间可以使用 Java 代码模拟一个简单的原型,确保设计是可行的,最后就可以付诸行动了。切实不要过度的追求设计,适当就好,当我们发现或者预计到将要出现问题时,在判断是否需要运用设计模式。

反模式

反模式是一种文字记录形式,描述了对某个问题必然产生的消极后果的常见解决方案。由于管理人员或者开发人员不知道更好的解决方案,缺乏决定特定问题的经验或知识,或者说不适合的条件下套用了某个设计模式,这些都会造成反模式。与设计模式类似,反模式描述了一个一般的形式,主要原因、典型症状。后果,以及最后如何通过重构解决问题。

反模式是把一般情况映射到一类特定解决方案的有效方法。反模式的一般形式为它所针对的哪类问题提供了一个易于辨识的模板。此外,它还清楚地说明了与该问题相关联的症状以及导致这一问题的内在原因:把特定设计模式应用于不正确的环境。

反模式为识别软件行业反复出现的问题提供了实际经验,并为大多数常见的问题提供了详细的解决方案。反模式对业界常见的问题进行总结,并且告诉你如何识别这些问题以及如何解决。它有效的说明了可以在不同的层次上采取的措施,以便改善应用开发过程,软件系统和对软件项目的有效管理。

总的来说,设计模式总结了在特定问题下正确的解决方案,而反模式则是告诉你在特定问题上的错误解决方案和他们的原因、解决方案,通过最终的解决方案,它能够将腐化的软件系统拉回正轨。

总结

灵活的软件设计需要知识和经验与思考,好的设计通常是经历了时间的洗礼慢慢演化而来,工程师的成长也是一样。因此,掌握必要的面向对象、设计模式、反模式等知识,并且这工作中不断实践、思考,将使你的软件设计之路走得更加从容、顺畅。

写在后面:
面向对象的六大原则在开发过程中极为重要,他们给灵活、可扩展的软件系统提供了更细粒度的指导原则。如果能很好地将这些原则运用到项目中,再在一些合适的场景运用一些经过验证过设计模式,那么开发出来的软件在一定程度上能够得到质量保证。其实六大原则最终可以简化为几个关键字:抽象、单一职责、最小化。那么在实际开发中如何权衡,实践这些原则,也是需要大家在工作过程中不断地思考、摸索、实践。