前七章內容閱讀與紙質書,後面的內容源於下面的電子書鏈接:
HOW DO YOU WRITE FUNCTIONS LIKE THIS? 如何寫出這樣的函數#
寫軟體就像寫其他任何東西一樣。當你寫論文或文章時,你先把想法寫下來,然後再進行潤飾,直到它讀起來順暢。初稿可能笨拙且無序,因此你會不斷修改、重組和精煉,直到它讀起來符合你的期望。
寫代碼和寫別的東西很像。在寫論文或文章時,你先想什麼就寫什麼,然後再打磨它。初稿也許粗陋無序,你就斟酌推敲,直至達到你心目中的樣子。
當我寫函數時,它們往往冗長且複雜。它們有很多縮進和嵌套循環。它們的參數列表很長。名稱是隨意的,還有重複的代碼。但我也有一套單元測試,覆蓋了每一行笨拙的代碼。
我寫函數時,一開始都冗長而複雜。有太多縮進和嵌套循環。有過長的參數列表。名稱是隨意取的,也會有重複的代碼。不過我會配上一套單元測試,覆蓋每行醜陋的代碼。
然後我會潤飾和精煉這些代碼,拆分出函數、修改名稱、消除重複。我縮短方法並重新排列它們。有時我會拆分整個類,同時保持測試通過。
然後我打磨這些代碼,分解函數、修改名稱、消除重複。我縮短和重新安置方法。有時我還拆散類。同時保持測試通過。最後,遵循本章列出的規則,我組裝好這些函數。
最終,我得到的函數遵循了我在本章中列出的規則。我並不是一開始就這樣寫它們。我想沒有人能做到。
我並不從一開始就按照規則寫函數。我想沒人做得到。
- 編程藝術是且一直就是語言設計的藝術。
- 注釋的恰當用法是彌補我們在用代碼表達意圖時遭遇的失敗。注意,我用了 “失敗” 一詞。我是說真的。注釋總是一種失敗。我們總無法找到不用注釋就能表達自我的方法,所以總要有注釋,這並不值得慶賀。
- Ps. 所以還是得寫,因為我現在還無法避免所有的 “失敗”
- 我為什麼要極力貶低注釋?因為注釋會撒謊。也不是說總是如此或有意如此,但出現得實在太頻繁。注釋存在的時間越久,就離其所描述的代碼越遠,越來越變得全然錯誤。原因很簡單。程序員不能堅持維護注釋。
再次,我們看到這兩種定義的互補性;它們是虛擬的對立面!這揭示了對象和數據結構之間的根本二分法:
我們再次看到這兩種定義的本質;它們是截然對立的。這說明了對象與數據結構之間的二分原理:
過程式代碼(使用數據結構的代碼)便於在不改動既有數據結構的前提下添加新函數。OO 代碼,另一方面,便於在不改動既有函數的前提下添加新類。
過程式代碼(使用數據結構的代碼)便於在不改動既有數據結構的前提下添加新函數。面向對象代碼便於在不改動既有函數的前提下添加新類。
互補的情況也成立:
反過來講也說得通:
過程式代碼難以添加新數據結構,因為所有函數必須改變。OO 代碼難以添加新函數,因為所有類必須改變。
過程式代碼難以添加新數據結構,因為必須修改所有函數。面向對象代碼難以添加新函數,因為必須修改所有類。
所以,對於 OO 而言困難的事情,對於過程式代碼卻容易,而對於過程式代碼困難的事情,對於 OO 卻容易!
所以,對於面向對象較難的事,對於過程式代碼卻較容易,反之亦然!
在任何複雜系統中,總會有需要添加新數據類型而不是新函數的時候。在這些情況下,對象和 OO 是最合適的。另一方面,還會有想要添加新函數而不是數據類型的時候。在這種情況下,過程式代碼和數據結構會更合適。
在任何一個複雜系統中,總會有需要添加新數據類型而不是新函數的時候。這時,對象和面向對象就比較適合。另一方面,也會有想要添加新函數而不是數據類型的時候。在這種情況下,過程式代碼和數據結構更合適。
成熟的程序員知道,認為一切都是對象的想法是一種神話。有時你真的想要簡單的數據結構,並在其上運行程序。
像我們為 ACMEPort 定義的包裝器非常有用。事實上,包裝第三方 API 是一種最佳實踐。當你包裝一個第三方 API 時,你最小化了對它的依賴:未來你可以選擇轉移到不同的庫,而不會有太大的懲罰。包裝還使得在測試自己的代碼時更容易模擬第三方調用。
類似我們為 ACMEPort 定義的這種打包類非常有用。實際上,將第三方 API 打包是個良好的實踐手段。當你打包一個第三方 API,你就降低了對它的依賴:未來你可以不太痛苦地改用其他代碼庫。在你測試自己的代碼時,打包也有助於模擬第三方調用。
包裝的最後一個優勢是你不必綁死在特定廠商的 API 設計選擇上。你可以定義一個你覺得舒適的 API。在前面的例子中,我們為端口設備故障定義了一種類型的異常,並發現這樣能寫出更整潔的代碼。
打包的好處還在於你不必綁死在某個特定廠商的 API 設計上。你可以定義自己感覺舒服的 API。在上例中,我們為端口設備錯誤定義了一個異常類型,然後發現這樣能寫出更整潔的代碼。
對於代碼的某個特定區域,單一異常類通常可行。伴隨異常發送出來的信息能夠區分不同錯誤。如果你想要捕獲某個異常,並且放過其他異常,就使用不同的異常類。
對於代碼的某個特定區域,單一異常類通常可行。伴隨異常發送出來的信息能夠區分不同錯誤。如果你想要捕獲某個異常,並且放過其他異常,就使用不同的異常類。
在此之前的內容主要是通過紙質書閱讀的,筆記難以整理(其實是懶)就零散放在上面了,接下來幾章主要通過大佬的在線翻譯(見頂部鏈接)閱讀的,比較好進行記錄。
第八章 邊界#
- 我們沒有測試第三方代碼的責任,但為要使用的第三方代碼編寫測試,可能最符合我們的利益。
學習第三方代碼很難。整合第三方代碼也很難。同時做這兩件事難上加難。如果我們採取不同的做法呢?不要在生產代碼中試驗新東西,而是編寫一些測試來探索我們對第三方代碼的理解。Jim Newkirk 將這類測試稱為學習測試。
學習第三方代碼很難。整合第三方代碼也很難。同時做這兩件事難上加難。如果我們採取不同的做法呢?不要在生產代碼中試驗新東西,而是編寫測試來遍覽和理解第三方代碼。Jim Newkirk 把這叫做學習性測試(learning tests)。
在學習性測試中,我們如在應用中那樣調用第三方 API。我們基本上是在通過核對試驗來檢查自己對那個 API 的理解程度。測試聚焦於我們想從 API 得到的東西。
在學習性測試中,我們如在應用中那樣調用第三方代碼。我們基本上是在通過核對試驗來檢查自己對那個 API 的理解程度。測試聚焦於我們想從 API 得到的東西。
第九章 單元測試#
有些讀者可能會同意這種做法。或許,在很久以前,你也用過我為那個 Timer 類寫測試的方法。從編寫那種用後即扔的測試,到編寫全套自動化單元測試是一大進步。所以,就像那個我指導過的團隊一樣,你或許也會認為脏測試好過沒測試。
有些讀者可能會同意這種做法。或許,在很久以前,你也用過我為那個 Timer 類寫測試的方法。從編寫那種用後即扔的測試到編寫全套自動化單元測試是一大進步。所以,就像那個我指導過的團隊一樣,你或許也會認為脏測試好過沒測試。
這個團隊沒有意識到的是,脏測試等同於 —— 如果不是壞於的話 —— 沒測試。問題在於,測試必須隨著生產代碼的演進而修改。測試越脏,就越難修改。測試代碼越纏結,你就越有可能花更多時間塞進新測試,而不是編寫新生產代碼。隨著你修改生產代碼,舊測試開始失敗,而測試代碼中的亂七八糟的東西將阻礙代碼再次通過。於是,測試變得就像是不斷翻番的債務。
這個團隊沒有意識到的是,脏測試等同於 —— 如果不是壞於的話 —— 沒測試。問題在於,測試必須隨著生產代碼的演進而修改。測試越脏,就越難修改。測試代碼越纏結,你就越有可能花更多時間塞進新測試,而不是編寫新生產代碼。修改生產代碼後,舊測試就會開始失敗,而測試代碼中亂七八糟的東西將阻礙代碼再次通過。於是,測試變得就像是不斷翻番的債務。
9.3 - 整潔的測試#
這些測試顯然呈現了構造 - 操作 - 檢驗(BUILD-OPERATE-CHECK)模式。每個測試都清晰地拆分為三個環節。第一個環節構造測試數據,第二個環節操作測試數據,第三個部分檢驗操作是否得到期望的結果。
這些測試顯然呈現了構造 - 操作 - 檢驗(BUILD-OPERATE-CHECK)模式。每個測試都清晰地拆分為三個環節。第一個環節構造測試數據,第二個環節操作測試數據,第三個部分檢驗操作是否得到期望的結果。
9.5-F.I.R.S.T#
及時(Timely)測試應及時編寫。單元測試應該恰好在使其通過的生產代碼之前編寫。如果在編寫生產代碼之後編寫測試,你會發現生產代碼難以測試。你可能會認為某些生產代碼本身難以測試。你可能不會去設計可測試的代碼。
及時(Timely)測試應及時編寫。單元測試應該恰好在使其通過的生產代碼之前編寫。如果在編寫生產代碼之後編寫測試,你會發現生產代碼難以測試。你可能會認為某些生產代碼本身難以測試。你可能不會去設計可測試的代碼。
第十章 - 類#
在類這一章中,我試著將其中大部分的概念遷移到 Go 中的 interface {} 進行理解。
類的名稱應當描述其權責。實際上,命名正是幫助判斷類的長度的第一個手段。如果無法為某個類命以精確的名稱,這個類大概就太長了。類名越含混,該類越有可能擁有過多權責。例如,如果類名中包括含義模糊的詞,如 Processor 或 Manager 或 Super,這種現象往往說明有不恰當的權責聚集情況存在。
類的名稱應當描述其權責。實際上,命名正是幫助判斷類的長度的第一個手段。如果無法為某個類命以精確的名稱,這個類大概就太長了。類名越含混,該類越有可能擁有過多權責。例如,如果類名中包括含義模糊的詞,如 Processor 或 Manager 或 Super,這種現象往往說明有不恰當的權責聚集情況存在。
內聚#
- 當類喪失了內聚性,就拆分它!
所以將大函數拆為許多小函數,往往也是將類拆分為多個小類的時機。程序會更加有組織,也會擁有更為透明的結構。
所以,將大函數拆為許多小函數,往往也是將類拆分為多個小類的時機。程序會更加有組織,也會擁有更為透明的結構。
10.3 - 為了修改而組織#
如果系統解耦到足以這樣測試的程度,也就更加靈活,更加可復用。部件之間的解耦代表著系統中的元素互相隔離得很好。隔離也讓對系統每個元素的理解變得更加容易。
如果系統解耦到足以這樣測試的程度,也就更加靈活,更加可復用。部件之間的解耦代表著系統中的元素互相隔離得很好。隔離也讓對系統每個元素的理解變得更加容易。
通過降低連接度,我們的類就遵循了另一條類設計原則,依賴倒置原則(Dependency Inversion Principle,DIP)。本質而言,DIP 認為類應當依賴於抽象而不是依賴於具體細節。
通過降低連接度,我們的類就遵循了另一條類設計原則,依賴倒置原則(Dependency Inversion Principle,DIP)。本質而言,DIP 認為類應當依賴於抽象而不是依賴於具體細節。
第 11 章 - 系統#
“一開始就做對系統” 純屬神話。反之,我們應該只去實現今天的用戶故事,然後重構,明天再擴展系統、實現新的用戶故事。這就是迭代和增量敏捷的精髓所在。測試驅動開發、重構以及它們打造出的整潔代碼,在代碼層面保證了這個過程的實現。
“一開始就做對系統” 純屬神話。反之,我們應該只去實現今天的用戶故事,然後重構,明天再擴展系統、實現新的用戶故事。這就是迭代和增量敏捷的精髓所在。測試驅動開發、重構以及它們打造出的整潔代碼,在代碼層面保證了這個過程的實現。
不要過早動手
我們都知道最好是將責任授予最有資格的人。我們常常忘記,延遲決策至最後一刻也是好手段。這不是懶惰或不負責;它讓我們能夠基於最有可能的信息做出選擇。提前決策是一種預備知識不足的決策。如果決策太早,就會缺少太多客戶反饋、關於項目的思考和實施經驗。
眾所周知,最好是授權給最有資格的人。但我們常常忘記了,延遲決策至最後一刻也是好手段。這不是懶惰或不負責;它讓我們能夠基於最有可能的信息做出選擇。提前決策是一種預備知識不足的決策。如果決策太早,就會缺少太多客戶反饋、關於項目的思考和實施經驗。
無論你是在設計系統還是單獨的模塊,別忘了使用大概可工作的最簡單方案。
無論是設計系統或單獨的模塊,別忘了使用大概可工作的最簡單方案。
第 12 章 Emergence 迭進#
據 Kent 所述,只要遵循以下規則,設計就能變得 “簡單”:
據 Kent 所述,只要遵循以下規則,設計就能變得 “簡單”:
- 運行所有測試
- 不可重複
- 表達了程序員的意圖
- 儘可能減少類和方法的數量
運行所有測試;不可重複;表達了程序員的意圖;儘可能減少類和方法的數量;
這些規則按其重要程度排列。
以上規則按其重要程度排列。
值得注意的是,遵循一個簡單明瞭的規則,即我們需要有測試並持續運行它們,會影響我們系統對 OO 低耦合度和高內聚度的主要目標的遵循。編寫測試會導致更好的設計。
遵循有關編寫測試並持續運行測試的簡單、明確的規則,系統就會更貼近 OO 低耦合度、高內聚度的目標。編寫測試引致更好的設計。
在這個重構步驟中,我們可以應用有關良好軟體設計的所有知識。我們可以提高內聚性,降低耦合度,分離關注點,模塊化系統關注點,縮小我們的函數和類,選擇更好的名稱,等等。這也是應用簡單設計后三條規則的地方:消除重複,保證表達力,儘可能減少類和方法的數量。
在重構過程中,可以應用有關優秀軟體設計的一切知識。提升內聚性,降低耦合度,切分關注面,模塊化系統性關注面,縮小函數和類的尺寸,選用更好的名稱,如此等等。這也是應用簡單設計后三條規則的地方:消除重複,保證表達力,儘可能減少類和方法的數量。
我們中的大多數人都經歷過費解代碼的糾纏。我們中的許多人自己就編寫過費解的代碼。寫出自己能理解的代碼很容易,因為在寫這些代碼時,我們正深入於要解決的問題中。代碼的其他維護者不會那麼深入,也就不易理解代碼。
我們中的大多數人都經歷過費解代碼的糾纏。我們中的許多人自己就編寫過費解的代碼。寫出自己能理解的代碼很容易,因為在寫這些代碼時,我們正深入於要解決的問題中。代碼的其他維護者不會那麼深入,也就不易理解代碼。
所以有一個角度的閱讀源碼的方式就是從測試開始(當然前提是準備閱讀的目標擁有足夠優秀的測試代碼,並且覆蓋率夠高。)
編寫良好的單元測試也具有表達性。測試的主要目的之一就是通過實例起到文檔的作用。讀到我們的測試的人應該能很快理解某個類是做什麼的。
編寫良好的單元測試也具有表達性。測試的主要目的之一就是通過實例起到文檔的作用。讀到測試的人應該能很快理解某個類是做什麼的。
第 13 章 Concurrency 並發編程#
“對象是過程的抽象。線程是調度的抽象。”
—James O. Coplien1
“對象是過程的抽象。線程是調度的抽象。”——James O
以下是一些關於編寫並發軟體的中肯說法:
下面是一些有關編寫並發軟體的中肯說法:
- 並發會在性能和編寫額外代碼上增加一些開銷。
- 正確的並發是複雜的,即便對於簡單的問題。
- 並發錯誤通常無法重現,因此經常被忽略為偶發事件,而不是它們實際上是的真正缺陷。
- 並發通常需要對設計策略的根本性修改。
並發會在性能和編寫額外代碼上增加一些開銷;正確的並發是複雜的,即便對於簡單的問題也是如此;並發缺陷並非總能重現,所以常被看做偶發事件而忽略,未被當做真的缺陷看待;並發常常需要對設計策略的根本性修改。
- 與並發相關的代碼有其自身的開發、修改和調優生命周期。
- 與並發相關的代碼有其自身的挑戰,這些挑戰與非並發相關的代碼不同,且往往更為困難。
- 錯誤的並發代碼可能失敗的方式數量使得它足夠具挑戰性,而不需要周邊應用代碼的額外負擔。
與並發相關代碼有自己的開發、修改和調優生命周期;開發相關代碼有自己要對付的挑戰,和非並發相關代碼不同,而且往往更為困難;即便沒有周邊應用程序增加的負擔,寫得不好的並發代碼可能的出錯方式數量也已經足具挑戰性。
建議:將你的並發相關代碼與其他代碼分開。
建議:分離並發相關代碼與其他代碼。
第 14 章 Successive Refinement 逐步改進#
讓我讓你放心。我並不是從頭到尾簡單地寫這個程序。更重要的是,我不指望你能夠一次性寫出整潔優雅的程序。如果我們在過去幾十年中學到什麼,那就是編程是一種技藝,而非科學。要編寫整潔代碼,你必須先寫髒代碼,然後再清理它。
先放鬆一下神經。這段程序並非從一開始就寫成現在的樣子。更重要的是,我也沒指望你能夠一次過寫出整潔、漂亮的程序。如果說我們從過去幾十年裡面學到什麼東西,那就是編程是一種技藝甚於科學的東西。要編寫整潔代碼,必須先寫肮髒代碼,然後再清理它。
漸進主義要求我在做其他修改之前迅速修正這個問題。事實上,修正並不困難。我只需移動對 null 的檢查。再也不用檢測 boolean 是否為 null,而是檢查 ArgumentMarshaller。
漸進主義要求我在做其他修改之前迅速修正這個問題。修正並不費勁。我只是把對 null 值的檢查移了個位置。再也不用檢測 boolean 是否為 null,而是檢查 ArgumentMarshaler 是否為 null。
14、15、16 三章基本都是實踐內容,但是由於本書的例子是基於 Java 構建的,而本人對 Java 確實不太熟悉(案例必然涉及到 Java 世界的一些特有特性或者輪子),便決定不在這三章中琢磨太多時間。直接將重心放回了最後總結的章節。
第 17 章 Smells and Heuristics 味道與啟發#
本章內容相當於之前內容的一個總結,所以英文原文我就暫且不一並摘錄了。另外,全文都不保證完整,畢竟這只是我的一篇筆記。一般情況下我只會記下讓我有感觸的部分。
- 無關或不正確的注釋就是廢棄的注釋。注釋會很快過時。最好別編寫將被廢棄的注釋。如果發現廢棄的注釋,最好儘快更新或刪除掉。廢棄的注釋會遠離它們曾經描述的代碼,變成代碼中無關和誤導的浮島。
- 如果注釋描述的是某種充分自我描述了的東西,那麼注釋就是多餘的。
下方的 G 系列來自 17.4 一般性問題
G2:明顯的行為未被實現#
-
遵循 “最小驚異原則”(The Principle of Least Surprise),函數或類應該實現其他程序員有理由期待的行為。例如,考慮一個將日期名稱翻譯為表示該日期的枚舉的函數。
Day day = DayDate.StringToDay(String dayName);
-
如果明顯的行為未被實現,讀者和用戶就不能再依靠他們對函數名稱的直覺。他們不再信任原作者,不得不閱讀代碼細節。
G3:不正確的邊界行為#
- 沒什麼可以替代謹小慎微。每種邊界條件、每種極端情形、每個異常都代表了某種可能搞亂優雅而直白的算法的東西。別依賴直覺。追索每種邊界條件,並編寫測試。
G5:重複#
- 每次看到重複代碼,都代表遺漏了抽象。重複的代碼可能成為子程序或乾脆是另一個類。將重複代碼疊放進類似的抽象,增加了你的設計語言的詞彙量。其他程序員可以用到你創建的抽象設施。編碼變得越來越快,錯誤越來越少,因為你提升了抽象層級。
- 更隱蔽的形態是採用類似算法但具體代碼行不同的模塊。這也是一種重複,可以使用模板方法模式或策略模式來修正。
G6:在錯誤的抽象層級上的代碼#
- 良好的軟體設計要求分離位於不同層級的概念,將它們放到不同容器中。有時,這些容器是基類或派生類,有時是源文件、模塊或組件。無論哪種情況,分離都要完整。較低層級概念和較高層級概念不應混雜在一起。
G10: 垂直分離#
- 變量和函數應該在靠近被使用的地方定義。本地變量應該正好在其首次被使用的位置上面聲明,垂直距離要短。本地變量不該在其被使用之處幾百行以外聲明。
- 私有函數應該剛好在其首次被使用的位置下面定義。私有函數屬於整個類,但我們還是要限制調用和定義之間的垂直距離。找個私有函數,應該只是從其首次被使用處往下看一點那麼簡單。
G11:前後不一致#
- 如果在特定函數中用名為 response 的變量來持有 HttpServletResponse 對象,則在其他用到 HttpServletResponse 對象的函數中也用同樣的變量名。如果將某個方法命名為 processVerificationRequest,則給處理其他請求類型的方法取類似的名字,例如 processDeletion Request。
G17:位置錯誤的權責#
- 最小驚異原則在這裡起作用了。代碼應該放在讀者自然而然期待它所在的地方。PI 常量應該在出現在聲明三角函數的地方。OVERTIME_RATE 常量應該在 HourlyPayCalculator 類中聲明。
G20:函數名稱應該表達其行為#
- 如果你必須查看函數的實現(或文檔)才知道它是做什麼的,就該換個更好的函數名,或者重新安排功能代碼,放到有較好名稱的函數中。
G26:準確#
- 期望某個查詢的第一次匹配就是唯一匹配可能過於天真。用浮點數表示貨幣幾近於犯罪。因為你不想做並發更新就避免使用鎖和 / 或事務管理往好處說也是一種懶惰行為。在可以用 List 的時候非要把變量聲明為 ArrayList 就過分拘束了。把所有變量設置為 protected 卻不夠自律。
- 在代碼中做決定時,確認自己足夠準確。明確自己為何要這麼做,如果遇到異常情況如何處理。別懶得理會決定的準確性。如果你打算調用可能返回 null 的函數,確認自己檢查了 null 值。如果查詢你認為是數據庫中唯一的記錄,確保代碼檢查不存在其他記錄。如果要處理貨幣數據,使用整數,並恰當地處理四捨五入。如果可能有並發更新,確認你實現了某種鎖定機制。
- 代碼中的含糊和不準確要麼是意見不同的結果,要麼源於懶惰。無論原因是什麼,都要消除。
G28:封裝條件#
-
如果沒有 if 或 while 語句的上下文,布爾邏輯就難以理解。應該把解釋了條件意圖的函數抽離出來。
// 例如: if (shouldBeDeleted(timer)) is preferable to // 好於 if (timer.hasExpired() && !timer.isRecurrent())
G33:封裝邊界條件#
-
邊界條件難以追蹤。把處理邊界條件的代碼集中到一處,不要散落於代碼中。我們不想見到四處散見的 +1 和 -1 字樣。
if(level + 1 < tags.length) { parts = new Parse(body, tags, level + 1, offset + endTag); body = null; } // 注意,level + 1 出現了兩次。這是個應該封裝到名為 nextLevel 之類的變量中的邊界條件。 int nextLevel = level + 1; if(nextLevel < tags.length) { parts = new Parse(body, tags, nextLevel, offset + endTag); body = null; }
17.6 名稱#
N2:名稱應與抽象層級相符#
- 不要取溝通實現的名稱;取反映類或函數抽象層級的名稱。這樣做不容易。人們擅長於混雜抽象層級。每次瀏覽代碼,你總會發現有些變量的名稱層級太低。你應當趁機為之改名。要讓代碼可讀,需要持續不斷的改進。看看下面的 Modem 接口:
N5:為較大作用範圍選用較長名稱#
- 名稱的長度應與作用範圍的廣泛度相關。對於較小的作用範圍,可以用很短的名稱,而對於較大作用範圍就該用較長的名稱。
- 類似 i 和 j 之類的變量名對於作用範圍在 5 行之內的情形沒問題。