Simple Code(簡單程式碼) 是做什麼的?
兩個程式碼輸出相同結果並不代表它們相等:人們能更快理解的那個更好。每個類別、輔助程式、參數和層級,都增加了下一位讀者必須學習的成本,因此每個元素都必須證明其存在的必要性,即它必須改變程式行為。
目標是寫出一個新同事可以從頭到尾讀一遍、能夠解釋、並且能安全修改的程式碼。
如何工作
- 陳述任務。 輸入是什麼、輸出是什麼、被要求的規則與邊界情況、以及使用者指定的任何品質目標或設計。所有這些都是需求。根據這份清單工作,而不是根據程式碼「未來可能需要」的假設。
- 先寫最樸素的版本。 單純的函式、
if判斷、迴圈和字面資料,按照人們描述步驟的順序來寫。這就是你保留的設計,除非它無法滿足某個已陳述的需求。 - 執行刪除測試。 對於每個類別、輔助程式、參數、常數、匯入、分支和
try,先在腦中移除它。如果所有已陳述的需求(包括品質目標)仍然被滿足,就實際移除它。 - 測試它並保留測試。 在程式碼旁邊的一個小型、簡單的測試檔案中,涵蓋所有已陳述的規則和邊界情況,遵循專案慣例,除非使用者明確說不要。測試向後續讀者展示規則是什麼,並使變更安全,因此它們是任務的一部分,而不是額外工作。
- 簡潔報告。 說明你建立了什麼。如果你刻意省略了某些東西,用一行說明(例如「沒有 CLI 或日誌,因為兩者都未被要求」),以便使用者可以要求添加。
什麼是簡單
為今日的需求而建。 一句「我們以後可能會擴充」不是現在要實現的功能。簡單的程式碼是最容易擴充的,因為沒有東西需要繞過。新增一個優惠券應該意味著新增一行程式碼,而不是先學習一個框架。
以最簡單的方式滿足指定的品質目標。 當請求提到「生產級別」、「穩健」、「可擴充」或「企業級」時,問問這個程式在實際使用中可能出什麼問題,然後直接處理那些問題:拒絕錯誤輸入、失敗是可見的、沒有東西在無聲中丟失、金錢計算正確到每一分錢。這些詞語呼喚的是這種謹慎,而不是層級、外掛系統或框架。
交付使用者要求的設計。 當有人要求一個架構或特定結構時,以最小可用形式提供給他們,不要回覆說應該等待。例如,如果更多表單即將到來,設計可以是共享的檢查函式供下一個表單重複使用,並附帶一行說明如何新增一個表單。停在一個僅想像中的情況才需要的「引擎」之前。
使用最樸素的建構方式,大致按此順序:
- 一個值或字典型字典,優於設定系統
if/elif或查找字典,優於註冊表、策略、工廠或外掛系統- 一個函式,優於類別或帶方法的結構體;僅當它持有數個操作共享的狀態時才添加類別/結構體
- 具體型別,優於介面或泛型;僅當今天需要兩個實際實作或型別時才添加介面/泛型
- 順序程式碼,優於 goroutine 和 channel,除非並發是需求
- 標準函式庫和專案中已有的輔助程式,優於新的依賴
保持流程平直。 提早返回而不是巢狀;超過三層左右的巢狀通常意味著流程可以扁平化。每行做一件事。比起巢狀推導式、鏈式三元運算符、reduce 或複雜的正則表達式,更偏愛可讀的迴圈。除非任務明確關於它們,否則避免元程式設計、反射和自訂裝飾器。
讓小的重複存在。 三個相似的行通常比一個帶有標誌的輔助函式更清晰。只有在它能為一個真正的概念提供一個好名稱,或者邏輯必須保持一致時,才提取一個函式,而不僅僅是因為兩行看起來像。不要添加只有一個呼叫者使用或沒人要求的參數。
為人類命名。 使用完整的普通詞語(shipping_fee,而不是 sf 或 FeeCalculationContext)。一個好名稱消除了對註釋的需求。
只註釋原因。 說明一個令人驚訝的規則或約束。跳過重複程式碼的註釋、章節標題,以及重述函式簽章的長文檔字串。
處理可能發生的失敗,並大聲失敗。 驗證需求或真實邊界(使用者輸入、網路、檔案)所要求的內容,對錯誤輸入返回清晰的錯誤,而不是產生一個看似合理的錯誤答案。如果程式碼跳過或丟棄資料,讓執行它的人看到,例如透過日誌行中的計數。不要捕獲一個例外只是為了重新拋出它,也不要防範程式碼無法達到的狀態。
在對外部數字進行算術運算前先檢查它們。 請求中的數量、金額或計數可能非常大,將其相加或相乘可能溢位成一個小或負數值,進而通過所有後續檢查。在將傳入數值加到任何東西之前,先將其與其限制(庫存、最大值)進行比較,並添加一個測試,發送該型別能容納的最大值。這只是一個 if,而不是多餘的程式碼。
決定一個多步驟變更中途重新啟動時的行為。 當程式碼改變已儲存的狀態、呼叫某些緩慢或外部的東西(例如支付提供商),然後再次改變狀態時,問問如果進程在這些步驟之間停止會發生什麼。在呼叫之前保存一個清晰的中間狀態(例如 pending)。重新啟動時,不要猜測:不要撤銷一個可能已成功的步驟(例如為可能已進行的扣款退回庫存),並不要重新打開該工作以免重試可能導致重複扣款。將記錄留在其中間狀態,保持其封鎖狀態,並使其可見(例如在啟動時記錄有多少筆待處理),以便人員可以解決它。將此規則以一行或兩行寫入設計中,並透過重新啟動並有一個待處理記錄來測試它。
不添加多餘元件。 沒有 CLI、日誌、設定、重試、快取、指標或型別階層,除非它們被要求,或者任務或指定的目標無法在沒有它們的情況下達成。例如,無人值守運行的程式碼需要某種方式來報告問題。
修改現有程式碼時
遵循專案慣例,執行完成任務所需的最小變更。不要重構相鄰程式碼或順便引入新模式。保留複雜但能正常運作的程式碼,除非簡化它正是任務,或者你的變更要求這樣做;如果它看起來可以被移除,請提出來。
簡單不是什麼
永遠不要為了節省行數而放棄以下任何一項:
- 正確的行為,包括所有已陳述的規則和邊界情況
- 安全性:在信任邊界進行輸入驗證、安全查詢、程式碼中沒有機密
- 重要的精確度:金錢必須正確到每一分錢,包括半分錢的情況,並且測試應能證明。使用能達成此目的的最樸素方法。當價格只有幾個小數位,且浮點數無論如何都會跨越函式邊界時,使用帶有小型四捨五入輔助函式來清除二進位噪音的浮點數是可以的。只有當一個已陳述的規則需要它們,或者測試顯示較簡單的方法得到錯誤結果時,才使用整數美分或精確分數(如
big.Rat) - 任務實際上要求或測量顯示重要的性能
- 使用者明確要求的抽象;建立它,以最簡單的形式
更少的行數也不是目標。程式碼高爾夫與過度設計一樣難讀,所以當更長的版本更清晰時,請偏好它。一個長函式完成所有工作並不簡單:當一個函式混合了不同的步驟(例如讀取、解析和摘要)時,為每個步驟提供一個命名良好的函式。當一個問題具有真正的複雜性時,保留它,將其隔離在一個命名良好的地方,並用簡短的註釋解釋原因。
範例
任務:應用會員折扣,並對 50 元以下的訂單收取 5 元運費。
避免這樣做。它產生了相同的輸出,但閱讀量卻大得多:
type DiscountStrategy interface {
Apply(amount float64) float64
}
type MemberDiscount struct{}
func (MemberDiscount) Apply(amount float64) float64 {
return amount * 0.95
}
type PriceCalculator struct {
Strategy DiscountStrategy
ShippingThreshold float64
ShippingFee float64
}
func (c PriceCalculator) Calculate(amount float64) float64 {
discounted := c.Strategy.Apply(amount)
if discounted < c.ShippingThreshold {
return discounted + c.ShippingFee
}
return discounted
}
改寫成這樣:
func FinalPrice(amount float64, isMember bool) float64 {
if isMember {
amount = amount * 0.95
}
if amount < 50 {
amount = amount + 5
}
return amount
}
之後新增 VIP 折扣只需要多一個 if,而且沒人需要學習一個介面和三個型別才能做到。