值得反复品味的面向对象六大原则

本文原创,转载请注明出处。
欢迎关注我的 简书 ,关注我的专题 Android Class 我会长期坚持为大家收录简书上高质量的 Android 相关博文。

写在前面:

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

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

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

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

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

(打字好累…)

面向对象六大原则

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

单一职责原则

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

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

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

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

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

优点:

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

里氏替换原则

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

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

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

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

    /**
     * 根据 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 的核心实现:

// 返回的数据格式为 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,但支持一种新的数据格式不能通过修改源码的形式,这样可能会为旧代码引入错误,但是,你又必须实现功能扩展。这就是开闭原则的定义:对扩展开放,对修改关闭。我们看看应该如何做:

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 缓存接口的设计。

/**
 * 请求缓存接口
 *
 * @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 类实现这个功能。代码如下:

// 讲请求结果缓存到内存中
public class LruMemCache implements Cache<String, Response> {

    /**
     * Response LRU 缓存
     *
     * @param key
     * @return
     */

    private LruCache<String, Response> mResponseCache;

    public LruMemCache() {
        //计算可使用的最大内存
        final intmaxMemory=(int) (Runtime.getRuntime().maxMemory() / 1024);

        //取八分之一的可用最大内存为缓存
        final intCacheSize=intmaxMemory / 8;
        mResponseCache = new LruCache<String, Response>(intCacheSize) {
            @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 value) {
        mResponseCache.get(key, value);
    }

    @Override
    public void remove(String key) {
        mResponseCache.remove(key);
    }
}

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

优点:

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

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

本文终于要结束了,让我擦擦屏幕上的血(话说写读书笔记比自己写文章累多了…),未来会继续给大家总结设计模式、重构的手法、以及本例中非常实用的 网络框架 的封装,敬请期待~

    原文作者:MeloDev
    原文地址: https://www.jianshu.com/p/3abb531f7647
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