通過本 系列 的前幾期,您已經(jīng)知道,我的觀點是軟件的每個部分都包括可重用的代碼塊。 例如,公司處理安全性的方式在整個應用程序甚至多個應用程序中可能都是一致的。 這就是我所說的 慣用模式 的實例。這些模式代表對構建軟件特定部分時遇到的問題的常用解決方案。慣用模式有兩種類型:
在前幾期中,我將大部分注意力放在如何發(fā)現(xiàn)這些模式上面。但是,發(fā)現(xiàn)模式之后,必須能夠?qū)⑺鼈冏鳛榭芍赜么a加以利用。在本文中,我將研究設計與代碼之間的關系,特別是表達性強的代碼如何使模式的累積變得更容易。您將看到,有時候通過改變抽象風格,可以解決一些看似難以解決的設計問題,并且可以簡化代碼。
早在 1992 年,Jack Reeves 寫了一篇題為 “What is Software Design?” 的思維敏銳的論文(參考資料 提供了一個在線版本)。在此文中,他將傳統(tǒng)的工程(例如硬件工程和結(jié)構工程)與軟件 “工程” 作了比較,目的是為軟件開發(fā)人員拿掉工程這個詞上的引號。這篇論文得出一些有趣的結(jié)論。
Reeves 首先觀察到,一項工程最終交付的成果是 “某種類型的文檔”。設計橋梁的結(jié)構工程師不會交付真正的橋。其最終成果是一座橋的設計。然后,這份設計被傳到一個建筑團隊手上,由他們來建造真正的橋梁。對于軟件而言,類似的設計文檔是什么呢?是餐巾紙上的涂鴉、白板上的草圖、UML 圖、時序圖還是其他類似的工件?這些都是設計的一部分,它們合起來仍不足以讓制造團隊做出實際的東西來。在軟件中,制造團隊是編譯器和部署機制,這意味著完整的設計是源代碼 — 完整的 源代碼。其他工件只能為創(chuàng)建代碼提供幫助,但是最終的設計成果還是源代碼本身,這意味著軟件中的設計不能脫離源代碼。
Reeves 接下來的觀點是關于制造成本的,制造成本通常不算工程的一部分,但是是工件的總體成本估計的一部分。構建物理實體較為昂貴,這通常是整個生產(chǎn)流程中最昂貴的部分。相反,正如 Reeves 所說的:
“...軟件構建起來很便宜。它廉價得簡直就像是免費。”
記住,說這句話的時候,他正在經(jīng)歷 C++ 編譯和鏈接階段,這可是非常消耗時間的?,F(xiàn)在,在 Java? 領域,每時每刻都有團隊冒出來實現(xiàn)您的設計!軟件構建現(xiàn)在是如此的廉價,以至于幾乎可以忽略。相對于傳統(tǒng)的工程師,我們有著巨大的優(yōu)勢。傳統(tǒng)工程師肯定也很希望能夠免費地建造他們的設計,并進行假設分析的游戲。您能想象嗎?如果橋梁工程師能夠?qū)崟r地試驗他們的設計,而且還是免費,那么造出來的橋梁將會是多么的精致。
制造是如此容易,這就解釋了為什么在軟件開發(fā)中沒有那么高的數(shù)學嚴密性。為了取得可預測性,傳統(tǒng)工程師開發(fā)了一些數(shù)學模型和其他尖端技術。而軟件開發(fā)人員不需要那種級別的嚴密分析。構建設計并對其進行測試,比為其行為構建形式化的證明要來得容易。測試就是軟件開發(fā)的工程嚴謹度(engineering rigor)。這也導致了 Reeves 的論文中的一個最有趣的結(jié)論:
如果軟件設計相當容易被證實,并且基本上可以免費構建,那么毫不奇怪,軟件設計必將變得極其龐大而復雜。
實際上,我認為軟件設計是人類有史以來嘗試過的最復雜的事情,尤其是在我們所構建的軟件的復雜性不斷攀升的背景下??紤]到軟件開發(fā)成為主流也才大約 50 年的光景,通常的企業(yè)軟件的復雜性已經(jīng)令人瞠目。
Reeves 的論文得出的另一個結(jié)論是,在目前,軟件中的設計(也就是編寫整個源代碼)是最昂貴的活動。也就是說,在設計時所浪費的時間是最寶貴的資源。這將我們帶回到緊急設計上來。如果在開始編寫代碼之前,花費大量的時間試圖參與到所有的事情中來,那么您總會浪費一些時間,因為一開始有些事情是未知的。換句話說,在編寫軟件時,您總是陷入意想不到的時間黑洞,因為有些需求比您想象的更復雜,或者您一開始并沒有完全理解問題。越靠后做決定,就越有把握作出更好的決定 — 因為您所獲得的上下文和知識是與時俱增的,如 圖 1 所示:
精益軟件運動有一個很好的概念叫做 最后可靠時刻(last responsible moment) — 不是將決定推遲到最后時刻,而是最后可靠時刻。等待的時間越長,就越有機會擁有適合的設計。
Reeves 論文中的另一個結(jié)論是圍繞可讀設計的重要性的,可讀設計又轉(zhuǎn)換成更加可讀的代碼。發(fā)現(xiàn)代碼中的慣用模式已經(jīng)夠難了,但是如果語言中再加上一些額外的晦澀的東西,那就會難上加難。例如,發(fā)現(xiàn)匯編語言代碼基中的慣用模式就非常困難,因為該語言強加了太多晦澀的元素,必須環(huán)顧四周才能 “看到” 設計。
既然設計就是代碼,那么應該盡量選擇表達性最強的語言。充分利用語言的表達性有利于更容易地發(fā)現(xiàn)慣用模式,因為設計的媒介更清晰。
下面是一個例子。在本系列較早的一期(“組合方法和 SLAP”)中,我應用組合方法和 單一抽象層(SLAP)原則,對一些已有代碼進行了重構。清單 1 顯示我得出的頂層代碼:
addOrder()
方法的抽象 public void addOrderFrom(ShoppingCart cart, String userName, Order order) throws SQLException { setupDataInfrastructure(); try { add(order, userKeyBasedOn(userName)); addLineItemsFrom(cart, order.getOrderKey()); completeTransaction(); } catch (SQLException sqlx) { rollbackTransaction(); throw sqlx; } finally { cleanUp(); } } // remainder of code omitted for brevity |
這看上去可以作為不錯的慣用模式積累起來。積累慣用模式的第一種途徑是使用 “原生” 語言(即 Java),如 清單 2 所示:
public void wrapInTransaction(Command c) { setupDataInfrastructure(); try { c.execute(); completeTransaction(); } catch (RuntimeException ex) { rollbackTransaction(); throw ex; } finally { cleanUp(); } } public void addOrderFrom(final ShoppingCart cart, final String userName, final Order order) throws SQLException { wrapInTransaction(new Command() { public void execute() { add(order, userKeyBasedOn(userName)); addLineItemsFrom(cart, order.getOrderKey()); } }); } |
在這個版本中,我使用 Gang of Four 的 Command 設計模式(請參閱 參考資料),將樣板代碼抽象到 wrapInTransaction()
方法。 addOrderFrom()
方法現(xiàn)在可讀性強多了 — 該方法的精華(最深處的兩行)現(xiàn)在更明顯了。但是,為了達到那種程度的抽象,Java 語言附加了很多技術性的繁瑣的東西。您必須理解匿名內(nèi)聯(lián)類是如何工作的(Command
子類的內(nèi)聯(lián)聲明),并理解 execute()
方法的含義。例如,在匿名內(nèi)聯(lián)類的主體中,只能調(diào)用外部類中的 final 對象引用。
如果用表達性更強的 Java 方言來編寫同樣的代碼,結(jié)果會怎樣?清單 3 顯示用 Groovy 重新編寫的同一個方法:
addOrderFrom()
方法 public class OrderDbClosure { def wrapInTransaction(command) { setupDataInfrastructure() try { command() completeTransaction() } catch (RuntimeException ex) { rollbackTransaction() throw ex } finally { cleanUp() } } def addOrderFrom(cart, userName, order) { wrapInTransaction { add order, userKeyBasedOn(userName) addLineItemsFrom cart, order.getOrderKey() } } } |
該代碼(特別是 addOrderFrom()
方法)的可讀性更強。 Groovy 語言包括 Command 設計模式;Groovy 中任何以花括號 — { }
— 括起來的代碼自動成為一個代碼塊,可通過將左、右圓括號放在存放代碼塊引用的變量之后執(zhí)行。這個內(nèi)置模式使 addOrderFrom()
方法的主體可具有更強的表達性(通過減少晦澀的代碼)。Groovy 還允許消除圍繞參數(shù)的一些括號,從而減少干擾。
清單 4 顯示一個類似的重寫版本,這一次用的是 Ruby(通過 JRuby):
addOrderFrom()
方法 def wrap_in_transaction setup_data_infrastructure begin yield complete_transaction rescue rollback_transaction throw ensure cleanup end end def add_order_from wrap_in_transaction do add order, user_key_based_on(user_name) add_line_items_from cart, order.order_key end end |
與 Java 版本相比,上述代碼更類似于 Groovy 代碼。Groovy 代碼與 Ruby 代碼的主要不同點在 Command 模式特征中。在 Ruby 中,任何方法都可以使用代碼塊,代碼塊通過方法主體中的 yield
調(diào)用執(zhí)行。因此,在 Ruby 中,甚至不需要指定專門類型的基礎結(jié)構元素 — 該語言中已具有處理這種常見用法的功能。
不同的語言以不同的方式處理抽象。閱讀本文的人都熟悉一些普遍的抽象風格 — 例如結(jié)構化、模塊化和面向?qū)ο?— 它們出現(xiàn)在很多不同的語言中。當長時間使用一種特定的語言時,它就成了金錘:每個問題看上去就像一個釘子,可以用該語言的抽象來驅(qū)動。對于純面向?qū)ο笳Z言(例如 Java 語言)來說,這一點尤為明顯,因為主要的抽象就是分層和易變狀態(tài)。
Java 世界現(xiàn)在對一些函數(shù)式語言,例如 Scala 和 Clojure 表現(xiàn)出很大的興趣。當使用函數(shù)式語言編寫代碼時,您會以不同的方式思考問題的解決方案。例如,在大多數(shù)函數(shù)式語言中,默認方式是創(chuàng)建不可變變量,而不是可變變量,這與 Java 截然相反。在 Java 代碼中,默認情況下數(shù)據(jù)結(jié)構是可變的,必須添加更多的代碼,才能使它們具有不變的行為。這意味著以函數(shù)式語言編寫多線程應用程序要容易得多,因為不可變數(shù)據(jù)結(jié)構與線程交互起來非常自然,因而代碼可以很簡潔。
抽象不是語言設計者的專利。2006 年,OOPSLA 上有一篇題為 “Collaborative Diffusion: Programming Antiobjects”(請參閱 參考資料)的論文,其中介紹了 antiobject 的概念,這是一種特殊的對象,其行為方式與我們想象的剛好相反。這種方法用于解決論文中提出的一個問題: 如果我們受太多現(xiàn)實世界的啟發(fā)而創(chuàng)建對象,那么對象的隱喻可以延伸到很遠。
該論文的觀點是,很容易陷入特定的抽象風格,使問題愈加復雜。通過將解決方案編寫為 antiobject,可以換一個角度來解決更簡單的問題。
這篇論文引用的例子非常完美地詮釋了這個概念 — 這個例子就是 20 世紀 80 年代早期最初的 Pac-Man 視頻控制臺游戲(如 圖 2 所示):
最初的 Pac-Man 游戲的處理器能力和內(nèi)存甚至不如現(xiàn)在的一些腕表。在這么有限的資源下,游戲設計者面臨一個嚴峻的問題:如何計算迷宮中兩個移動物體之間的距離?他們沒有足夠的處理器能力進行這樣的計算,所以他們采取一種 antiobject 方法,將所有游戲智能構建到迷宮本身當中。
Pac-Man 中的迷宮是一個狀態(tài)機,其中的每個格子根據(jù)一定的規(guī)則隨整個迷宮的變化而變化。設計者發(fā)明了 Pac-Man 氣味(smell) 的概念。Pac-Man 角色占用的格子有最大的 Pac-Man 氣味,而最近騰出來的格子的氣味值為最大氣味減去 1,并且氣味迅速衰退。鬼魂(追趕 Pac-Man,移動速度比 Pac-Man 稍快)平時隨機閑逛,直到聞到 Pac-Man 的氣味,這時它們會追進氣味更濃的格子。再為鬼魂的移動增加一定的隨機性,這就是 Pac-Man。這種設計的一個副作用是,鬼魂不能堵截 Pac-Man:即使 Pac-Man 迎面而來,鬼魂也看不到,它們只知道 Pac-Man 在哪里呆過。
換個角度簡化問題使底層代碼更加簡單。通過轉(zhuǎn)而抽象背景,Pac-Man 設計者在資源非常有限的環(huán)境中實現(xiàn)了他們的目標。當遇到特別難以解決的問題時(尤其是在重構過于復雜的代碼時),問問自己,是否可以采用某種更有效的 antiobject 方法。