1. 程式人生 > 實用技巧 >LingCoder / OnJava8 "是一個"與"像是一個"的關係

LingCoder / OnJava8 "是一個"與"像是一個"的關係

"是一個"與"像是一個"的關係 對於繼承可能會引發爭論:繼承應該只覆蓋基類的方法(不應該新增基類中沒有的方法)嗎?如果這樣的話,基類和派生類就是相同的型別了,因為它們具有相同的介面。這會造成,你可以用一個派生類物件完全替代基類物件,這叫作"純粹替代",也經常被稱作"替代原則"。在某種意義上,這是一種處理繼承的理想方式。我們經常把這種基類和派生類的關係稱為是一個(is-a)關係,因為可以說"圓是一個形狀"。判斷是否繼承,就看在你的類之間有無這種 is-a 關係。

有時你在派生類添加了新的介面元素,從而擴充套件介面。雖然新型別仍然可以替代基類,但是這種替代不完美,原因在於基類無法訪問新新增的方法。這種關係稱為像是一個(is-like-a)關係。新型別不但擁有舊型別的介面,而且包含其他方法,所以不能說新舊型別完全相同。 以空調為例,假設房間裡已經安裝好了製冷裝置的控制器,即你有了控制製冷裝置的介面。想象一下,現在空調壞了,你重新安裝了一個既製冷又制熱的熱力泵。熱力泵就像是一個(is-like-a)空調,但它可以做更多。因為當初房間的控制系統被設計成只能控制製冷裝置,所以它只能與新物件(熱力泵)的製冷部分通訊。新物件的介面已經擴充套件了,現有控制系統卻只知道原來的介面,一旦看到這個設計,你就會發現,作為基類的製冷系統不夠一般化,應該被重新命名為"溫度控制系統",也應該包含制熱功能,這樣的話,我們就可以使用替代原則了。上圖反映了在現實世界中進行設計時可能會發生的事情。

當你看到替代原則時,很容易會認為純粹替代是唯一可行的方式,並且使用純粹替代的設計是很好的。但有些時候,你會發現必須得在派生(擴充套件)類中新增新方法(提供新的介面)。只要仔細審視,你可以很明顯地區分兩種設計方式的使用場合。


多型 我們在處理類的層次結構時,通常把一個物件看成是它所屬的基類,而不是把它當成具體類。通過這種方式,我們可以編寫出不侷限於特定型別的程式碼。在上個“形狀”的例子中,“方法”(method)操縱的是通用“形狀”,而不關心它們是“圓”、“正方形”、“三角形”還是某種尚未定義的形狀。所有的形狀都可以被繪製、擦除和移動,因此“方法”向其中的任何代表“形狀”的物件傳送訊息都不必擔心物件如何處理資訊。

這樣的程式碼不會受新增的新型別影響,並且新增新型別是擴充套件面向物件程式以處理新情況的常用方法。 例如,你可以通過通用的“形狀”基類派生出新的“五角形”形狀的子類,而不需要修改通用"形狀"基類的方法。通過派生新的子類來擴充套件設計的這種能力是封裝變化的基本方法之一。

這種能力改善了我們的設計,且減少了軟體的維護代價。如果我們把派生的物件型別統一看成是它本身的基類(“圓”當作“形狀”,“自行車”當作“車”,“鸕鶿”當作“鳥”等等),編譯器(compiler)在編譯時期就無法準確地知道什麼“形狀”被擦除,哪一種“車”在行駛,或者是哪種“鳥”在飛行。這就是關鍵所在:當程式接收這種訊息時,程式設計師並不想知道哪段程式碼會被執行。“繪圖”的方法可以平等地應用到每種可能的“形狀”上,形狀會依據自身的具體型別執行恰當的程式碼。

如果不需要知道執行了哪部分程式碼,那我們就能新增一個新的不同執行方式的子類而不需要更改呼叫它的方法。那麼編譯器在不確定該執行哪部分程式碼時是怎麼做的呢?舉個例子,下圖的 BirdController 物件和通用 Bird 物件中,BirdController 不知道 Bird 的確切型別卻還能一起工作。從 BirdController 的角度來看,這是很方便的,因為它不需要編寫特別的程式碼來確定 Bird 物件的確切型別或行為。那麼,在呼叫 move() 方法時是如何保證發生正確的行為(鵝走路、飛或游泳、企鵝走路或游泳)的呢?


