1. 程式人生 > >程式碼之髓讀後感——類&繼承

程式碼之髓讀後感——類&繼承

面向物件

語言中的用語並不是共通的,在不同語言中,同一個用語的含義可能會有很大差別。

C++的設計者本賈尼·斯特勞斯特盧普對類和繼承給予了正面肯定,然而,“面向物件”這個詞的發明者艾倫·凱(Alan kay,他同時也是 Smalltalk 語言的設計者)卻持有不同的意見,他對類和繼承持否定立場。

img

對於面向物件的理解

我們是怎樣理解世界的呢?我們將生活中遇見的事物總結為特定的“物”的概念,它們就是諸如桌子、椅子、銀行貸款、公式、人、多項式、三角形、電晶體之類的東西。我們的思考、語言以及行動就是建立在指示、說明和操作這些所謂的“物”的基礎之上。我們在用計算機解決問題的時候,有必要將現實世界中的“物”的模型在計算機中建立起來。

img

大部分語言的程式設計中,類並不是不可或缺的,但 Java 語言是例外。Java 語言“把類定義為部件,將其組裝起來即是程式設計”。因此,在用 Java 語言編寫程式時類是必要的。其他諸如 C++、Python、Ruby 這樣的語言,在編寫程式時既可以使用類也可以不使用類。

那是使用類好呢,還是不使用也可以呢?

這取決於要編寫的程式。如果僅是小規模的程式,沒必要使用類的情況居多。也有人認為,在多人分工協作編寫的大型程式中,使用類來劃分責任範圍比較好。圖形使用者介面的編寫中面向物件的特性似乎非常管用。比如設計一個按鈕,需要有放置按鈕的座標和按鈕的寬、高等值,也需要有表達按鈕按下時的動作的函式。將實現按鈕所必需的這些要素統一到類中,編寫程式就會變得簡單起來。

歸集變數與函式建立模型的方法

除類之外,還有幾種其他的方式。

  1. 模組(module)。模組原本是一種將相關聯的函式集中到一起的功能。在 Perl 語言中類似的功能被稱為包(package)。Perl 語言在引入面向物件時,採用了把用來歸集函式的包和用來歸集變數的雜湊(hash)繫結在一起的方法。
  2. 把函式和變數放入雜湊中。這是 JavaScript 等語言採用的方法。
  3. 閉包(closure)。使用函式執行時的名稱空間來歸集變數的方法。這種方法主要在函式式語言中使用。

    為什麼把這稱為閉包?一個包含了自由變數的開放表示式,它和該自由變數的約束環境組合在一起後,實現了一種封閉的狀態。
    類的存在只不過是因為人們覺得有了它編寫程式會更方便些,而約定的一種事項。它並不是什麼物理法則或宇宙真理,僅僅是人們的一種約定而已。所以,為了理解為什麼會有這樣一種約定,我們需要考慮語言設計者的意圖。

C++ 語言和 Java 語言的類具有以下幾個作用:

  • 整合體的生成器
  • 可行操作的功能說明
  • 程式碼再利用的單位

繼承

繼承的不同實現策略。繼承的實現策略大體可以分為三種。

  • 一般化與專門化

第一種策略是在父類中實現那些一般化的功能,在子類中實現那些專門的個性化的功能。其設計方針就是子類是父類的專門化。

img

  • 共享部分的提取

第二種策略是從多個類中提取出共享部分作為父類。它和一般化與專門化的考慮很不一樣。對於子類是否為父類的一種,它的答案是否定的。這種提取出共享部分的設計方針是習慣了函式的一種考慮問題的方法。

img

  • 差異實現

第三種策略認為繼承之後僅實現有變更的那些屬性會帶來效率的提高。它把繼承作為實現方式再利用的途徑,旨在使程式設計實現更加輕鬆。的確有很多這樣的情況。但這些情況下通常子類都不是父類的一種。

img

繼承的弊端

