簡介:識別出代碼中的慣用模式後,下一步是積累和使用它們。理解設計與代碼之間的關系有利於發 現可重用的代碼。本期的 演化架構與緊急設計 探索代碼與設計的關系,使用表達性強的語言的重要性, 以及重新考慮抽象風格的潛在價值。
通過本 系列 的前幾期,您已經知道,我的觀點是軟件的每個部分都包括可重用的代碼塊。例如,公 司處理安全性的方式在整個應用程序甚至多個應用程序中可能都是一致的。這就是我所說的 慣用模式 的 實例。這些模式代表對構建軟件特定部分時遇到的問題的常用解決方案。慣用模式有兩種類型:
技術模式 —— 包括事務、安全性和其他基礎結構元素。
域模式 —— 包括單個應用程序內或跨多個應用程序的業務問題的解決方案。
在前幾期中,我將大部分注意力放在如何發現這些模式上面。但是,發現模式之後,必須能夠將它們 作為可重用代碼加以利用。在本文中,我將研究設計與代碼之間的關系,特別是表達性強的代碼如何使模 式的累積變得更容易。您將看到,有時候通過改變抽象風格,可以解決一些看似難以解決的設計問題,並 且可以簡化代碼。
設計即代碼
早在 1992 年,Jack Reeves 寫了一篇題為 “What is Software Design?” 的思維敏銳的論文。在 此文中,他將傳統的工程(例如硬件工程和結構工程)與軟件 “工程” 作了比較,目的是為軟件開發人 員拿掉工程這個詞上的引號。這篇論文得出一些有趣的結論。
Reeves 首先觀察到,一項工程最終交付的成果是 “某種類型的文檔”。設計橋梁的結構工程師不會 交付真正的橋。其最終成果是一座橋的設計。然後,這份設計被傳到一個建築團隊手上,由他們來建造真 正的橋梁。對於軟件而言,類似的設計文檔是什麼呢?是餐巾紙上的塗鴉、白板上的草圖、UML 圖、時序 圖還是其他類似的工件?這些都是設計的一部分,它們合起來仍不足以讓制造團隊做出實際的東西來。在 軟件中,制造團隊是編譯器和部署機制,這意味著完整的設計是源代碼 — 完整的 源代碼。其他工件只 能為創建代碼提供幫助,但是最終的設計成果還是源代碼本身,這意味著軟件中的設計不能脫離源代碼。
Reeves 接下來的觀點是關於制造成本的,制造成本通常不算工程的一部分,但是是工件的總體成本估 計的一部分。構建物理實體較為昂貴,這通常是整個生產流程中最昂貴的部分。相反,正如 Reeves 所說 的:
“...軟件構建起來很便宜。它廉價得簡直就像是免費。”
記住,說這句 話的時候,他正在經歷 C++ 編譯和鏈接階段,這可是非常消耗時間的。現在,在 Java™ 領域,每 時每刻都有團隊冒出來實現您的設計!軟件構建現在是如此的廉價,以至於幾乎可以忽略。相對於傳統的 工程師,我們有著巨大的優勢。傳統工程師肯定也很希望能夠免費地建造他們的設計,並進行假設分析的 游戲。您能想象嗎?如果橋梁工程師能夠實時地試驗他們的設計,而且還是免費,那麼造出來的橋梁將會 是多麼的精致。
制造是如此容易,這就解釋了為什麼在軟件開發中沒有那麼高的數學嚴密性。為 了取得可預測性,傳統工程師開發了一些數學模型和其他尖端技術。而軟件開發人員不需要那種級別的嚴 密分析。構建設計並對其進行測試,比為其行為構建形式化的證明要來得容易。測試就是軟件開發的工程 嚴謹度(engineering rigor)。這也導致了 Reeves 的論文中的一個最有趣的結論:
如果軟件 設計相當容易被證實,並且基本上可以免費構建,那麼毫不奇怪,軟件設計必將變得極其龐大而復雜。
實際上,我認為軟件設計是人類有史以來嘗試過的最復雜的事情,尤其是在我們所構建的軟件的 復雜性不斷攀升的背景下。考慮到軟件開發成為主流也才大約 50 年的光景,通常的企業軟件的復雜性已 經令人瞠目。
Reeves 的論文得出的另一個結論是,在目前,軟件中的設計(也就是編寫整個源代 碼)是最昂貴的活動。也就是說,在設計時所浪費的時間是最寶貴的資源。這將我們帶回到緊急設計上來 。如果在開始編寫代碼之前,花費大量的時間試圖參與到所有的事情中來,那麼您總會浪費一些時間,因 為一開始有些事情是未知的。換句話說,在編寫軟件時,您總是陷入意想不到的時間黑洞,因為有些需求 比您想象的更復雜,或者您一開始並沒有完全理解問題。越靠後做決定,就越有把握作出更好的決定 — 因為您所獲得的上下文和知識是與時俱增的,如 圖 1 所示:
圖 1. 越靠後做決定,做出的決定就越符合實際
精益軟件運動有一個很好的概念叫做 最後可靠時刻(last responsible moment) — 不是將決定推 遲到最後時刻,而是最後可靠時刻。等待的時間越長,就越有機會擁有適合的設計。
表達性
Reeves 論文中的另一個結論是圍繞可讀設計的重要性的,可讀設計又轉換成更加可讀的代碼。發現代 碼中的慣用模式已經夠難了,但是如果語言中再加上一些額外的晦澀的東西,那就會難上加難。例如,發 現匯編語言代碼基中的慣用模式就非常困難,因為該語言強加了太多晦澀的元素,必須環顧四周才能 “ 看到” 設計。
既然設計就是代碼,那麼應該盡量選擇表達性最強的語言。充分利用語言的表達性有利於更容易地發 現慣用模式,因為設計的媒介更清晰。
下面是一個例子。在本系列較早的一期(“組合方法和 SLAP”)中,我應用組合方法和 單一抽象層 (SLAP)原則,對一些已有代碼進行了重構。清單 1 顯示我得出的頂層代碼:
清單 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 所示:
清單 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());
}
});
}
以框架作為模式集合
如果您熟悉 Hibernate,那麼您將注意到,wrapInTransaction() 方法很像 Hibernate 的 doInTransaction helper。最成功的框架包含的是一組符合實際的技術慣用模式。框架中模式的有用性密 切關系到框架如何得以生存。如果框架是從實用代碼中提取的,那麼其中的模式更多地關注現實中的問題 。良好的框架(例如 Hibernate、Spring 和 Ruby on Rails)大多經歷了實際應用的嚴峻考驗。
另一方面,如果一個框架是在象牙塔中創建的,很多模式聽起來很好,但是在實際項目中卻沒那麼有 用。我常提到的一個揣測性框架開發的例子是 JavaServer Faces(JSF)的定制呈現管道 “特性”。它 允許輸出各種類型的輸出格式(例如 HTML、XHTML 和 WML)。我還沒遇到過需要這個特性的開發人員( 雖然我相信存在這樣的開發人員),但是您在編寫的每個 JSF 應用程序中都為此付出了一點代價。(它 增加了理解事件模型和管道的復雜性。)
在這個版本中,我使用 Gang of Four 的 Command 設計模式,將樣板代碼抽象到 wrapInTransaction() 方法。addOrderFrom() 方法現在可讀性強多了 — 該方法的精華(最深處的兩行 )現在更明顯了。但是,為了達到那種程度的抽象,Java 語言附加了很多技術性的繁瑣的東西。您必須 理解匿名內聯類是如何工作的(Command 子類的內聯聲明),並理解 execute() 方法的含義。例如,在 匿名內聯類的主體中,只能調用外部類中的 final 對象引用。
如果用表達性更強的 Java 方言來編寫同樣的代碼,結果會怎樣?清單 3 顯示用 Groovy 重新編寫的 同一個方法:
清單 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 中任何以花括號 — { } — 括起來的代碼自動成為一個代碼塊,可通過將左、右圓括號放在存放 代碼塊引用的變量之後執行。這個內置模式使 addOrderFrom() 方法的主體可具有更強的表達性(通過減 少晦澀的代碼)。Groovy 還允許消除圍繞參數的一些括號,從而減少干擾。
清單 4 顯示一個類似的重寫版本,這一次用的是 Ruby(通過 JRuby):
清單 4. 翻譯成 Ruby 的 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 調用 執行。因此,在 Ruby 中,甚至不需要指定專門類型的基礎結構元素 — 該語言中已具有處理這種常見用 法的功能。
抽象的風格
不同的語言以不同的方式處理抽象。閱讀本文的人都熟悉一些普遍的抽象風格 — 例如結構化、模塊 化和面向對象 — 它們出現在很多不同的語言中。當長時間使用一種特定的語言時,它就成了金錘:每個 問題看上去就像一個釘子,可以用該語言的抽象來驅動。對於純面向對象語言(例如 Java 語言)來說, 這一點尤為明顯,因為主要的抽象就是分層和易變狀態。
Java 世界現在對一些函數式語言,例如 Scala 和 Clojure 表現出很大的興趣。當使用函數式語言編 寫代碼時,您會以不同的方式思考問題的解決方案。例如,在大多數函數式語言中,默認方式是創建不可 變變量,而不是可變變量,這與 Java 截然相反。在 Java 代碼中,默認情況下數據結構是可變的,必須 添加更多的代碼,才能使它們具有不變的行為。這意味著以函數式語言編寫多線程應用程序要容易得多, 因為不可變數據結構與線程交互起來非常自然,因而代碼可以很簡潔。
抽象不是語言設計者的專利。2006 年,OOPSLA 上有一篇題為 “Collaborative Diffusion: Programming Antiobjects”的論文,其中介紹了 antiobject 的概念,這是一種特殊的對象,其行為方 式與我們想象的剛好相反。這種方法用於解決論文中提出的一個問題: 如果我們受太多現實世界的啟發 而創建對象,那麼對象的隱喻可以延伸到很遠。
該論文的觀點是,很容易陷入特定的抽象風格,使問題愈加復雜。通過將解決方案編寫為 antiobject ,可以換一個角度來解決更簡單的問題。
這篇論文引用的例子非常完美地诠釋了這個概念 — 這個例子就是 20 世紀 80 年代早期最初的 Pac -Man 視頻控制台游戲(如 圖 2 所示):
圖 2. 最初的 Pac-Man 視頻游戲
最初的 Pac-Man 游戲的處理器能力和內存甚至不如現在的一些腕表。在這麼有限的資源下,游戲設計 者面臨一個嚴峻的問題:如何計算迷宮中兩個移動物體之間的距離?他們沒有足夠的處理器能力進行這樣 的計算,所以他們采取一種 antiobject 方法,將所有游戲智能構建到迷宮本身當中。
Pac-Man 中的迷宮是一個狀態機,其中的每個格子根據一定的規則隨整個迷宮的變化而變化。設計者 發明了 Pac-Man 氣味(smell) 的概念。Pac-Man 角色占用的格子有最大的 Pac-Man 氣味,而最近騰出 來的格子的氣味值為最大氣味減去 1,並且氣味迅速衰退。鬼魂(追趕 Pac-Man,移動速度比 Pac-Man 稍快)平時隨機閒逛,直到聞到 Pac-Man 的氣味,這時它們會追進氣味更濃的格子。再為鬼魂的移動增 加一定的隨機性,這就是 Pac-Man。這種設計的一個副作用是,鬼魂不能堵截 Pac-Man:即使 Pac-Man 迎面而來,鬼魂也看不到,它們只知道 Pac-Man 在哪裡呆過。
換個角度簡化問題使底層代碼更加簡單。通過轉而抽象背景,Pac-Man 設計者在資源非常有限的環境 中實現了他們的目標。當遇到特別難以解決的問題時(尤其是在重構過於復雜的代碼時),問問自己,是 否可以采用某種更有效的 antiobject 方法。
結束語
在本期中,我探討了為什麼表達性是重要的,以及代碼中表達性的具體表現。我同意 Jack Reeves 對 於不同工程的比較;我認為,完整的源代碼就是軟件中的設計工件。一旦理解了這一點,就可以為過去很 多的失敗找到解釋(例如模型驅動的架構試圖直接從 UML 工件轉換到代碼,最終導致失敗,因為這種制 圖語言的表達性不足以捕捉所需的細微差別)。這種理解會帶來一些負面影響,例如意識到設計(即編寫 代碼)是花費最大的活動。這並不意味著在開始編寫代碼之前,不應該使用初期工具(例如 UML 之類的 東西)來幫助理解設計,但是一旦進入編寫代碼階段,代碼就成為實際的設計。
設計的可讀性很重要。設計的表達性越強,就越容易修改,並最終通過緊急設計從中收獲慣用模式。 在下一期,我將繼續沿著這條思路,並提供利用從代碼中收獲的設計元素的具體方式。