這個問題的答案,是面向物件程式設計的妙訣:在傳統意義上,編譯器不能進行函式呼叫。由非 OOP 編譯器產生的函式呼叫會引起所謂的早期繫結,這個術語你可能從未聽說過,不會想過其他的函式呼叫方式。這意味著編譯器生成對特定函式名的呼叫,該呼叫會被解析為將執行的程式碼的絕對地址。

通過繼承,程式直到執行時才能確定程式碼的地址,因此傳送訊息給物件時,還需要其他一些方案。為了解決這個問題,面嚮物件語言使用後期繫結的概念。當向物件傳送資訊時,被呼叫的程式碼直到執行時才確定。編譯器確保方法存在,並對引數和返回值執行型別檢查,但是它不知道要執行的確切程式碼。

為了執行後期繫結,Java 使用一個特殊的程式碼位來代替絕對呼叫。這段程式碼使用物件中儲存的資訊來計算方法主體的地址(此過程在多型性章節中有詳細介紹)。因此,每個物件的行為根據特定程式碼位的內容而不同。當你向物件傳送訊息時,物件知道該如何處理這條訊息。在某些語言中,必須顯式地授予方法後期繫結屬性的靈活性。例如,C++ 使用 virtual 關鍵字。在這些語言中,預設情況下方法不是動態繫結的。在 Java 中,動態繫結是預設行為,不需要額外的關鍵字來實現多型性。

為了演示多型性,我們編寫了一段程式碼,它忽略了型別的具體細節,只與基類對話。該程式碼與具體型別資訊分離,因此更易於編寫和理解。而且,如果通過繼承添加了一個新型別(例如,一個六邊形),那麼程式碼對於新型別的 Shape 就像對現有型別一樣有效。因此,該程式是可擴充套件的。

程式碼示例:


void doSomething(Shape shape) {
    shape.erase();
    // ...
    shape.draw();
}

此方法與任何 Shape 對話,因此它與所繪製和擦除的物件的具體型別無關。如果程式的其他部分使用 doSomething() 方法:

    Circle circle = new Circle();
    Triangle triangle = new Triangle();
    Line line = new Line();
    doSomething(circle);
    doSomething(triangle);
    doSomething(line);

可以看到無論傳入的“形狀”是什麼,程式都正確的執行了。

  doSomething(circle);

當預期接收 Shape 的方法被傳入了 Circle,會發生什麼。由於 Circle 也是一種 Shape,所 以 doSomething(circle) 能正確地執行。也就是說,doSomething() 能接收任意傳送給 Shape 的訊息。這是完全安全和合乎邏輯的事情。

這種把子類當成其基類來處理的過程叫做“向上轉型”(upcasting)。在面向物件的程式設計裡,經常利用這種方法來給程式解耦。再看下面的 doSomething() 程式碼示例:

    shape.erase();
    // ...
    shape.draw();

我們可以看到程式並未這樣表達:“如果你是一個 Circle ,就這樣做;如果你是一個 Square,就那樣做...”。若那樣編寫程式碼,就需檢查 Shape 所有可能的型別,如圓、矩形等等。這顯然是非常麻煩的,而且每次添加了一種新的 Shape 型別後,都要相應地進行修改。在這裡,我們只需說:“你是一種幾何形狀,我知道你能刪掉 erase() 和繪製 draw(),你自己去做吧,注意細節。”

儘管我們沒作出任何特殊指示,程式的操作也是完全正確和恰當的。我們知道,為 Circle 呼叫draw() 時執行的程式碼與為一個 Square 或 Line 呼叫 draw() 時執行的程式碼是不同的。但在將 draw() 資訊發給一個匿名 Shape 時,根據 Shape 控制代碼當時連線的實際型別,會相應地採取正確的操作。這非常神奇,因為當 Java 編譯器為 doSomething() 編譯程式碼時,它並不知道自己要操作的準確型別是什麼。

儘管我們確實可以保證最終會為 Shape 呼叫 erase() 和 draw(),但並不能確定特定的 Circle,Square 或者 Line 呼叫什麼。最後,程式執行的操作卻依然是正確的,這是怎麼做到的呢?

傳送訊息給物件時,如果程式不知道接收的具體型別是什麼,但最終執行是正確的,這就是物件的“多型性”(Polymorphism)。面向物件的程式設計語言是通過“動態繫結”的方式來實現物件的多型性的。編譯器和執行時系統會負責對所有細節的控制;我們只需知道要做什麼,以及如何利用多型性來更好地設計程式。