方法的多樣意味著控制的複雜,自由度太高往往會需要我們去限制。

比如說 goto 。

尤其是第三種使用方法——繼承已有的類並實現差異部分,這種程式設計風格會造成多層級的繼承樹,很容易導致程式碼理解困難。

這裡就提到了程式設計中很重要的一點,就是可讀性。

里氏置換原則

這個原則可以表述為:假設對於 T 型別的物件 x,屬性 q(x) 恆為真。如果 S 為 T 的派生類,那麼 S 型別的物件 y 的屬性 q(y) 也必須恆為真。

這句話換種表達就是,對於類 T 的物件一定成立的條件,對於類 T 的子類 S 的物件也必須成立。

為了保證類的繼承關係和型別的父子關係這兩種關係之間的一致性,有必要遵守這一原則。這一原則也可以表達為繼承必須是 is-a 關係。把子類 S 的所有物件都看作是父類 T 的物件而不會有任何問題,必須要做到這一點。

這一約束條件是非常嚴格的。當要繼承某種類時,需要考慮該類是否可以被繼承。假設繼承的時候考慮的屬性可以使里氏置換原則成立。但是在隨後的程式編寫過程中,需要的屬性可能會越來越多。隨著屬性的增加,置換原則就有可能被打破。是在設計階段就把所有屬性列出來,只有當置換原則絕對不被打破時才去繼承呢?還是在開發階段如果發現新的屬性就放棄類的繼承呢?不管哪種方式都很費勁。

多重繼承

現實世界中一種事物有可能屬於多種分類。為了實現對這種現實情況的模擬,作為工具的程式設計語言是不是應該支援對多個類的繼承呢?這就是多重繼承的初衷。

多重繼承對於實現方式再利用非常便利。

多重繼承的問題

多重繼承看起來真的很方便。但是,使用多重繼承時該如何解決名字解釋的問題呢?當問到類中 x 值是什麼時,該如何回答呢?

首先,如果這個類本身知道答案,就直接給出回答。其次,如果這個類本身不知道答案,就去問它的父類再給出回答。

但是如下情況就麻煩了。

  • 解決方法 1:禁止多重繼承。Java 語言中就禁止了類的多重繼承。只要不認可類的多重繼承這種方式,就不會有上述問題。這樣可以把問題解決得很乾脆,只是會以失去多重繼承的良好便利性為代價。

    • 委託。取而代之發展起來的概念是委託。這種方法定義了具有待使用實現方式的類的物件,然後根據需要使用該物件來處理。使用繼承後,從型別到名稱空間都會被一起繼承,從而導致問題的發生,這種方法只是停留在使用物件的層面上。
public class TestDelegate {
    public static void main(String[] args){
        new UseInheritance().useHello(); // -> hello!
        new UseDelegate().useHello();    // -> hello!
    }
}

class Hello{   ❶
    public void hello(){
        System.out.println("hello!");
    }
}

class UseInheritance extends Hello {   ❷
    public void useHello(){
        hello();                       ❸
    }
}

class UseDelegate {                    ❹
    Hello h = new Hello();             ❺
    public void useHello(){
        h.hello();                     ❻
    }
}

顯示“Hello !”的方法 hello 為類 Hello 所持有(❶)。類 UseInheritance 通過繼承類 Hello 自身也持有了方法 hello(❷)並加以使用(❸)。與之不同,類 UseDelegate 並沒有繼承類 Hello(❹),而是通過句❺持有了類 Hello 的物件。當有需要使用時通過句❻將需要的處理委託給該物件操作。

與從多個類中繼承實現強耦合的方式相比,使用委託進行耦合的方式顯然要更好一些。對於委託的使用,也不需要在原始碼中寫死,而是可以通過配置檔案在合適的時候注入執行時中去。這個想法催生了依賴注入(Dependency Injection)的概念。

