淺談物件導向程式設計

taney發表於2016-04-16

1. OOP簡介

物件導向程式設計(object-oriented programming)以下統一簡稱為OOP。世界上第一個OOP語言叫Simula,誕生於20世紀60年代,是它引入了物件、類、繼承、虛過程等等這些概念。當時還沒有“object-oriented”這個術語,這個術語是由第二個OOP語言Smalltalk的發明者Alan Kay提出來的,Smalltalk是“純OO”的語言,在Smalltalk中一切皆物件:class、primitive type、code block(相當於匿名函式)等全是物件,物件行為的執行是通過向物件傳送訊息實現的,它沒有指令式程式設計(imperative programming)中if、while這種語法結構,這些控制結構是通過向Boolean型別物件傳遞帶有code block的訊息實現的,Smalltalk是OOP語言中的代表,它影響了許多後來的OO語言,像Objective-C、Ruby、Java。 OOP作為一種思想,並不是由某個人發明出來的,各路OO大師都有自己的觀點,所以對於到底什麼是OOP,並沒有一致的、權威的定義。本文所表達的OOP來自 Smalltalk + 自己膚淺的理解。

2. 物件的組成

2.1 協議 和 實現

  • 物件(object) 表示一個由 狀態(私有的) 和 操作(公開的)組成的單元
  • 訊息(message) 表示傳送給一個物件讓它執行某個操作的請求。一個物件能夠響應的訊息的集合叫做它的介面(interface)協議(protocol),外界與物件進行互動應當只能通過這個物件的介面

訊息代表一個物件能夠響應什麼操作,操作具體如何執行則是由方法(method)表示的。物件收到訊息後決定呼叫哪個方法來進行處理,方法屬於內部實現,也應是私有的。

Smalltalk中,類通過定義protocol description來表示本類的例項可以響應哪些訊息,方法則是單獨定義,而在C++、Java這些語言中,沒有這個區分,對於C++可以把protocol description理解成標頭檔案裡的函式宣告,把方法理解成原始檔中的函式定義。
對於C#/Java,可以把protocol description理解成介面中的方法列表,方法理解成實現類中的方法。

2.2 狀態及處理過程的隱藏

物件的狀態是私有的,只能由方法操作,方法是行為的具體實現,也是私有的,方法的呼叫是物件收到訊息後由該物件自已進行的,這樣物件的狀態處理細節是完全隱藏的,這種特徵就是“封裝”。

汽車 與 封裝

駕駛手動檔汽車時不用直接去操作它引擎、變速齒輪,而是通過 變速桿、離合/制動/加速 踏板 這些介面,如果你不瞭解汽車的話,應該不知道變速桿和離合器是幹什麼用的,這其實是因為手動檔汽車只是對引擎做了很淺的一層封裝,某些介面其實暴露了其內部的實現 -> 汽油機,以至於在與汽車這個物件互動時需要注意一些規則比如鬆離合器要慢、換檔前要踩離合器等等,以保證這個物件能正常工作,這就增加了使用者和這個物件間的耦合度,假設汽油機做了一些改進或者說引擎換成了電動機,那駕駛人的操作習慣就要作一些調整。

自動檔汽車就封裝得更好,只保留了 制動/加速 踏板,變速桿也被封裝成了幾個抽象檔位,內部細節被隱藏了,對外耦合就小了。

2.2.1 隱藏的實現

JavaScript中可以通過閉包;Ruby中例項變數本身就是隱藏的,外部無法訪問;C++/C#/Java可通過private關鍵字;C雖然語法上不支援,但程式設計師可以通過命名約定實現。

3. OOP的解耦利器 – 多型(subtype polymorphism)

多型從字面上講是指“不同的物件以不同的方法響應相同的訊息”,與程式式程式設計(procedural programming)不同,在OOP中物件是基本單元,函式存在於物件中,外部需要某個操作時向物件傳送訊息,物件來決定呼叫哪個函式,這樣就將行為的實現者和行為的請求者解耦了。

