早在 2015 年,Brian Will 撰寫了一篇有挑釁性的部落格:物件導向程式設計:一個災難故事。他隨後發布了一個名為物件導向程式設計很糟糕的視訊,該視訊更加詳細。
我建議你花些時間觀看視訊,下面是我的一段總結:
OOP 的柏拉圖式理想是一堆相互解耦的物件,它們彼此之間傳送無狀態訊息。沒有人真的像這樣製作軟體,Brian 指出這甚至沒有意義:物件需要知道向哪個物件傳送訊息,這意味著它們需要相互參照。該視訊大部分講述的是這樣一個痛點:人們試圖將物件耦合以實現控制流,同時假裝它們是通過設計解耦的。
總的來說,他的想法與我自己的 OOP 經驗產生了共鳴:物件沒有問題,但是我一直不滿意的是面向物件建模程式控制流,並且試圖使程式碼“正確地”物件導向似乎總是在建立不必要的複雜性。
有一件事我認為他無法完全解釋。他直截了當地說“封裝沒有作用”,但在註腳後面加上“在細粒度的程式碼級別”,並繼續承認物件有時可以奏效,並且在庫和檔案級別封裝是可以的。但是他沒有確切解釋為什麼有時會奏效,有時卻沒有奏效,以及如何和在何處劃清界限。有人可能會說這使他的 “OOP 不好”的說法有缺陷,但是我認為他的觀點是正確的,並且可以在根本狀態和偶發狀態之間劃清界限。
如果你以前從未聽說過“根本”和“偶發”這兩個術語的使用,那麼你應該閱讀 Fred Brooks 的經典文章《沒有銀彈》。(順便說一句,他寫了許多很棒的有關構建軟體系統的文章。)我以前曾寫過關於根本和偶發的複雜性的文章,這裡有一個簡短的摘要:軟體是複雜的。部分原因是因為我們希望軟體能夠解決混亂的現實世界問題,因此我們將其稱為“根本複雜性”。“偶發複雜性”是所有其它的複雜性,因為我們正嘗試使用矽和金屬來解決與矽和金屬無關的問題。例如,對於大多數程式而言,用於記憶體管理或在記憶體與磁碟之間傳輸資料或解析文字格式的程式碼都是“偶發的複雜性”。
假設你正在構建一個支援多個頻道的聊天應用。訊息可以隨時到達任何頻道。有些頻道特別有趣,當有新訊息傳入時,使用者希望得到通知。而其他頻道靜音:訊息被儲存,但使用者不會受到打擾。你需要跟蹤每個頻道的使用者首選設定。
一種實現方法是在頻道和頻道設定之間使用對映(也稱為雜湊表、字典或關聯陣列)。注意,對映是 Brian Will 所說的可以用作物件的抽象資料型別(ADT)。
如果我們有一個偵錯程式並檢視記憶體中的對映物件,我們將看到什麼?我們當然會找到頻道 ID 和頻道設定資料(或至少指向它們的指標)。但是我們還會找到其它資料。如果該對映是使用紅黑樹實現的,我們將看到帶有紅/黑標籤和指向其他節點的指標的樹節點物件。與頻道相關的資料是根本狀態,而樹節點是偶發狀態。不過,請注意以下幾點:該對映有效地封裝了它的偶發狀態 —— 你可以用 AVL 樹實現的另一個對映替換該對映,並且你的聊天程式仍然可以使用。另一方面,對映沒有封裝根本狀態(僅使用 get()
和 set()
方法存取資料並不是封裝)。事實上,對映與根本狀態是盡可能不可知的,你可以使用基本相同的對映資料結構來儲存與頻道或通知無關的其他對映。
這就是對映 ADT 如此成功的原因:它封裝了偶發狀態,並與根本狀態解耦。如果你思考一下,Brian 用封裝描述的問題就是嘗試封裝根本狀態。其他描述的好處是封裝偶發狀態的好處。
要使整個軟體系統都達到這一理想狀況相當困難,但擴充套件開來,我認為它看起來像這樣:
其中有些違反了我很久以來的直覺。例如,如果你有一個資料庫查詢函數,如果資料庫連線處理隱藏在該函數內部,並且唯一的引數是查詢引數,那麼介面會看起來會更簡單。但是,當你使用這樣的函數構建軟體系統時,協調資料庫的使用實際上變得更加複雜。元件不僅以自己的方式做事,而且還試圖將自己所做的事情隱藏為“實現細節”。資料庫查詢需要資料庫連線這一事實從來都不是實現細節。如果無法隱藏某些內容,那麼顯露它是更合理的。
我對將物件導向程式設計和函數語言程式設計放在對立的兩極非常警惕,但我認為從函數語言程式設計進入物件導向程式設計的另一極端是很有趣的:OOP 試圖封裝事物,包括無法封裝的根本複雜性,而純函數語言程式設計往往會使事情變得明確,包括一些偶發複雜性。在大多數時候,這沒什麼問題,但有時候(比如在純函數式語言中構建自我指稱的資料結構)設計更多的是為了函數程式設計,而不是為了簡便(這就是為什麼 Haskell 包含了一些“逃生出口”)。我之前寫過一篇所謂“弱純性”的中間立場。
Brian 發現封裝對更大規模有效,原因有幾個。一個是,由於大小的原因,較大的元件更可能包含偶發狀態。另一個是“偶發”與你要解決的問題有關。從聊天程式使用者的角度來看,“偶發的複雜性”是與訊息、頻道和使用者等無關的任何事物。但是,當你將問題分解為子問題時,更多的事情就變得“根本”。例如,在解決“構建聊天應用”問題時,可以說頻道名稱和頻道 ID 之間的對映是偶發的複雜性,而在解決“實現 getChannelIdByName()
函數”子問題時,這是根本複雜性。因此,封裝對於子元件的作用比對父元件的作用要小。
順便說一句,在視訊的結尾,Brian Will 想知道是否有任何語言支援無法存取它們所作用的範圍的匿名函數。D 語言可以。 D 中的匿名 Lambda 通常是閉包,但是如果你想要的話,也可以宣告匿名無狀態函數:
import std.stdio;void main(){ int x = 41; // Value from immediately executed lambda auto v1 = () { return x + 1; }(); writeln(v1); // Same thing auto v2 = delegate() { return x + 1; }(); writeln(v2); // Plain functions aren't closures auto v3 = function() { // Can't access x // Can't access any mutable global state either if also marked pure return 42; }(); writeln(v3);}