* 介面。剛剛提到 Java 語言中禁止了多重繼承,但它也具備實現多重繼承的功能。這就需要藉助介面(interface)。Java 語言中類的繼承用 extends,介面的繼承用 implements 來區別表示。另外介面的繼承也稱為實現。介面是沒有實現方式的類。它的功能僅僅在於說明繼承了該介面的類必須持有某某名字的方法。多重繼承中發生的問題是多種實現方式相沖突時選取哪個的問題。而在介面的多重繼承中,儘管有多個持有某某方法的資訊存在,但這僅僅表明持有某某方法,不會造成任何困擾。
public class TestMultiImpl implements Foo, Bar {
    public void hello(){
        System.out.println("hello!");
    }
}

interface Foo {
    public void hello();
}

interface Bar {
    public void hello();
}

類 TestMultiImpl 繼承了 Foo 和 Bar 兩個介面。如果這個類中不實現 public void hello (),編譯時將出現“沒有實現應該實現的方法”這樣的錯誤。也就是說,繼承了介面 Foo 後,這個類就作為一種型別表現出必須持有 public void hello () 的特點,可以讓編譯器對它進行型別檢查。

Java 語言為了僅實現功能上的多重繼承引入了介面。PHP 語言和 Java 語言一樣不認可多重繼承,並從 2004 年釋出的 PHP5 開始引入了介面的概念。

  • 解決方法 2:按順序進行搜尋

曾經也有些語言試圖通過明確定義搜尋順序來解決衝突問題。

出現過深度優先搜尋法,過載和菱形繼承時會很麻煩。以及廣度優先。

以及後來的C3線性化。

* 父類不比子類先被檢查
* 如果是從多個類中繼承下來則優先檢查先書寫的類

  • 解決方法 3:混入式處理

原本,問題是指從一個類到它的祖先類有多種追溯方法。定義僅包含所需功能的類並把它與需要新增這些功能的更大的類糅合在一起。把這種設計方針、混入式處理方式和用來混入的小的類統稱為混入處理(Mix-in)。

通常這些小型的類最小限度地定義了一些方法,起到了作為程式碼再利用單位的作用。為了表明這一點,Python 語言會在該類的名字中加上 MixIn 來標識。

Ruby 語言採用的規則是:類是單一繼承的而模組則可以任意數量地做混入式處理。模組無法建立例項,但可以像類一樣擁有成員變數和方法。也就是說,模組實質上是從類中去除了例項建立功能。即使類的多重繼承被禁止了,通過使用模組的 Mix-In 方式照樣可以實現對實現方式的再利用。

  • 解決方法 4:Trait

類具有兩種截然相反的作用。一種是用於建立例項的作用,它要求類是全面的、包含所有必需的內容的、大的類。另一種是作為再利用單元的作用,它要求類是按功能分的、沒有多餘內容的、小的類。當類用於建立例項時,作為再利用單元來說就顯得太大了。既然如此,如果把再利用單元的作用特別化,設定一些更小的結構(特性=方法的組合)是不是可以呢?這就是 Trait 的初衷。

已有的 Trait,通過改寫某些方法定義新的 Trait 實現繼承。還可以通過組合多個 Trait 實現新的 Trait。這就是 Trait 的概要說明。它一方面把問題妥當地分而治之,一方面又因為功能繁多令人困惑。讀者們想必都還記得 goto 語句就是因為其功能過於強大而退出歷史的舞臺的吧。所以說力量過於強大未必是件好事。

Trait 技術是一個很好的開端。它認為類同時具有的作為再利用單元和例項生成器的兩種作用是相反的。或許類這一概念作為面向物件的根基具有不可動搖的地位。然而這一概念本身也是從一個雛形慢慢發展得越來越複雜,進一步整理之後再逐漸讓渡出某些功能的。現在備受關注的 Trait 和一些其他概念也必將不斷地演變下去。經過長時間琢磨沉澱,一部分將臻於成熟被推廣使用,最後將變成現在的靜態作用域和 while 語句那樣被認為是理所當然的存在。