在工作初期,我们可能会有这样的感觉,自己的代码接口设计混乱、代码耦合较为严重、一个类的代码过多等,当自己回过头再看这些代码时可能都会感慨怎么写成那样。再看那些知名的开源库,他们大多有着整齐的代码、清晰简单的接口、职责单一的类,这个时候我们通常会捶胸顿足而感叹:什么时候老夫能写出这样的代码!
其实在做开发这些年中,我渐渐的感觉到,其实国内一些初、中级工程师写的东西不规范或者说不够清晰的原因是缺乏一些指导原则。他们手中挥舞着面向对象的大旗,写出来的东西却充斥着面向过程的气味。也许是他们不知道有这些原则,也许是他们知道但是不能很好运用到实际代码中,亦或是他们没有在实战中体会到这些原则能够带来的优点,以至于他们对这些原则并没有足够的重视。
面向对象六大原则
在此之前,有一点需要大家知道,熟悉这些原则并不是说你写出的程序就一定灵活、清晰,只是为你的优秀代码之路铺上了一层栅栏,在这些原则的指导下你才能避免陷入一些常见的代码泥沼,从而让你专心写出优秀的东西。
下面我们就以Android网络框架SimpleNet为例来学习这六大面向对象的基本原则,体会这些原则在开发过程中带来的强大能量。
1 单一职责原则
单一职责原则的英文名称是Single Responsibility Principle,简称是SRP,简单地说就是一个类只做一件事。这个设计原则备受争议却又极其重要。只要你想和别人争执、怄气或者是吵架,这个原则是屡试不爽的。因为单一职责的划分界限并不是如马路上的行车道那么清晰,很多时候都是需要靠个人经验来界定。当然,最大的问题就是对职责的定义,什么是类的职责,以及怎么划分类的职责。
试想一下,如果你遵守了这个原则,那么你的类就会划分得很细,每个类都有比较单一的职责,这不就是高内聚、低耦合么!当然,如何界定类的职责这需要你的个人经验了。
在SimpleNet中,我觉得很能够体现SRP原则的就是HttpStack这个类族了。HttpStack定义了一个执行网络请求的接口,代码如下:
public interface HttpStack {
/**
* 执行Http请求,并且返回一个Response
*/
public Response performRequest(Request request);
}
从上述程序中可以看到,HttpStack只有一个performRequest函数,它的职责就是执行网络请求并且返回一个Response。它的职责很单一,这样在需要修改执行网络请求的相关代码时,只需要修改实现HttpStack接口的类,而不会影响其他的类的代码。如果某个类的职责包含有执行网络请求、解析网络请求、进行gzip压缩、封装请求参数等,那么在你修改某处代码时就必须谨慎,以免修改的代码影响了其他的功能。当你修改的代码能够基本上不影响其他的功能。这就在一定程度上保证了代码的可维护性。注意,单一职责原则并不是说一个类只有一个函数,而是说这个类中的函数所做的工作是高度相关的,也就是高内聚。HttpStack抽象了执行网络请求的具体过程,接口简单清晰,也便于扩展。
优点
(1)类的复杂性降低,实现什么职责都有清晰明确的定义。
(2)可读性提高,复杂性降低,那当然可读性提高了。
(3)可维护性提高,可读性提高,那当然更容易维护了。
(4)变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
2 里氏替换原则
面向对象的语言的三大特点是继承、封装、多态,里氏替换原则就是依赖于继承、多态这两大特性。里氏替换原则简单来说就是所有引用基类、接口的地方必须能透明地使用其子类的对象。通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
还是以HttpStack为例,SimpleNet定义了HttpStack来表示执行网络请求这个抽象概念。在执行网络请求时,只需要定义一个HttpStack对象,然后调用performRequest即可,至于HttpStack的具体实现由更高层的调用者指定。这部分代码在RequestQueue类中,示例如下:
/**
* @paramcoreNums线程核心数
* @paramhttpStack http执行器
*/
protected RequestQueue(intcoreNums, HttpStackhttpStack) {
mDispatcherNums = coreNums;
mHttpStack = httpStack != null ? httpStack : HttpStackFactory.createHttpStack();
}
HttpStackFactory类的createHttpStack函数负责根据API版本创建不同的HttpStack,实现代码如下:
// 根据API版本选择HttpClient或者HttpURLConnection
public final class HttpStackFactory {
// API 9
private static final int GINGERBREAD_SDK_NUM = 9;
/**
* 根据SDK版本号来创建不同的Http执行器,即SDK 9之前使用HttpClient,之后则使用HttlUrlConnection
* @return
*/
public static HttpStackcreateHttpStack() {
intruntimeSDKApi = Build.VERSION.SDK_INT;
if (runtimeSDKApi>= GINGERBREAD_SDK_NUM) {
return new HttpUrlConnStack();
}
return new HttpClientStack();}}
上述代码中,RequestQueue类中依赖的是HttpStack接口,而通过HttpStackFactory的createHttpStack函数返回的是HttpStack的实现类HttpClientStack或HttlUrlConnStack。这就是所谓的里氏替换原则,任何父类、父接口出现的地方子类都可以出现,这不就保证了可扩展性吗!
任何实现HttpStack接口的类的对象都可以传递给RequestQueue实现网络请求的功能,这样SimpleNet执行网络请求的方法就有很多种可能性,而不是只有HttpClient和HttpURLConnection。例如,用户想使用OkHttp作为SimpleNet的执行引擎,那么创建一个实现了HttpStack接口的OkHttpStack类,然后在该类的performRequest函数中执行网络请求,最终将OkHttpStack对象注入RequestQueue即可。
细想一下,很多应用框架不就是这样实现吗?框架定义一系列相关的逻辑骨架与抽象,使得用户可以将自己的实现注入到框架中,从而实现变化万千的功能。
优点
(1)代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性。
(2)提高代码的重用性。
(3)提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,很多开源框架的扩展接口都是通过继承父类来完成的。
(4)提高产品或项目的开放性。
缺点
(1)继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法。
(2)降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束。
(3)增强了耦合性。当父类的常量、变量和方法被修改时,必需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大片的代码需要重构。
3 依赖倒置原则
依赖倒置原则这个名字看着有点不好理解,“依赖”还要“倒置”,这到底是什么意思?依赖倒置原则的几个关键点如下:
(1)高层模块不应该依赖低层模块,两者都应该依赖其抽象。
(2)抽象不应该依赖细节。
(3)细节应该依赖抽象。
在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的。细节就是实现类、实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字 new 产生一个对象。依赖倒置原则在 Java 语言中的表现就是:模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。软件先驱们总是喜欢将一些理论定义得很抽象,弄得不是那么容易理解,其实就是一句话:面向接口编程,或者说是面向抽象编程,这里的抽象指的是接口或者抽象类。面向接口编程是面向对象精髓之一。
采用依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。
优点
(1)可扩展性好。
(2)耦合度低。
4 开闭原则
开闭原则是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。开闭原则的定义是:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,
可能会给旧代码引入错误。因此,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。
在软件开发过程中,永远不变的就是变化。开闭原则是使我们的软件系统拥抱变化的核心原则之一。对扩展开放,对修改关闭这样的高层次的概括,即在需要对软件进行升级、变化时应该通过扩展的形式来实现,而非修改原有代码。当然这只是一种比较理想的状态,是通过扩展还是通过修改旧代码需要根据代码自身来定。
在SimpleNet中,开闭原则体现得比较好的是Request类族的设计。我们知道,在开发C/S应用时,服务器返回的数据格式多种多样,有字符串类型、xml、Json等。而解析服务器返回的Response的原始数据类型则是通过Request类来实现的,这样就使得Request类对于服务器返回的数据格式有良好的扩展性,即Request的可变性太大。
例如,返回的数据格式是Json,那么,使用JsonRequest请求来获取数据,它会将结果转成JsonObject对象,我们看看JsonRequest的核心实现:
// 返回的数据类型为Json的请求, Json对应的对象类型为JSONObject
public class JsonRequest extends Request {
public JsonRequest(HttpMethod method, String url,
RequestListener listener) {
super(method, url, listener);
}
// 将Response的结果转换为JSONObject
@Override
public JSONObjectparseResponse(Response response) {
String jsonString = new String(response.getRawData());
try {
return new JSONObject(jsonString);
} catch (JSONException e) {
e.printStackTrace();
}return null;
}
}
JsonRequest通过实现Request抽象类的parseResponse解析服务器返回的结果,这里将结果转换为JSONObject,并且封装到Response类中。
例如,SimpleNet添加对图片请求的支持,即要实现类似ImageLoader的功能。这个时候我的请求返回的数据是Bitmap图片。因此,我需要在该类型的Request中得到的结果是Request,但支持一种新的数据格式不能通过修改源码的形式,这样可能会为旧代码引入错误,但是,你又必须实现功能扩展。这就是开闭原则的定义:对扩展开放,对修改关闭。我们看看SimpleNet是如何做的:
public class ImageRequest extends Request {
public ImageRequest(HttpMethod method, String url,
RequestListener listener) {
super(method, url, listener);
}
// 将数据解析为Bitmap
@Override
public Bitmap parseResponse(Response response) {
return BitmapFactory.decodeByteArray(response.rawData,
0, response.rawData.length);
}}
ImageRequest类的parseResponse函数中将Response中的原始数据转换为Bitmap即可。当我们需要添加其他数据格式时,只需要继承自Request类,并且在parseResponse方法中将数据转换为具体的形式即可。这样通过扩展的形式来应对软件的变化或者说用户需求的多样性,既避免了破坏原有系统,又保证了软件系统的可扩展性。依赖于抽象,而不依赖于具体,使得对扩展开放,对修改关闭。开闭原则与依赖倒置原则、里氏替换原则一样,实际上最终都遵循一句话:面向接口编程。
优点
(1)增加稳定性。
(2)可扩展性高。
5 接口隔离原则
客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。
可能描述起来不是很好理解,我们还是以示例来加强理解吧。
我们知道,在SimpleNet的网络队列中是会对请求进行排序的。SimpleNet内部使用PriorityBlockingQueue来维护网络请求队列,PriorityBlockingQueue需要调用Request类的compareTo函数来进行排序。试想一下,PriorityBlockingQueue其实只需要调用Request类的排序方法就可以了,其他的接口它根本不需要,即PriorityBlockingQueue只需要compareTo这个接口,而这个compareTo方法就是我们上述所说的最小接口。当然,compareTo这个方法并不是SimpleNet本身定义的接口方法,而是Java中的Comparable接口,但我们这里只是为了学习,至于哪里定义的无关紧要:
public abstract class Request implements Comparable> {
/**
* 排序方法,PriorityBlockingQueue只需要调用元素的compareTo即可进行排序
*/
@Override
public intcompareTo(Request another) {
Priority myPriority = this.getPriority();
Priority anotherPriority = another.getPriority();
// 如果优先级相等,那么按照添加到队列的序列号顺序来执行
return myPriority.equals(anotherPriority) ?this.getSerialNumber()
- another.getSerialNumber()
: myPriority.ordinal() - anotherPriority.ordinal();
}
// 代码省略
}
PriorityBlockingQueue类相关代码 :
public class PriorityBlockingQueue extends AbstractQueue
implements BlockingQueue, java.io.Serializable {
// 代码省略
// 添加元素时进行排序
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
int n, cap;
Object[] array;
while ((n = size) >= (cap = (array = queue).length))
tryGrow(array, cap);
try {
Comparatorcmp = comparator;
// 没有设置Comparator,则使用元素本身的compareTo方法进行排序
if (cmp == null)
siftUpComparable(n, e, array);
else
siftUpUsingComparator(n, e, array, cmp);
size = n + 1;
notEmpty.signal();
} finally {
lock.unlock();
}
return true;
}
private static void siftUpComparable(int k, T x, Object[] array) {
Comparable key = (Comparable) x;
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = array[parent];
// 调用元素的compareTo方法进行排序
if (key.compareTo((T) e) >= 0)
break;
array[k] = e;
k = parent;
}
array[k] = key;
}
}
从PriorityBlockingQueue的代码可知,在元素排序时,PriorityBlockingQueue只需要知道元素是个Comparable对象即可,不需要知道这个对象是不是Request类以及这个类的其他接口。它只需要排序,因此,只要知道它是实现了Comparable接口的对象即可,Comparable就是它的最小接口,也是通过Comparable隔离了PriorityBlockingQueue类对Request类的其他方法的可见性。
优点
(1)降低耦合性。
(2)提升代码的可读性。
(3)隐藏实现细节。
6 迪米特原则
迪米特法则也称为最少知识原则(Least Knowledge Principle),虽然名字不同,但描述的是同一个原则:一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,这有点类似接口隔离原则中的最小接口的概念。类的内部如何实现、如何复杂都与调用者或者依赖者没有关系,调用者或者依赖者只需要知道它需要的方法即可,其他的一概不关心。类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
迪米特法则还有一个英文解释是:Only talk to your immedate friends(只与直接的朋友通信)。什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。
例如,SimpleNet中的Response缓存接口的设计。
/**
* 请求缓存接口
* @param key的类型
* @param value类型
*/
public interface Cache {
public V get(K key);
public void put(K key, V value);
public void remove(K key);
}
Cache接口定义了缓存类需要实现的最小接口,依赖缓存类的对象只需要知道这些接口即可。例如,需要将Http Response缓存到内存中,并且按照LRU的规则进行存储。我们需要LruCache类实现这个功能,代码如下:
// 将请求结果缓存到内存中
public class LruMemCache implements Cache {
/**
* Reponse LRU缓存
*/
private LruCachemResponseCache;
public LruMemCache() {
// 计算可使用的最大内存
final intmaxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// 取八分之一的可用内存作为缓存
final intcacheSize = maxMemory / 8;
mResponseCache = new LruCache(cacheSize) {
@Override
protected intsizeOf(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 response) {
mResponseCache.put(key, response);
}
@Override
public void remove(String key) {
mResponseCache.remove(key);
}
}
在这里,SimpleNet的直接朋友就是Cache或者LruMemCahce,间接朋友就是LruCache类。SimpleNet只需要直接和Cache类交互即可,并不需要知道LruCache的对象的存在,即真正实现缓存功能的对象是LruCache。这就是迪米特原则,尽量少地知道对象的信息,只与直接的朋友交互。
优点
(1)降低复杂度。
(2)降低耦合度。
(3)增加稳定性。
面向对象六大原则在开发过程中极为重要,它们给灵活、可扩展的软件系统提供了更细粒度的指导原则。如果能够很好地将这些原则运用到项目中,再在一些合适的场景运用一些经过验证过的设计模式,那么开发出来的软件在一定程度上能够得到质量保证。其实这六大原则最终可以简化为几个关键词:抽象、单一职责、最小化。那么在实际开发过程中如何权衡、实践这些原则,也是需要大家在工作中不断地思考、摸索。
设计模式
在软件工程中,设计模式是对软件设计中普遍存在、反复出现的各种问题所提出的通用解决方案。这个术语是由Erich Gamma等人在1990年从建筑设计领域引入到软件工程领域,从此设计模式在面向对象设计领域逐渐被重视起来。
设计模式并不直接用来完成代码的编写,而是描述在各种情况下要如何解决软件设计问题。面向对象设计模式通常以类或对象来描述其中的关系和相互作用,它们的相互作用能够使软件系统具有高内聚、低耦合的特性,并且使软件能够应对变化。
模式的4个要素
1.模式名称
模式名称用一两个词来描述模式的问题、解决方案和效果。基于一个模式词汇表,同行、同事之间就可以通过它们进行交流,文档中也可以通过模式名代表一个设计。模式名可以帮助我们思考,便于我们与其他人交流设计思想及设计结果。
2.问题
描述了应该在什么情况使用设计模式。它解释了设计问题和问题存在的前因后果,它可能描述了特定的设计问题,例如,某个设计不具备良好的可扩展性等,也可能描述了导致不灵活设计的类或对象结构。
3.解决方案
描述了设计的组成成分,它们之间的相互关系及各自的职责和协作方式。因为模式就像一个模板,可应用于多种不同场合,所以解决方案并不描述一个具体的设计或实现,而是提供设计问题的抽象描述和怎样用一个具有一般意义的类或者对象组合来解决这个问题。
4.效果
描述了模式应用的效果及使用模式应权衡的问题。尽管我们描述设计决策时,并不总提到模式效果,但它们对于评价设计选择和理解使用模式的代价及好处具有重要意义。软件效果大多关注对时间和空间的衡量,它们也表述了语言和实现问题。因为复用是面向对象设计的要素之一,所以模式效果包括它对系统的灵活性、扩充性或可移植性的影响,显式地列出这些效果对理解和评价这些模式很有帮助。 设计模式为反复出现的局部软件设计问题指出了通用的解决方案,在很大程度上促进了面向对象软件工程的发展。它将这些常见的设计问题一一总结,将大师们的经验、教训、设计经验分享给所有人,使得即使是刚入门的工程师也能够设计出可扩展、灵活的软件系统,大大提升了软件质量。关于设计模式领域的书籍大家可以参考《设计模式之禅》和《Android源码设计模式解析与实战》。
避免掉进过度设计的怪圈
当你掌握一些设计模式或者手法之后,比较容易出现的问题是过度设计。有的人甚至在一个应用中一定要将23种常见的设计模式运用上,这就本末倒置了。设计模式的四大要素中就明确指出,模式的运用应该根据软件系统所面临的问题来决定是否需要使用现有的设计。也就是说,在出现问题或者你预计会出现那样的问题时才推荐使用特定的设计模式,而不是将各种设计模式套进你的软件中。
不管在设计、实现、测试之间有多少时间都应该避免过度设计,它会打破你的反馈回路,使你的设计得不到反馈,从而慢慢陷入危险中。所以你只需要保持简单的设计,这样就有时间来测试该设计是否真的可行,然后作出最后的决策。
当设计一款软件时,从整体高度上设定一种架构模式,确定应用的整体架构,然后再分析一些重要模块的设计思路,并且保证它们的简单性、清晰性,如果有时间可以使用Java代码模拟一个简单的原型,确保设计是可行的,最后就可以付诸行动了。切记不要过度地追求设计,适当就好,当我们发现或者预计到将要出现问题时再判断是否需要运用设计模式。
反模式
反模式是一种文字记录形式,描述了对某个问题必然产生的消极后果的常见解决方案。由于管理人员或者开发人员不知道更好的解决方案,缺乏决定特定问题所需的经验或知识,或者说不适合的条件下套用了某个设计模式,这些都会造成反模式。与设计模式类似,反模式描述了一个一般的形式、主要原因、典型症状、后果,以及最后如何通过重构解决问题。
反模式是把一般情况映射到一类特定解决方案的有效方法。反模式的一般形式为它所针对的哪类问题提供了一个易于辨识的模板。此外,它还清楚地说明了与该问题相关联的症状以及导致这一问题的内在原因。这些模板元素完整地说明了反模式存在的情况。这个一般形式可以减少使用设计模式时最为常见的问题:把特定设计模式应用于不正确的环境。
反模式为识别软件行业反复出现的问题提供了实际经验,并为大多数常见的问题提供了详细的解决方案。反模式对业界常见的问题进行总结,并且告诉你如何识别这些问题以及如何解决。它有效地说明了可以在不同层次上采取的措施,以便改善应用开发过程、软件系统和对软件项目的有效管理。
总的来说,设计模式总结了在特定问题下正确的解决方案,而反模式则是告诉你在特定的问题上的错误解决方案以及它们的原因、解决方案,通过最终的解决方案,它能够将腐化的软件系统拉回正轨。
然而,任何一篇文章甚至一本书都不足以使你快速地成长为面向对象专家。灵活的软件设计需要知识、经验与思考,好的设计通常是经历了时间的洗礼慢慢演化而来,工程师的成长也是一样。因此,掌握必要的面向对象、设计模式、反模式等知识,并且在工作中不断实践、思考,将使你的软件设计之路走得更从容、顺畅。