單繼承結構 自從 C++ 引入以來,一個 OOP 問題變得尤為突出:是否所有的類都應該預設從一個基類繼承呢?這個答案在 Java 中是肯定的(實際上,除 C++ 以外的幾乎所有OOP語言中也是這樣)。在 Java 中,這個最終基類的名字就是 Object。

Java 的單繼承結構有很多好處。由於所有物件都具有一個公共介面,因此它們最終都屬於同一個基類。相反的,對於 C++ 所使用的多繼承的方案則是不保證所有的物件都屬於同一個基類。從向後相容的角度看,多繼承的方案更符合 C 的模型,而且受限較少。

對於完全面向物件程式設計,我們必須要構建自己的層次結構,以提供與其他 OOP 語言同樣的便利。我們經常會使用到新的類庫和不相容的介面。為了整合它們而花費大氣力(有可能還要用上多繼承)以獲得 C++ 樣的“靈活性”值得嗎?如果從零開始,Java 這樣的替代方案會是更好的選擇。

另外,單繼承的結構使得垃圾收集器的實現更為容易。這也是 Java 在 C++ 基礎上的根本改進之一。

由於執行期的型別資訊會存在於所有物件中,所以我們永遠不會遇到判斷不了物件型別的情況。這對於系統級操作尤其重要,例如異常處理。同時,這也讓我們的程式設計具有更大的靈活性。

集合

通常,我們並不知道解決某個具體問題需要的物件數量和持續時間,以及物件的儲存方式。那麼我們如何知悉程式在執行時需要分配的記憶體空間呢?

在面向物件的設計中,問題的解決方案有些過於輕率:建立一個新型別的物件來引用、容納其他的物件。當然,我們也可以使用多數程式語言都支援的“陣列”(array)。在 Java 中“集合”(Collection)的使用率更高。(也可稱之為“容器”,但“集合”這個稱呼更通用。)

“集合”這種型別的物件可以儲存任意型別、數量的其他物件。它能根據需要自動擴容,我們不用關心過程是如何實現的。

還好,一般優秀的 OOP 語言都會將“集合”作為其基礎包。在 C++ 中,“集合”是其標準庫的一部分,通常被稱為 STL(Standard Template Library,標準模板庫)。SmallTalk 有一套非常完整的集合庫。同樣,Java 的標準庫中也提供許多現成的集合類。

在一些庫中,一兩個泛型集合就能滿足我們所有的需求了,而在其他一些類庫(Java)中,不同型別的集合對應不同的需求:常見的有 List,常用於儲存序列;Map,也稱為關聯陣列,常用於將物件與其他物件關聯;Set,只能儲存非重複的值;其他還包括如佇列(Queue)、樹(Tree)、棧(Stack)、堆(Heap)等等。從設計的角度來看,我們真正想要的是一個能夠解決某個問題的集合。如果一種集合就滿足所有需求,那麼我們就不需要剩下的了。之所以選擇集合有以下兩個原因:

集合可以提供不同型別的介面和外部行為。堆疊、佇列的應用場景和集合、列表不同,它們中的一種提供的解決方案可能比其他靈活得多。

不同的集合對某些操作有不同的效率。例如,List 的兩種基本型別:ArrayList 和 LinkedList。雖然兩者具有相同介面和外部行為,但是在某些操作中它們的效率差別很大。在 ArrayList 中隨機查詢元素是很高效的,而 LinkedList 隨機查詢效率低下。反之,在 LinkedList 中插入元素的效率要比在 ArrayList 中高。由於底層資料結構的不同,每種集合型別在執行相同的操作時會表現出效率上的差異。

我們可以一開始使用 LinkedList 構建程式,在優化系統性能時改用 ArrayList。通過對 List 介面的抽象,我們可以很容易地將 LinkedList 改為 ArrayList。

在 Java 5 泛型出來之前,集合中儲存的是通用型別 Object。Java 單繼承的結構意味著所有元素都基於 Object 類,所以在集合中可以儲存任何型別的資料,易於重用。要使用這樣的集合,我們先要往集合新增元素。由於 Java 5 版本前的集合只儲存 Object,當我們往集合中新增元素時,元素便向上轉型成了 Object,從而丟失自己原有的型別特性。這時我們再從集合中取出該元素時,元素的型別變成了 Object。那麼我們該怎麼將其轉回原先具體的型別呢?這裡,我們使用了強制型別轉換將其轉為更具體的型別,這個過程稱為物件的“向下轉型”。通過“向上轉型”,我們知道“圓形”也是一種“形狀”,這個過程是安全的。可是我們不能從“Object”看出其就是“圓形”或“形狀”,所以除非我們能確定元素的具體型別資訊,否則“向下轉型”就是不安全的。也不能說這樣的錯誤就是完全危險的,因為一旦我們轉化了錯誤的型別,程式就會執行出錯,丟擲“執行時異常”(RuntimeException)。(後面的章節會提到) 無論如何,我們要尋找一種在取出集合元素時確定其具體型別的方法。另外,每次取出元素都要做額外的“向下轉型”對程式和程式設計師都是一種開銷。以某種方式建立集合,以確認儲存元素的具體型別,減少集合元素“向下轉型”的開銷和可能出現的錯誤難道不好嗎?這種解決方案就是:引數化型別機制(Parameterized Type Mechanism)。

