Marin Folwer 說過:
“庫本質上是一組可以呼叫的函式,這些函式現在經常被組織到類中。”
函式組織到類中?恕我冒昧,這個觀點是錯誤的。而且這是對物件導向程式設計中類的非常普遍的誤解。類不是函式的組織者,物件也不是資料結構。
那麼什麼是“合理的”物件呢?哪些不合理呢?區別又是什麼?雖然這是個爭論比較激烈的主題,但同時也是非常重要的。如果我們不瞭解物件到底是什麼,我們怎麼才能編寫出物件導向的軟體呢?好吧,幸虧Java、Ruby,還有其他語言,我們可以。但是它到底有多好呢?很不幸,這不是精確的科學,而且有很多不同的觀點。下面是我認為一個良好物件應該具有的品質。
類與物件
在我們談論物件之前,我們先來看看類是什麼。類是物件出生(也叫例項化)的地方。類的主要職責是根據需要建立新物件,以及當它們不再被使用時銷燬它們。類知道它的孩子長什麼樣、如何表現。換言之,類知道它們遵循的合約(contract)。
有時我聽到類被稱作“物件模板”(比如,Wikipedia就這樣說)。這個定義是不對的,因為它把類放到了被動的境地。這個定義假設有人先取得一個模板,然後使用這個模板建立一個物件。技術上這可能是對的,但是在概念上是錯誤的。其他人不應該牽涉進來 — 應該只有類和它的孩子。一個物件請求類建立另一個物件,然後類建立了一個物件;就是這樣。Ruby表達這個概念要比Java或C++好多了:
1 |
photo = File.new('/tmp/photo.png') |
photo
物件被類File
建立(new
是類的入口點)。一旦被建立後,物件可以自我支配。它不應該知道是誰建立了它,以及類中它的兄弟姐妹有多少。是的,我的意思是反射(reflection) 是個可怕的觀點,我將會在接下來用一篇部落格來詳細闡述:) 現在,我們來談談物件以及它們最好和最糟的方面。
1. 他存在於現實生活中
首先,物件是一個活著的有機體。而且,物件應該被人格化,即,被當做人一樣對待(或者寵物,如果你更喜歡寵物的話)。根本上說,我的意思是物件不是一個資料結構或者一組函式的集合。相反,它是一個獨立的實體,有自己的生命週期,自己的行為,自己的習慣。
一名僱員,一個部門,一個HTTP請求,MySQL中的一張表,檔案的一行,或者檔案本身都是合理的物件 — 因為它們存在於現實生活,即使當軟體被關閉時。更準確來說,一個物件是現實生活中一個生物的表示(representative)。與其他物件來一樣,它作為現實生活中生物的代理。如果沒有這樣的生物,顯然不存在這樣的物件。
1 2 |
photo = File.new('/tmp/photo.png') puts photo.width() |
這個例子中,我請求File
建立一個新物件photo
,它將是磁碟上一個真實檔案的表示。你也許會說檔案也是虛擬的東西,只有電腦開機時才會存在。我同意,那麼我把“現實生活”的重新定義為:它是物件所處的程式範圍之外的一切事物。磁碟上的檔案在我們的程式範圍之外;這就是為何在程式內建立它的表示是完全正確的。
一個控制器,一個解析器,一個過濾器,一個驗證器,一個服務定位器,一個單例,或者一個工廠都不是良好物件(是的,多數GoF模式都是反模式(anti-patterns)!)。脫離了軟體,它們並不存在於現實生活中。它們被建立完全是為了將其他物件聯絡在一起。它們是人造的、仿冒的生物。它們並不表示任何人。嚴格上說,一個XML解析器到底表示誰呢?沒有人。
它們中的一些如果改變名字可能變成良好的;其餘物件的存在則是毫無理由的。比如,XML解析器可以更名為“可解析的XML”,然後可以表示我們程式範圍外的XML文件。
始終問問自己,“我的物件所對應現實生活中的實體是什麼?”如果你不能找到答案,考慮下重構吧。
2. 他根據合約辦事
一個良好物件總是根據合約(constract)辦事。他期望被僱傭是因為他遵循合約而不是他的個人優點。另一方面,當我們僱傭一個物件,我們不應該歧視它,並期望一個特定類的特定物件來為我們工作。我們應該期望任何物件做我們間的合約所約定的事情。只要這個物件做我們所需要的事,我們就不應該關心他的出身,他的性別,或者他的信仰。
比如,我想要在螢幕上展示一張圖片。我希望圖片從一個PNG格式的檔案讀取。我其實是在僱傭一個來自DataFile
類的物件,要求他給我那幅圖片的二進位制內容。
但是等會,我關心內容到底來自哪裡嗎 — 磁碟上的檔案,或者HTTP請求,或者可能Dropbox中的一個文件?事實上,我不關心。我所關心的是有物件給我PNG內容的位元組陣列。所以,我的合約是這樣的:
1 2 3 |
interface Binary { byte[] read(); } |
現在,任何類的任何物件(不僅僅是DataFile
)都可以為我工作。如果他是合格的,那麼他所應該做的,就是遵循合約 — 通過實現Binary
介面。
規則很簡單:良好物件的每個公共方法都應該實現介面中對應的方法。如果你的物件有公共方法沒有實現任何介面,那麼他被設計得很糟糕。
這裡有兩個實際原因。首先,一個沒有合約的物件不能在單元測試中進行模擬(mock)。另外,無合約的物件不能通過裝飾(decoration)來擴充套件。
3. 他是獨特的
一個良好物件應當總是封裝一些東西以保持獨特性。如果沒有可以封裝的東西,這個物件可能有完全一樣的複製品(克隆),我認為這是糟糕的。下面是一個可能有克隆的糟糕物件的例子:
1 2 3 4 5 6 7 8 9 |
class HTTPStatus implements Status { private URL page = new URL("http://www.google.com"); @Override public int read() throws IOException { return HttpURLConnection.class.cast( this.page.openConnection() ).getResponseCode(); } } |
我可以建立很多HTTPStatus
類的例項,它們都是相等的:
1 2 3 |
first = new HTTPStatus(); second = new HTTPStatus(); assert first.equals(second); |
很顯然,實用類(utility classes),可能只包含靜態方法,不能例項化良好物件。更一般地說,實用類沒有本文提到的任何優點,甚至不能稱作”類”。它們僅僅濫用了物件正規化(object paradign),它們能存在於物件導向中僅僅由於它們的創造者啟用了靜態方法。
4. 他是不可變的
一個良好物件應該永遠不改變他封裝的狀態。記住,物件是現實生活中實體的表示,而這個實體應該在物件的整個生命週期中保持不變。換句話說,物件不應該背叛他所表示的實體。他永遠不應該換主人。:)
注意,不可變性(immutability)並不意味著所有方法都應該返回相同的值。相反,一個良好的不可變物件是非常動態的。然而,他不應該改變他的內部狀態。比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Immutable final class HTTPStatus implements Status { private URL page; public HTTPStatus(URL url) { this.page = url; } @Override public int read() throws IOException { return HttpURLConnection.class.cast( this.page.openConnection() ).getResponseCode(); } } |
儘管read()
方法返回不同的值,這個物件仍然是不可變的。他指向一個特定的Web頁面,並且永遠不會指向其他地方。他永遠不會改變他的內部狀態,也不會背叛他所表示的URL。
為什麼不可變性是一個美德呢?這篇文章進行了詳細的解釋:物件應該是不可變的。簡而言之,不可變物件更好,因為:
- 不可變物件建立、測試和使用更加簡單。
- 真正的不可變物件總是執行緒安全的。
- 他們可以幫助避免時間耦合(temporal coupling,[譯者注]指系統中元件的依賴關係與時間有關,如,兩行程式碼,後一行需要前一行程式碼先執行,這種依賴關係就是與時間有關的,對應的還有空間耦合/spatial coupling)。
- 他們的用法沒有副作用(沒有防禦性拷貝,[譯者注]由於物件是可變的,為了儲存物件在執行程式碼前的狀態,需要對該物件做一份拷貝)。
- 他們總是具有失敗原子性(failure atomicity, [譯者注]如果方法失敗,那麼物件狀態應該與方法呼叫前一致)。
- 他們更容易快取。
- 他們可以防止空引用。
當然,一個良好的物件不應該有setter方法,因為這些方法可以改變他的狀態,強迫他背叛URL。換言之,在HTTPStatus
類中加入一個setURL()
方法是個可怕的錯誤。
除了這些,不可變物件將督促你進行更加內聚(cohesive)、健壯(solid)、容易理解(understandable)的設計,如這篇檔案闡述的:不可變性如何有用。
5. 他的類不應該包含任何靜態(Static)的東西
一個靜態方法實現了類的行為,而不是物件的。假如我們有個類File
,他的孩子都擁有size()
方法:
1 2 3 4 5 6 |
final class File implements Measurable { @Override public int size() { // calculate the size of the file and return } } |
目前為止,一切都還好;size()
方法的存在是因為合約Measurable
,每個File
類的物件都可以測量自身的大小。一個可怕的錯誤可能是將類的這個方法設計為靜態方法(這種類被稱作實用類,在Java,Ruby,幾乎每一個OOP語言中都很流行):
1 2 3 4 5 6 |
// 糟糕的設計,請勿使用! class File { public static int size(String file) { // 計算檔案大小並返回 } } |
這種設計完全違背了物件導向正規化(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()
可以做得更多。一個傳統的方式是擴充套件這個類,並重寫他的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class OnlyValidStatus extends HTTPStatus { public OnlyValidStatus(URL url) { super(url); } @Override public int read() throws IOException { int code = super.read(); if (code > 400) { throw new RuntimException("unsuccessful HTTP code"); } return code; } } |
為什麼這是錯的?我們冒險破壞了整個父類的邏輯,因為重寫了他的一個方法。記住,一旦我在子類重寫了read()
方法,所有來自父類的方法都會使用新版本的read()
方法。字面上講,我們其實是在將一份新的“實現片段”插入到類中。理論上講,這是種冒犯。
另外,擴充套件一個最終類,你需要把他當做一個黑盒,然後使用自己的實現來包裝他(也叫裝飾器模式):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
final class OnlyValidStatus implements Status { private final Status origin; public OnlyValidStatus(Status status) { this.origin = status; } @Override public int read() throws IOException { int code = this.origin.read(); if (code > 400) { throw new RuntimException("unsuccessful HTTP code"); } return code; } } |
確保該類實現了與原始類相同的介面:Status
。HTTPStatus
的例項將會通過建構函式被傳遞和封裝給他。然後所有的呼叫將會被攔截,如果需要,可以通過其他方式來實現。這個設計中,我們把原始物件當做黑盒,而沒有觸及他的內部邏輯。
如果你不使用final
關鍵字,任何人(包括你自己)都可以擴充套件這個類並且…冒犯他:( 所以沒有final
的類是個糟糕的設計。
抽象類則完全相反 – 他告訴我們他是不完整的,我們不能”原封不動(as is)”直接使用他。我們需要將我們自己的實現邏輯插入到其中,但是隻插入到他開放給我們的位置。這些位置被顯式地標記為abstract
。比如,我們的HTTPStatus
可能看起來像這樣:
1 2 3 4 5 6 7 8 9 10 11 |
abstract class ValidatedHTTPStatus implements Status { @Override public final int read() throws IOException { int code = this.origin.read(); if (!this.isValid()) { throw new RuntimException("unsuccessful HTTP code"); } return code; } protected abstract boolean isValid(); } |
你也看到了,這個類不能夠準確地知道如何去驗證HTTP狀態碼,他期望我們通過繼承或者過載isValid()
方法來插入那一部分邏輯。我們將不會通過繼承來冒犯他,因為他通過final
來保護其他方法(注意他的方法的修飾符)。因此,這個類預料到我們的冒犯,並完美地保護了這些方法。
總結一下,你的類應該要麼是final
要麼是abstract
的 – 而不是其他任何型別。