好對象的7大美德
英文原文:Seven Virtues of a Good Object
Marin Folwer 說過:
“庫本質上是一組可以調用的函數,這些函數現在經常被組織到類中。”
函數組織到類中?恕我冒昧,這個觀點是錯誤的。而且這是對面向對象編程中類的非常普遍的誤解。類不是函數的組織者,對象也不是數據結構。
那么什么是“合理的”對象呢?哪些不合理呢?區別又是什么?雖然這是個爭論比較激烈的主題,但同時也是非常重要的。如果我們不了解對象到底是什么,我們怎么才能編寫出面向對象的軟件呢?好吧,幸虧Java、Ruby,還有其他語言,我們可以。但是它到底有多好呢?很不幸,這不是精確的科學,而且有很多不同的觀點。下面是我認為一個良好對象應該具有的品質。
類與對象
在我們談論對象之前,我們先來看看類是什么。類是對象出生(也叫實例化)的地方。類的主要職責是根據需要創建新對象,以及當它們不再被使用時銷毀它們。類知道它的孩子長什么樣、如何表現。換言之,類知道它們遵循的合約(contract)。
有時我聽到類被稱作“對象模板”(比如,Wikipedia就這樣說)。這個定義是不對的,因為它把類放到了被動的境地。這個定義假設有人先取得一個模板,然后使用這個模板創建一個對象。技術上這可能是對的,但是在概念上是錯誤的。其他人不應該牽涉進來 —— 應該只有類和它的孩子。一個對象請求類創建另一個對象,然后類創建了一個對象;就是這樣。Ruby表達這個概念要比Java或C++好多了:
photo = File.new('/tmp/photo.png')
photo
對象被類File
創建(new
是類的入口點)。一旦被創建后,對象可以自我支配。它不應該知道是誰創建了它,以及類中它的兄弟姐妹有多少。是的,我的意思是反射(reflection) 是個可怕的觀點,我將會在接下來用一篇博客來詳細闡述:) 現在,我們來談談對象以及它們最好和最糟的方面。
1. 他存在于現實生活中
首先,對象是一個活著的有機體。而且,對象應該被人格化,即,被當做人一樣對待(或者寵物,如果你更喜歡寵物的話)。根本上說,我的意思是對象不是一個數據結構或者一組函數的集合。相反,它是一個獨立的實體,有自己的生命周期,自己的行為,自己的習慣。
一名雇員,一個部門,一個HTTP請求,MySQL中的一張表,文件的一行,或者文件本身都是合理的對象 —— 因為它們存在于現實生活,即使當軟件被關閉時。更準確來說,一個對象是現實生活中一個生物的表示(representative)。與其他對象來一樣,它作為現實生活中生物的代理。如果沒有這樣的生物,顯然不存在這樣的對象。
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內容的字節數組。所以,我的合約是這樣的:
interface Binary {
byte[] read();
}
現在,任何類的任何對象(不僅僅是DataFile
)都可以為我工作。如果他是合格的,那么他所應該做的,就是遵循合約 —— 通過實現Binary
接口。
規則很簡單:良好對象的每個公共方法都應該實現接口中對應的方法。如果你的對象有公共方法沒有實現任何接口,那么他被設計得很糟糕。
這里有兩個實際原因。首先,一個沒有合約的對象不能在單元測試中進行模擬(mock)。另外,無合約的對象不能通過裝飾(decoration)來擴展。
3. 他是獨特的
一個良好對象應當總是封裝一些東西以保持獨特性。如果沒有可以封裝的東西,這個對象可能有完全一樣的復制品(克隆),我認為這是糟糕的。下面是一個可能有克隆的糟糕對象的例子:
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
類的實例,它們都是相等的:
first = new HTTPStatus(); second = new HTTPStatus(); assert first.equals(second);
很顯然,實用類(utility classes),可能只包含靜態方法,不能實例化良好對象。更一般地說,實用類沒有本文提到的任何優點,甚至不能稱作”類”。它們僅僅濫用了對象范式(object paradign),它們能存在于面向對象中僅僅由于它們的創造者啟用了靜態方法。
4. 他是不可變的
一個良好對象應該永遠不改變他封裝的狀態。記住,對象是現實生活中實體的表示,而這個實體應該在對象的整個生命周期中保持不變。換句話說,對象不應該背叛他所表示的實體。他永遠不應該換主人。:)
注意,不可變性(immutability)并不意味著所有方法都應該返回相同的值。相反,一個良好的不可變對象是非常動態的。然而,他不應該改變他的內部狀態。比如:
@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()
方法:
final class File implements Measurable { @Override public int size() { // calculate the size of the file and return } }
目前為止,一切都還好;size()
方法的存在是因為合約Measurable
,每個File
類的對象都可以測量自身的大小。一個可怕的錯誤可能是將類的這個方法設計為靜態方法(這種類被稱作實用類,在Java,Ruby,幾乎每一個OOP語言中都很流行):
// 糟糕的設計,請勿使用! 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()
可以做得更多。一個傳統的方式是擴展這個類,并重寫他的方法:
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()
方法。字面上講,我們其實是在將一份新的“實現片段”插入到類中。理論上講,這是種冒犯。
另外,擴展一個最終類,你需要把他當做一個黑盒,然后使用自己的實現來包裝他(也叫裝飾器模式):
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
可能看起來像這樣:
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
的 —— 而不是其他任何類型。