引數化型別機制可以使得編譯器能夠自動識別某個 class 的具體型別並正確地執行。舉個例子,對集合的引數化型別機制可以讓集合僅接受“形狀”這種型別的元素,並以“形狀”型別取出元素。Java 5 版本支援了引數化型別機制,稱之為“泛型”(Generic)。泛型是 Java 5 的主要特性之一。你可以按以下方式向 ArrayList 中新增 Shape(形狀):

  ArrayList<Shape> shapes = new ArrayList<>();

泛型的應用,讓 Java 的許多標準庫和元件都發生了改變。在本書的程式碼示例中,你也會經常看到泛型的身影。


物件建立與生命週期 我們在使用物件時要注意的一個關鍵問題就是物件的建立和銷燬方式。每個物件的生存都需要資源,尤其是記憶體。為了資源的重複利用,當物件不再被使用時,我們應該及時釋放資源,清理記憶體。

在簡單的程式設計場景下,物件的清理並不是問題。我們建立物件,按需使用,最後銷燬它。然而,情況往往要比這更復雜:

假設,我們正在為機場設計一個空中交通管制的系統(該例也適用於倉庫貨櫃管理、影帶出租或者寵物寄養倉庫系統)。第一步比較簡單:建立一個用來儲存飛機的集合,每當有飛機進入交通管制區域時,我們就建立一個“飛機”物件並將其加入到集合中,等到飛機離開時將其從這個集合中清除。與此同時,我們還需要一個記錄飛機資訊的系統,也許這些資料不像主要控制功能那樣引人注意。比如,我們要記錄所有飛機中的小型飛機的的資訊(比如飛行計劃)。此時,我們又建立了第二個集合來記錄所有小型飛機。 每當建立一個“飛機”物件的時候,將其放入第一個集合;若它屬於小型飛機,也必須同時將其放入第二個集合裡。

現在問題開始棘手了:我們怎麼知道何時該清理這些物件呢?當某一個系統處理完成,而其他系統可能還沒有處理完成。這樣的問題在其他的場景下也可能發生。在 C++ 程式設計中,當使用完一個物件後,必須明確將其刪除,這就讓問題變複雜了。

物件的資料在哪?它的生命週期是怎麼被控制的? 在 C++ 設計中採用的觀點是效率第一,因此它將選擇權交給了程式設計師。為了獲得最大的執行時速度,程式設計師可以在編寫程式時,通過將物件放在棧(Stack,有時稱為自動變數或作用域變數)或靜態儲存區域(static storage area)中來確定記憶體佔用和生存時間。這些區域的物件會被優先分配記憶體和釋放。這種控制在某些情況下非常有用。

然而相對的,我們也犧牲了程式的靈活性。因為在編寫程式碼時,我們必須要弄清楚物件的數量、生存時間還有型別。如果我們要用它來解決一個相當普遍的問題時(如計算機輔助設計、倉庫管理或空中交通管制等),限制就太大了。

第二種方法是在堆記憶體(Heap)中動態地建立物件。在這種方式下,直到程式執行我們才能確定需要建立的物件數量、生存時間和型別。什麼時候需要,什麼時候在堆記憶體中建立。 因為記憶體的佔用是動態管理的,所以在執行時,在堆記憶體上開闢空間所需的時間可能比在棧記憶體上要長(但也不一定)。在棧記憶體開闢和釋放空間通常是一條將棧指標向下移動和一條將棧指標向上移動的彙編指令。開闢堆記憶體空間的時間取決於記憶體機制的設計。

動態方法有這樣一個合理假設:物件通常是複雜的,相比於物件建立的整體開銷,尋找和釋放記憶體空間的開銷微不足道。(原文:The dynamic approach makes the generally logical assumption that objects tend to be complicated, so the extra overhead of finding storage and releasing that storage will not have an important impact on the creation of an object.)此外,更好的靈活性對於問題的解決至關重要。

