1. 程式人生 > 其它 >程式碼簡潔之道筆記

程式碼簡潔之道筆記

目錄

程式碼簡潔之道

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")
    }
    

    新的建議:

    1. 函式命名,值得花時間去推敲,反覆斟酌修改。先寫出來看看,不合適再改,直到簡潔滿意。

    2. 寫程式碼和寫別的東西很像(比如寫文章)。先想什麼就寫什麼,然後再打磨它。粗稿也許粗陋無序,反覆斟酌推敲,直到滿意。

      我寫函式時,一開始都冗長複雜。有太多縮排和巢狀,有過長的引數列表,名字也是隨意取,也有重複程式碼。不過我會配上一套單元測試,覆蓋每一行醜陋程式碼。打磨、分解函式、修改名稱、消除重複,保持測試通過,遵照本章的規則進行優化。並不是一開始就按照這些規則來寫函式,我想沒人做得到。

  • 消除特性依賴 (非強制)

    類的方法應該儘量只對類自身的變數和函式感興趣,不該垂青其他類的變數和函式。

    當類的方法通過其他外部類來實現自身,它就依賴了該外部類。那麼應該將該方法放到這個外部類中,這樣就能直接訪問了。

    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 來計算工資, HourlyPayCalculatorHourlyEmployee產生依賴。

    應該將 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之類規則。如此,設計也就有長足進步。

  • 重構

    有了測試,通過增量式地重構,就能保持程式碼整潔。消除了清理程式碼的恐懼。

    重構過程中,可以從 改進命名、優化函式介面引數、優化函式邏輯(一個抽象層級); 優化類設計、優化模組結構設計; 提高內聚、降低耦合。

  • 消除重複

    消除重複,抽取公共。通過注入函式(差異化各自實現)或者模板方法模式來優化。

  • 儘量減少類、方法