舉個例子,汽車、飛機、輪船這些交通工具,雖然它們的動力原理、操作方式都不一樣,但它們都具有一些相同的介面:加速、獲取速度,現要實現一個測速操作,可以測試任何交通工具。

Java示例:

在上面程式碼中,accelerationTest雖然是交通工具的使用者,但卻完全不受具體交通工具的影響,如果新新增一個Ship(船),accelerationTest一點都不用修改,因為accelerationTest和具體的交通工具都遵循了IVehicle這個協議,這樣accelerationTest就知道:不管你具體是什麼交通工具,反正都能夠響應協議裡的訊息,到底呼叫什麼方法來響應這些訊息則交給了協議的實現者(即具體的交通工具)(這個從實現角度說的話應該是交給了編譯器/直譯器),而不是讓accelerationTest根據具體的交通工具自己選擇呼叫哪個函式(過程式的思維)。

3.1 多型的實現

  • duck-typing(鴨子型別)。因為動態語言中沒有靜態型別檢查,所以能夠做到“只要會呱呱叫的,就可以算是鴨子”,比如用JavaScript程式碼繼續上面的示例:
  • dynamic-dispatch(動態分派)
    dynamic-dispatch是指在執行時去確定真正呼叫哪個函式,比如C++中的虛擬函式

即使是像C這種過程式的語言,也可利用函式指標實現多型:

4. 關於繼承

個人認為OOP只有封裝和多型,繼承不屬OOP的特性,它只是某些程式語言用來實現subtyping、多型和程式碼複用的一種手段。(subtyping和subclassing不是一回事,subtyping表達的是“可替換性”,subclassing就是指繼承,某些語言只能用subclassing實現subtyping)

如果你看過一些OO的書,你會發現都會提到一個原則叫“組合優於繼承”,組合表示的是一種包含關係,而繼承是一種層級關係,為什麼要用組合代替繼承呢?

其實呢,繼承也是包含關係,子類繼承父類,也就包含了父類的狀態,只不過這種包含關係是編譯器幫你做了,還包含了方法的實現,所以能夠響應父類能響應的訊息,就實現了多型。那這一舉兩得,不是挺好嗎?是挺好的,但不用繼承照樣能實現以上功能,而且繼承有如下缺點

  • 大多數的語言都是單根繼承,也就是隻能繼承一個父類,這樣你就失去了一次使用繼承的機會
  • 繼承的耦合度太高,子類和父類的關係在編譯時被固定死了,不能動態切換,而組合關係可以在執行隨意切換,一個例子就是裝飾者模式 ,裝飾者模式和繼承類似實現的是一種“擴充套件”的目的,在裝飾者模式中被擴充套件的物件是可選擇的,而繼承實現的這種擴充套件則是“死”的

什麼時候用繼承?

如果你想實現多型,正好又想複用實現,那麼可以用繼承。在使用繼承前,建議想一下,使用它的目的是什麼,如果僅僅為了實現多型就用介面,如果僅僅為了複用實現就用組合,繼承雖然方便,但太重量級,又有限制,少用為妙。

4.1 為什麼“正方形不是長方形”?

很多資料上都說繼承表達的是一種“xx is a yy”的關係,我覺得這種說法不夠準確,因為繼承是用來實現多型的,是針對行為而言的,所以表達的是“xx 能夠響應 yy 所能響應的訊息”。

“正方形 繼承 長方形”是一個經典的違背“里氏替換原則”的例子,常識上我們一般都認為“正方形是一種特殊的長方形”,貌似滿足is-a的關係,可以用繼承,但如果從介面考慮就不一樣了:長方形能響應“操作長”、“操作寬”這兩個訊息,而正方形只能響應“操作邊長”這一個訊息,所以在OOP中我們要從介面上去考慮而不是簡單的去判斷是否具有“xx是一種特殊的yy”的關係。

5. 總結

OOP是用來在物件間統一協議,讓物件間的互動只關心協議,而不關心實現,所以OOP只有封裝和多型,至於其它的比如繼承,只是語言層面提供的實用特性。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

淺談物件導向程式設計 淺談物件導向程式設計

相關文章