Java 使用動態記憶體分配。每次建立物件時,使用 new 關鍵字構建該物件的動態例項。這又帶來另一個問題:物件的生命週期。較之堆記憶體,在棧記憶體中建立物件,編譯器能夠確定該物件的生命週期並自動銷燬它;然而如果你在堆記憶體建立物件的話,編譯器是不知道它的生命週期的。在 C++ 中你必須以程式設計方式確定何時銷燬物件,否則可能導致記憶體洩漏。Java 的記憶體管理是建立在垃圾收集器上的,它能自動發現物件不再被使用並釋放記憶體。垃圾收集器的存在帶來了極大的便利,它減少了我們之前必須要跟蹤的問題和編寫相關程式碼的數量。因此,垃圾收集器提供了更高級別的保險,以防止潛在的記憶體洩漏問題,這個問題使得許多 C++ 專案沒落。

Java 的垃圾收集器被設計用來解決記憶體釋放的問題(雖然這不包括物件清理的其他方面)。垃圾收集器知道物件什麼時候不再被使用並且自動釋放記憶體。結合單繼承和僅可在堆中建立物件的機制,Java 的編碼過程比用 C++ 要簡單得多。我們所要做的決定和要克服的障礙也會少很多!


異常處理 自程式語言被髮明以來,程式的錯誤處理一直都是個難題。因為很難設計出一個好的錯誤處理方案,所以許多程式語言都忽略了這個問題,把這個問題丟給了程式類庫的設計者。他們提出了在許多情況下都可以工作但很容易被規避的半途而廢的措施,通常只需忽略錯誤。多數錯誤處理方案的主要問題是:它們依賴程式設計師之間的約定俗成而不是語言層面的限制。換句話說,如果程式設計師趕時間或沒想起來,這些方案就很容易被忘記。

異常處理機制將程式錯誤直接交給程式語言甚至是作業系統。“異常”(Exception)是一個從出錯點“丟擲”(thrown)後能被特定型別的異常處理程式捕獲(catch)的一個物件。它不會干擾程式的正常執行,僅當程式出錯的時候才被執行。這讓我們的編碼更簡單:不用再反覆檢查錯誤了。另外,異常不像方法返回的錯誤值和方法設定用來表示發生錯誤的標誌位那樣可以被忽略。異常的發生是不會被忽略的,它終究會在某一時刻被處理。

最後,“異常機制”提供了一種可靠地從錯誤狀況中恢復的方法,使得我們可以編寫出更健壯的程式。有時你只要處理好丟擲的異常情況並恢復程式的執行即可,無需退出。

Java 的異常處理機制在程式語言中脫穎而出。Java 從一開始就內建了異常處理,因此你不得不使用它。這是 Java 語言唯一接受的錯誤報告方法。如果沒有編寫適當的異常處理程式碼,你將會收到一條編譯時錯誤訊息。這種有保障的一致性有時會讓程式的錯誤處理變得更容易。值得注意的是,異常處理並不是面向物件的特性。儘管在面向物件的語言中異常通常由物件表示,但是在面嚮物件語言之前也存在異常處理。

本章小結 面向過程程式包含資料定義和函式呼叫。要找到程式的意圖,你必須要在腦中建立一個模型,弄清函式呼叫和更底層的概念。這些程式令人困擾,因為它們的表示更多地面向計算機而不是我們要解決的問題,這就是我們在設計程式時需要中間表示的原因。OOP 在面向過程程式設計的基礎上增加了許多新的概念,所以有人會認為使用 Java 來程式設計會比同等的面向過程程式設計要更復雜。在這裡,我想給大家一個驚喜:通常按照 Java 規範編寫的程式會比面向過程程式更容易被理解。

你看到的是物件的概念,這些概念是站在“問題空間”的(而不是站在計算機角度的“解決方案空間”),以及傳送訊息給物件以指示該空間中的活動。面向物件程式設計的一個優點是:設計良好的 Java 程式程式碼更容易被人閱讀理解。由於 Java 類庫的複用性,通常程式要寫的程式碼也會少得多。

OOP 和 Java 不一定適合每個人。評估自己的需求以及與現有方案作比較是很重要的。請充分考慮後再決定是不是選擇 Java。如果在可預見的未來,Java 並不能很好的滿足你的特定需求,那麼你應該去尋找其他替代方案(特別是,我推薦看 Python)。如果你依然選擇 Java 作為你的開發語言,我希望你至少應該清楚你選擇的是什麼,以及為什麼選擇這個方向。