好物件的 7 大美德

douxingxiang發表於2015-02-21

Marin Folwer 過:

“庫本質上是一組可以呼叫的函式,這些函式現在經常被組織到類中。”

函式組織到類中?恕我冒昧,這個觀點是錯誤的。而且這是對物件導向程式設計中類的非常普遍的誤解。類不是函式的組織者,物件也不是資料結構。

那麼什麼是“合理的”物件呢?哪些不合理呢?區別又是什麼?雖然這是個爭論比較激烈的主題,但同時也是非常重要的。如果我們不瞭解物件到底是什麼,我們怎麼才能編寫出物件導向的軟體呢?好吧,幸虧Java、Ruby,還有其他語言,我們可以。但是它到底有多好呢?很不幸,這不是精確的科學,而且有很多不同的觀點。下面是我認為一個良好物件應該具有的品質。

類與物件

在我們談論物件之前,我們先來看看是什麼。類是物件出生(也叫例項化)的地方。類的主要職責是根據需要建立新物件,以及當它們不再被使用時銷燬它們。類知道它的孩子長什麼樣、如何表現。換言之,類知道它們遵循的合約(contract)

有時我聽到類被稱作“物件模板”(比如,Wikipedia就這樣說)。這個定義是不對的,因為它把類放到了被動的境地。這個定義假設有人先取得一個模板,然後使用這個模板建立一個物件。技術上這可能是對的,但是在概念上是錯誤的。其他人不應該牽涉進來 — 應該只有類和它的孩子。一個物件請求類建立另一個物件,然後類建立了一個物件;就是這樣。Ruby表達這個概念要比Java或C++好多了:

photo物件被類File建立(new是類的入口點)。一旦被建立後,物件可以自我支配。它不應該知道是誰建立了它,以及類中它的兄弟姐妹有多少。是的,我的意思是反射(reflection) 是個可怕的觀點,我將會在接下來用一篇部落格來詳細闡述:) 現在,我們來談談物件以及它們最好和最糟的方面。

1. 他存在於現實生活中

首先,物件是一個活著的有機體。而且,物件應該被人格化,即,被當做人一樣對待(或者寵物,如果你更喜歡寵物的話)。根本上說,我的意思是物件不是一個資料結構或者一組函式的集合。相反,它是一個獨立的實體,有自己的生命週期,自己的行為,自己的習慣。

一名僱員,一個部門,一個HTTP請求,MySQL中的一張表,檔案的一行,或者檔案本身都是合理的物件 — 因為它們存在於現實生活,即使當軟體被關閉時。更準確來說,一個物件是現實生活中一個生物的表示(representative)。與其他物件來一樣,它作為現實生活中生物的代理。如果沒有這樣的生物,顯然不存在這樣的物件。

這個例子中,我請求File建立一個新物件photo,它將是磁碟上一個真實檔案的表示。你也許會說檔案也是虛擬的東西,只有電腦開機時才會存在。我同意,那麼我把“現實生活”的重新定義為:它是物件所處的程式範圍之外的一切事物。磁碟上的檔案在我們的程式範圍之外;這就是為何在程式內建立它的表示是完全正確的。

一個控制器,一個解析器,一個過濾器,一個驗證器,一個服務定位器,一個單例,或者一個工廠都不是良好物件(是的,多數GoF模式都是反模式(anti-patterns)!)。脫離了軟體,它們並不存在於現實生活中。它們被建立完全是為了將其他物件聯絡在一起。它們是人造的、仿冒的生物。它們並不表示任何人。嚴格上說,一個XML解析器到底表示誰呢?沒有人。

它們中的一些如果改變名字可能變成良好的;其餘物件的存在則是毫無理由的。比如,XML解析器可以更名為“可解析的XML”,然後可以表示我們程式範圍外的XML文件。

始終問問自己,“我的物件所對應現實生活中的實體是什麼?”如果你不能找到答案,考慮下重構吧。

2. 他根據合約辦事

一個良好物件總是根據合約(constract)辦事。他期望被僱傭是因為他遵循合約而不是他的個人優點。另一方面,當我們僱傭一個物件,我們不應該歧視它,並期望一個特定類的特定物件來為我們工作。我們應該期望任何物件做我們間的合約所約定的事情。只要這個物件做我們所需要的事,我們就不應該關心他的出身,他的性別,或者他的信仰。

比如,我想要在螢幕上展示一張圖片。我希望圖片從一個PNG格式的檔案讀取。我其實是在僱傭一個來自DataFile類的物件,要求他給我那幅圖片的二進位制內容。

但是等會,我關心內容到底來自哪裡嗎 — 磁碟上的檔案,或者HTTP請求,或者可能Dropbox中的一個文件?事實上,我不關心。我所關心的是有物件給我PNG內容的位元組陣列。所以,我的合約是這樣的:

現在,任何類的任何物件(不僅僅是DataFile)都可以為我工作。如果他是合格的,那麼他所應該做的,就是遵循合約 — 通過實現Binary介面。

