程式碼簡潔之道筆記
程式碼簡潔之道
1. 程式碼整潔的意義
1.1 糟糕程式碼的壞處
-
生產力低下
-
程式碼不敢該,改一次花費大量的時間來梳理背後邏輯,應付性堆加,專案延期。
-
專案現場,頻繁出問題,出了問題難以排查,耗費人力,資源浪費。
-
加新人,惡行迴圈,導致團隊癱瘓。
-
-
壞的示範作用。
1.2 何謂整潔程式碼
- 優雅。
- 閱讀起來賞心悅目,可讀性強,擴充套件性強,維護性強。
- 抽象簡潔,職責單一。
- 。。。
2. 有意義的命名
-
名副其實
-
避免誤導
-
類名
類名一般採用
名詞
或者名詞短語
-
方法名
方法名一般採用
動詞
或者動詞短語
getXXX
setXXX
isXXX
3. 函式 (重點)
重點:
只做一件事(一個函式一個抽象層級)、函式引數 (引數優化策略)、優化if-else 和 switch-case、返回Null 和 判Null、標識變數做引數
-
短小
建議:if、for、while這種程式碼塊不超過3行
-
只做一件事情
函式只做一件事情,可以確保邏輯單一簡潔,可閱讀性強,只有一個變化因素。
函式只做一件事情,這一件事情可以分為多個步驟,但是
這幾個步驟必須是同一個抽象層級
引入其他低級別的含義,程式碼就比較臃腫混亂
。// 比如:查詢一班同學中成績TopN的同學 func findTopNStudentsInClass(classId string, topN int) []Students { students := findStudents(classId) // 實現排序 for i, stu := range students{ if students[i].Bigger(students[j]) { // swap... } } // 返回TopN return stduents[:topN] }
例項中這個函式就違背了只做一件事(函式的幾個操作步驟應該在同一抽象層級)這個原則。導致了程式碼的整潔性破壞。
另外還引入函式變化的另外的因素:比如換一種排序方式,就要重新測試該函式,而如果把抽象層級排序函式拎出來,單獨實現,變化了,只測試該排序函式即可,本函式不受影響。
// 優化改進版 func findTopNStudentsInClass(classId string, topN int) []Students { students := findStudents(classId) // 實現排序 students = sort(students) // 返回TopN return stduents[:topN] } func sort([]stduents) []students{ // 實現排序演算法 }
可以看到,改進後,函式的幾個操作步驟都在統一抽象層級中。另外引起函式變化的因素也減少了。
總結:
函式只做一件事情的核心是,幾個操作步驟都在統一抽象層級中
。同一抽象層級的核心是圍繞一個操作主題(比如示例中幾個步驟都是對 主題Students進行處理)
-
每個函式一個抽象層級
描述見上面
-
函式引數
總體原則:函式引數不要超過兩個或者三個。
無參函式 > 一個引數函式 > 兩個引數函式 (閱讀使用更加簡潔,引數越少,理解成本越低)
-
一元引數
一元引數 要表達的是函式要操作物件的語意。 比如 write(file string), send(msg) 。這樣更整潔,好理解
另外一種是操作event
-
避免使用識別符號引數 (即:避免傳佈爾型別引數)
向函式傳佈爾型別引數令人駭人聽聞,表示這個函式不止做一件事情,違背了函式只做一件事的原則 (如為true,這樣做;如為false,則那樣做)。
應該把函式一分為二,用兩個單獨的函式來封裝。
// 壞程式碼 func render(isSuite bool) { if isSuite { // suite 測試 }else { // 單個用力測試 } } // 整潔程式碼 func renderForSuiteTest(){ } func renderForSingleTest() { }
-
二元函式
二元函式比一元函式難懂。如
writeFiled(name)
比writeFiled(outputStream, name)
好懂。第一個看一下就明白,第二個要停下來理解一下。再比如
assertEquals(expect, actual)
這樣的二元函式,好多次會搞錯引數的順序。這就是使用二元函式的代價。應儘量利用一些機制將二元函式轉換成一元函式。可以有下面幾種方式:
- 把
writeFiled
寫成outputStream
的 成員方法。然後直接呼叫outputStream.writeFiled(name)
- 把
outputStream
寫成當前類的成員變數,從而無需再傳遞它。 直接呼叫xxx.writeFiled(name)
- 還可以分離出類似
FiledWriter
新類,在其構造器中傳入outputStream
, 並提供一個write
方法。然後呼叫fileWriter.write(name)
- 把
-
三元函式
三元函式比二元函式難懂,需要花時間理解引數含義,傳遞順序等。
如果函式需要二、三個、三個以上引數,就說明一些引數需要封裝為類了。 改寫機制引數二元函式說明。
-
變參函式
變參部分可以理解為 函式列表 (
list
或者slice
), 這樣變參函式就變成了一元或者二元函數了。format(fmt string, args ...interface{})
pint(args ...interface{})
-
給函式取好名字
取好名字能夠較好的理解函式的意圖,以及引數的順序和意圖。
對於一元函式,函式和引數應該形成良好的
動/名詞對
形式,如write(Name)
-
-
輸出返回值
函式輸出要儘量明確,避免把引數當作輸出,會破壞一些使用慣例。
-
用多型替代
if/else
或者switch/case
if/else
或者switch/case
這類程式碼結構,是程式碼腐化的常見示例,違背了開閉原則。通常可以通過
多型
將其優化掉type Play struct{ palyType string price float32 } const ( stragedyPriceRate = 1.25 comedyPriceRate = 1.1 ) func CalcAmount(play Play) float32{ amount := 0.0 switch play.playType { case "stragedy": amount = stragedyPriceRate * price case "comedy": amount = comedyPriceRate * price default: } return amount }
如果要增加新的 戲曲型別,比如
京劇、豫劇...
等等,都要去修改這個函式,違背開閉原則。通過多型 or 依賴倒置原則來優化type Play interface{ Amount() float32 } type Stragedy struct{ price float32 rate float32 } func (s Stragedy) Amount() float32{ return s.price * s.rate } type Comedy struct{ price float32 rate float32 } func (c Comedy) Amount() float32{ return c.price * c.rate } func CalcAmount(play Play) float32{ return return play.Amount() }
通過依賴倒置優化,擴充套件性大大提高
-
無副作用
符合只做一件事是一個原則。不能在一個語義函式中做其他事情,比如再checkXXX中做一些初始化操作,就帶了副作用
func checkMsgValidte(msg *Msg) { if msg.Set == nil { msg.Set.Init() } }
-
分割命令和查詢
函式要麼做什麼事,要麼回答什麼事情,二者不可兼得。要麼修改值,要麼返回值,兩樣都幹可能會導致混亂。
// 設定某個屬性,成功返回True, 不存在返回False func (o Object)set(attribute string, value string) bool { if o.hasAttribute(attribute) { o.attribute = attribute return true } return false } // 給使用者會帶來混淆。 // 到底是name屬性之前不為空,還是設定name為tom成功呢? // 介面有歧義,帶來一定程度的誤解 if (o.set("name", "tom")) { // ... }
要解決這個問題,可以將
set
函式重新命名為setAndCheckIfExists
。或者採用更好的解決方案,把命令和查詢
分割開來,防止發生混淆:if attributeExist("name") { SetAttribute("name", "tom") }
新的建議:
-
函式命名,值得花時間去推敲,反覆斟酌修改。先寫出來看看,不合適再改,直到簡潔滿意。
-
寫程式碼和寫別的東西很像(比如寫文章)。先想什麼就寫什麼,然後再打磨它。粗稿也許粗陋無序,反覆斟酌推敲,直到滿意。
我寫函式時,一開始都冗長複雜。有太多縮排和巢狀,有過長的引數列表,名字也是隨意取,也有重複程式碼。不過我會配上一套單元測試,覆蓋每一行醜陋程式碼。打磨、分解函式、修改名稱、消除重複,保持測試通過,遵照本章的規則進行優化。並不是一開始就按照這些規則來寫函式,我想沒人做得到。
-
-
消除特性依賴 (非強制)
類的方法應該儘量只對類自身的變數和函式感興趣,不該垂青其他類的變數和函式。
當類的方法通過其他外部類來實現自身,它就依賴了該外部類。那麼應該將該方法放到這個外部類中,這樣就能直接訪問了。
type HourlyEmployee struct{} func(e HourlyEmployee) GetWorkHours() int {} func(e HourlyEmployee) GetHourSalary() int {} type HourlyPayCalculator struct{} func (h HourlyPayCalculator)CalcultateWeeklyPay(e HourlyEmployee) { workHours := e.GetWorkHours() hourSalary := e.GetHourSalary() return workHours * hourSalary }
如上面示例,
HourlyPayCalculator
通過HourlyEmployee
來計算工資,HourlyPayCalculator
對HourlyEmployee
產生依賴。應該將
CalcultateWeeklyPay
放到HourlyEmployee
類中
4. 註釋
儘量用程式碼做註釋,因為它是最真實的,註釋有可能是老舊或者有誤導性的。嘗試讓程式碼變得簡潔而有表達力,比花時間來寫註釋更有意義。
-
用程式碼來表達,而非註釋
嘗試讓程式碼變得簡潔而有表達力,比花時間來寫註釋更有意義
-
好註釋 (值得寫的註釋)
5. 格式
-
垂直方向上的空白區分
每行展現一個表示式或者子句。每組程式碼行展示一個完整的思路。這些思路用空白行分割開來。
再包宣告、包匯入、變數或常量定義、函式之間,都有空白行隔開,這些機簡單的規則很大的影響了程式碼的閱讀體驗
-
垂直方向上的靠近
比如都是成員變數,中間最好不要隔開,避免要移動視線才直到這幾個是成員變數
-
相關原則
一個函式呼叫另外一個函式,就應該放到一起。呼叫者儘可能的放在被呼叫者上面,負責自上而下的閱讀順序。
6. 錯誤處理
原則:要弄清錯誤處理與整潔程式碼之間的關係。錯誤處理很重要,但是如果它搞亂了程式碼的邏輯,就是錯誤的做法。
正確的做法是,將錯誤處理隔離開,單獨於主要邏輯之外,就能寫出整潔的程式碼。
-
使用異常而非錯誤碼
因為錯誤檢查容易遺忘。遇到錯誤時,丟擲一個異常,呼叫程式碼會比較整潔,邏輯不會被錯誤處理搞亂
-
別傳遞Null值
返回Null值是容易引發錯誤的做法。呼叫者需要不停的檢查返回是否為null.
// 示例: func registerItem(item Item) { if item != nil { registery := store.GetItemRegistery if registery != nil { registery.GetItem(item.GetId()) } } }
這段程式碼糟糕透了,返回Null值,是在給自己增加工作量,也是給呼叫者添亂。只要忘記檢查Null,就會導致程式失控。
可以顯示的返回錯誤,或者返回一個特例物件。// 返回Null的場景 employees := getEmployees() if employees != nil { for _, employee := range employees{ total += employee.getPay() } } // 如果getEmployees() 返回預設特例,可以避免判Null, 邏輯更簡潔 employees := getEmployees() for _, employee := range employees{ total += employee.getPay() } func getEmployees()[]employee{ if false { return emptyEmployees } return employees }
-
被傳遞Null值
方法中返回Null
是糟糕的做法,但是把Null值
傳遞給其他函式更糟糕。// 示例: func xProject(p1, p2 *Point) double { return (p1.x - p2.x) * 1.5 } // 如果有人傳遞了Null值會怎麼樣? 程式會直接得到一個NULL指標異常 xProject(Null, &Point{1}) // 如何修正? 可以建立一個 NullPointerException 異常 // 只是稍微比Panic好一點,但是還要捕獲處理這個異常 func xProject(p1, p2 *Point) double { if p1 == nil || p2 == nil{ throw InvalidArgumentException("invalid arguments") } return (p1.x - p2.x) * 1.5 } // 還可以使用斷言,但是也會產生執行時錯誤 func xProject(p1, p2 *Point) double { if p1 == nil || p2 == nil{ panic("invalid arguments") } return (p1.x - p2.x) * 1.5 } // 總結:最好的方式,還是編碼的時候,時刻牢記不能傳Null。呼叫者保證。
7. 邊界
對於第三方模型或者框架的引用 形成了邊界。為了減少邊界變化對業務程式碼的影響,一般要對邊界程式碼進行封裝。
一種是類封裝,另外一種是Adaptor,將一種介面轉換成另外一種介面。
8. 單元測試
單元測試與生產程式碼一樣重要,它不是二等公民。它需要被思考、設計和照料,應該像生產程式碼一樣保持整潔。
沒有測試程式碼,無法保證 對程式碼的修改 能如改動前一樣正常工作。不會影響到其他部分。
如此,會導致故障增加,不敢修改程式碼,不再清理生產程式碼,因為修改帶來的成本、風險太高。
如此延續下次,生產程式碼開始腐敗。
單元測試可以讓程式碼可擴充套件、可維護、可複用。 (其中,為了滿足程式碼的可測試性,要求模組外部依賴可以注入,如此更好擴充套件)
程式碼優化,單元測試先行。
測試帶來的好處:
有了測試,不怕修改引入新問題。測試用例不變,只要修改前後,用例執行的結果一致,就認為修改前後模型的行為一致。
如此,可以放心的優化程式碼,改進架構設計
-
TDD: 測試驅動開發。測試程式碼先行 (比較理想的一種理念,未必值得推崇)
-
整潔測試的三要素:可讀性。如何做到可讀:明確、簡潔。
-
測試用例格式:構造 -- 操作 -- 檢驗 (Build -- Operate -- Check)模式。
每個測試都可以清晰地拆分為三個環節
整潔測試的5大規則:
-
快速
測試應該足夠快。如果執行緩慢,你就不想 頻繁執行。這樣就不能夠及早發現問題,不能及時清理優化程式碼,導致程式碼腐化。
-
獨立
每個用例可獨立執行。
-
可重複
-
自足驗證
測試應該有布林值輸出。不能通過主觀的判斷確認用例是否通過,而應該採用客觀比對。
-
及時
用例編寫要及時,這樣既可以測試功能正確,也可以通過測試對程式碼進行重構(可測試的)
9. 類
- 類應該儘量小。遵循SRP 單一職責原則,只有一個引起變化的因素。避免改動一個,影響另一個。
- 內聚
- OCP 開閉原則
- DIP 依賴倒置原則
10. 簡單設計原則
下面關於簡單設計的四條原則,對建立良好設計的軟體很有幫助
-
執行所有測試
可測試性也是衡量程式碼鬆耦合設計的一個指標。
緊耦合的程式碼難以測試,寫的測試越多,越會使用依賴注入、介面和抽象等手段減少耦合,遵循DIP之類規則。如此,設計也就有長足進步。
-
重構
有了測試,通過增量式地重構,就能保持程式碼整潔。消除了清理程式碼的恐懼。
重構過程中,可以從 改進命名、優化函式介面引數、優化函式邏輯(一個抽象層級); 優化類設計、優化模組結構設計; 提高內聚、降低耦合。
-
消除重複
消除重複,抽取公共。通過注入函式(差異化各自實現)或者模板方法模式來優化。
-
儘量減少類、方法