值得反覆品味的面向對象的六大原則

本文原創,轉載請註明出處。
歡迎關注我的 簡書 ,關注我的專題 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 void 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。這就是迪米特原則,儘量少地知道對象的信息,只與直接的朋友交互。

優點:

  • 降低複雜度
  • 降低耦合性
  • 增加穩定性

寫在後面:
面向對象的六大原則在開發過程中極爲重要,他們給靈活、可擴展的軟件系統提供了更細粒度的指導原則。如果能很好地將這些原則運用到項目中,再在一些合適的場景運用一些經過驗證過設計模式,那麼開發出來的軟件在一定程度上能夠得到質量保證。其實六大原則最終可以簡化爲幾個關鍵字:抽象、單一職責、最小化。那麼在實際開發中如何權衡,實踐這些原則,也是需要大家在工作過程中不斷地思考、摸索、實踐。

本文終於要結束了,讓我擦擦屏幕上的血(話說寫讀書筆記比自己寫文章累多了…),未來會繼續給大家總結設計模式、重構的手法、以及本例中非常實用的 網絡框架 的封裝,敬請期待~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章