規則很簡單:良好物件的每個公共方法都應該實現介面中對應的方法。如果你的物件有公共方法沒有實現任何介面,那麼他被設計得很糟糕。

這裡有兩個實際原因。首先,一個沒有合約的物件不能在單元測試中進行模擬(mock)。另外,無合約的物件不能通過裝飾(decoration)來擴充套件。

3. 他是獨特的

一個良好物件應當總是封裝一些東西以保持獨特性。如果沒有可以封裝的東西,這個物件可能有完全一樣的複製品(克隆),我認為這是糟糕的。下面是一個可能有克隆的糟糕物件的例子:

我可以建立很多HTTPStatus類的例項,它們都是相等的:

很顯然,實用類(utility classes),可能只包含靜態方法,不能例項化良好物件。更一般地說,實用類沒有本文提到的任何優點,甚至不能稱作”類”。它們僅僅濫用了物件正規化(object paradign),它們能存在於物件導向中僅僅由於它們的創造者啟用了靜態方法。

4. 他是不可變的

一個良好物件應該永遠不改變他封裝的狀態。記住,物件是現實生活中實體的表示,而這個實體應該在物件的整個生命週期中保持不變。換句話說,物件不應該背叛他所表示的實體。他永遠不應該換主人。:)

注意,不可變性(immutability)並不意味著所有方法都應該返回相同的值。相反,一個良好的不可變物件是非常動態的。然而,他不應該改變他的內部狀態。比如:

儘管read()方法返回不同的值,這個物件仍然是不可變的。他指向一個特定的Web頁面,並且永遠不會指向其他地方。他永遠不會改變他的內部狀態,也不會背叛他所表示的URL。

為什麼不可變性是一個美德呢?這篇文章進行了詳細的解釋:物件應該是不可變的。簡而言之,不可變物件更好,因為:

  • 不可變物件建立、測試和使用更加簡單。
  • 真正的不可變物件總是執行緒安全的。
  • 他們可以幫助避免時間耦合(temporal coupling,[譯者注]指系統中元件的依賴關係與時間有關,如,兩行程式碼,後一行需要前一行程式碼先執行,這種依賴關係就是與時間有關的,對應的還有空間耦合/spatial coupling)。
  • 他們的用法沒有副作用(沒有防禦性拷貝,[譯者注]由於物件是可變的,為了儲存物件在執行程式碼前的狀態,需要對該物件做一份拷貝)。
  • 他們總是具有失敗原子性(failure atomicity, [譯者注]如果方法失敗,那麼物件狀態應該與方法呼叫前一致)。
  • 他們更容易快取。
  • 他們可以防止空引用

當然,一個良好的物件不應該有setter方法,因為這些方法可以改變他的狀態,強迫他背叛URL。換言之,在HTTPStatus類中加入一個setURL()方法是個可怕的錯誤。

除了這些,不可變物件將督促你進行更加內聚(cohesive)、健壯(solid)、容易理解(understandable)的設計,如這篇檔案闡述的:不可變性如何有用

5. 他的類不應該包含任何靜態(Static)的東西

一個靜態方法實現了類的行為,而不是物件的。假如我們有個類File,他的孩子都擁有size()方法:

目前為止,一切都還好;size()方法的存在是因為合約Measurable,每個File類的物件都可以測量自身的大小。一個可怕的錯誤可能是將類的這個方法設計為靜態方法(這種類被稱作實用類,在Java,Ruby,幾乎每一個OOP語言中都很流行):

這種設計完全違背了物件導向正規化(object-oriented paradigm)。為什麼?因為靜態方法將物件導向程式設計變成“面向類”程式設計(class-oriented programming)了。size()方法將類的行為暴露出去,而不是他的物件。這有什麼錯呢,你可能會問?為什麼我們不能在程式碼中將物件和類都當做第一類公民(first-class citizens,[譯者注]可以參與其他實體所有操作的實體,這些操作可能是賦值給變數,作為引數傳遞給方法,可以從方法返回等,比如int就是大多數語言的第一類公民,函式是函式式語言的第一類公民等)呢?為什麼他們不能同時有方法和屬性呢?

問題是在面向類程式設計中,分解(decomposition)不適用。我們不能拆分一個複雜的問題,因為整個程式中只有一個類的例項存在。而OOP的強大是允許我們將物件作為一種作用域分解(scope decomposition)的工具來用。當我在方法中例項化一個物件,他將專注於我的特定任務。他與這個方法中的其他物件是完全隔離的。這個物件在此方法的作用域中是個區域性變數。含有靜態方法的類,總是一個全域性變數,不管我在哪裡使用他。因此,我不能把與這個變數的互動與其他變數隔離開來。

除了概念上與物件導向的原則相悖,公共靜態方法有一些實際的缺點:

首先,不可能模擬他們(好吧,你可以使用PowerMock,這將成為你在一個Java專案所能做出的最可怕決定…幾年前,我犯過一次)。

再者,概念上他們不是執行緒安全的,因為他們總是根據靜態變數互動,而靜態變數可以被所有執行緒訪問。你可以使他們執行緒安全,但是這總是需要顯式地同步(explicit synchronization)。

每次你遇到一個靜態方法,馬上重寫!我不想再說靜態(或全域性)變數有多可怕了。我認為這是很明顯的。

6. 他的名字不是一個工作頭銜

一個物件的名字應該告訴我們這個物件什麼,而不是它什麼,就像我們在現實生活中給物體起名字一樣:書而不是頁面聚合器,杯子而不是裝水器,T恤而不是身體服裝師(body dresser)。當然也有例外,比如印表機和計算機,但是他們都是最近才被髮明出來,而且這些人沒有讀過這篇文章。:)

比如,這些名字告訴我們他們的主人是誰:蘋果,檔案,一組HTTP請求,一個socket,一個XML文件,一個使用者列表,一個正規表示式,一個整數,一個PostgreSQL表,或者Jeffrey Lebowski。一個命名合理的物件總是可以用一個小的示意圖就能畫出來。即使正規表示式也可以畫出來。

相反,下面例子中的命名,是在告訴我們他們的主人做什麼:一個檔案閱讀器,一個文字解析器,一個URL驗證器,一個XML印表機,一個服務定位器,一個單例,一個指令碼執行器,或者一個Java程式設計師。你能畫出來他們嗎?不,你不能。這些名字對良好物件來說是不合適的。他們是糟糕的名字,會導致糟糕的設計。

一般來說,避免以“-er”結尾的命名 — 他們中的大多數都是糟糕的。

FileReader的替代名字是什麼呢?”我聽到你問了。什麼將會是個好命名呢?我們想想。我們已經有File了,他是真實世界中磁碟上檔案的表示。這個表示並不足夠強大,因為他不知道怎麼讀取檔案內容。我們希望建立更強大的,並且具有此能力的一個。我們怎麼稱呼他呢?記住,名字應該說明他是什麼,而不是他做什麼。那他是什麼呢?他是個擁有資料的檔案;但是不僅僅是類似File的檔案,而是一個更復雜的擁有資料的檔案。那麼FileWithData或者更簡單DataFile怎麼樣?

相同的邏輯也適用於其他名字。始終思考下他是什麼而不是他做什麼。給你的物件一個真實的、有意義的名字而不是一個工作頭銜。

7. 他的類要麼是Final,要麼是Abstract

一個良好物件要麼來自一個最終類,要麼來自一個抽象類。一個final類不能通過繼承被擴充套件。一個abstract類不能擁有孩子。簡單上說,一個類應該要麼聲稱,“你不能破壞我,我對你來說是個黑盒”,要麼“我已經被破壞了;先修復我然後再使用我”。

它們中間不會有其他選項。最終類是個黑盒,你不能通過任何方式進行修改。當他工作他就工作,你要麼用他,要麼丟棄他。你不能建立另外一個類繼承他的屬性。這是不允許的,因為final修飾符的存在。唯一可以擴充套件最終類的方法是對他的孩子進行包裝。假如有個類HTTPStatus(見上),我不喜歡他。好吧,我喜歡他,但是他對我來說不是足夠強大。我希望如果HTTP狀態碼大於400時能丟擲一個異常。我希望他的方法read()可以做得更多。一個傳統的方式是擴充套件這個類,並重寫他的方法:

為什麼這是錯的?我們冒險破壞了整個父類的邏輯,因為重寫了他的一個方法。記住,一旦我在子類重寫了read()方法,所有來自父類的方法都會使用新版本的read()方法。字面上講,我們其實是在將一份新的“實現片段”插入到類中。理論上講,這是種冒犯。

另外,擴充套件一個最終類,你需要把他當做一個黑盒,然後使用自己的實現來包裝他(也叫裝飾器模式):

確保該類實現了與原始類相同的介面:StatusHTTPStatus的例項將會通過建構函式被傳遞和封裝給他。然後所有的呼叫將會被攔截,如果需要,可以通過其他方式來實現。這個設計中,我們把原始物件當做黑盒,而沒有觸及他的內部邏輯。

如果你不使用final關鍵字,任何人(包括你自己)都可以擴充套件這個類並且…冒犯他:( 所以沒有final的類是個糟糕的設計。

抽象類則完全相反 – 他告訴我們他是不完整的,我們不能”原封不動(as is)”直接使用他。我們需要將我們自己的實現邏輯插入到其中,但是隻插入到他開放給我們的位置。這些位置被顯式地標記為abstract。比如,我們的HTTPStatus可能看起來像這樣:

你也看到了,這個類不能夠準確地知道如何去驗證HTTP狀態碼,他期望我們通過繼承或者過載isValid()方法來插入那一部分邏輯。我們將不會通過繼承來冒犯他,因為他通過final來保護其他方法(注意他的方法的修飾符)。因此,這個類預料到我們的冒犯,並完美地保護了這些方法。

總結一下,你的類應該要麼是final要麼是abstract的 – 而不是其他任何型別。

相關文章