如何提高代碼的可讀性

本文整理自 taowen 師傅在滴滴內部的分享。

1.Why

對一線開發人員來說,每天工作內容大多是在已有項目的基礎上繼續堆代碼。當項目實在堆不動時就需要尋找收益來重構代碼。既然我們的大多數時間都花在坐在顯示器前讀寫代碼這件事上,那可讀性不好的代碼都是在謀殺自己 or 同事的生命,所以不如一開始就提煉技巧,努力寫好代碼; )

2.How

爲提高代碼可讀性,先來分析代碼實際運行環境。代碼實際運行於兩個地方:cpu人腦。對於 cpu,代碼優化需理解其工作機制,寫代碼時爲針對 cpu 特性進行優化;對於人腦,我們在讀代碼時,它像解釋器一樣,一行一行運行代碼,從這個角度來說,要提高代碼的可讀性首先需要知道大腦的運行機制。

下面來看一下人腦適合做的事情和不適合做的事情:

大腦擅長做的事情

JnNkCN SCz4AZ

大腦不擅長做的事情

7DGLpK

代碼優化理論

瞭解人腦的優缺點後,寫代碼時就可以根據人腦的特點對應改善代碼的可讀性了。這裏提取出三種理論:

  1. Align Models ,匹配模型:代碼中的數據和算法模型 應和人腦中的 心智模型對應

  2. Shorten Process , 簡短處理:寫代碼時應 縮短 “福爾摩斯探案集” 的流程長度,即不要寫大段代碼

  3. Isolate Process,隔離處理:寫代碼一個流程一個流程來處理,不要同時描述多個流程的演進過程

下面通過例子詳細解釋這三種模型:

Align Models

在代碼中,模型無外乎就是數據結構算法,而在人腦中,對應的是心智模型,所謂心智模型就是人腦對於一個物體 or 一件事情的想法,我們平時說話就是心智模型的外在表現。寫代碼時應把代碼中的名詞與現實名詞對應起來,減少人腦從需求文檔到代碼的映射成本。比如對於 “銀行賬戶” 這個名詞,很多變量名都可以體現這個詞,比如:bankAccount、bank_account、account、BankAccount、BA、bank_acc、item、row、record、model,編碼中應統一使用和現實對象能鏈接上的變量名。

代碼命名技巧

起變量名時候取其實際含義,沒必要隨便寫個變量名然後在註釋裏面偷偷用功。

// bad
var d int // elapsed time in days

// good
var elapsedTimeInDays int // 全局使用

起函數名時 動詞 + 名詞結合,還要注意標識出你的自定義變量類型:

// bad
func getThem(theList [][]int) [][]int {
 var list1 [][]int // list1是啥,不知道
 for _, x := range theList {
  if x[0] == 4 { // 4是啥,不知道
   list1 = append(list1, x)
  }
 }
 return list1
}

// good
type Cell []int // 標識[]int作用

func (cell Cell) isFlagged() bool { // 說明4的作用
 return cell[0] == 4
}

func getFlaggedCells(gameBoard []Cell) []Cell { // 起有意義的變量名
 var flaggedCells []Cell
 for _, cell := range gameBoard {
  if cell.isFlagged() {
   flaggedCells = append(flaggedCells, cell)
  }
 }
 return flaggedCells
}
代碼分解技巧

按照空間分解 (Spatial Decomposition):下面這塊代碼都是與 Page 相關的邏輯,仔細觀察可以根據 page 的空間分解代碼:

// bad
// …then…and then … and then ... // 平鋪直敘描述整個過程
func RenderPage(request *http.Request) map[string]interface{} {
 page := map[string]interface{}{}
 name := request.Form.Get("name")
 page["name"] = name
 urlPathName := strings.ToLower(name)
 urlPathName = regexp.MustCompile(`['.]`).ReplaceAllString(
  urlPathName, "")
 urlPathName = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(
  urlPathName, "-")
 urlPathName = strings.Trim(urlPathName, "-")
 page["url"] = "/biz/" + urlPathName
 page["date_created"] = time.Now().In(time.UTC)
 return page
}
// good
// 按空間分解,這樣的好處是可以集中精力到關注的功能上
var page = map[string]pageItem{
 "name":         pageName,
 "url":          pageUrl,
 "date_created": pageDateCreated,
}

type pageItem func(*http.Request) interface{}

func pageName(request *http.Request) interface{} { // name 相關過程
 return request.Form.Get("name")
}

func pageUrl(request *http.Request) interface{} { // URL 相關過程
 name := request.Form.Get("name")
 urlPathName := strings.ToLower(name)
 urlPathName = regexp.MustCompile(`['.]`).ReplaceAllString(
  urlPathName, "")
 urlPathName = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(
  urlPathName, "-")
 urlPathName = strings.Trim(urlPathName, "-")
 return "/biz/" + urlPathName
}

func pageDateCreated(request *http.Request) interface{} { // Date 相關過程
 return time.Now().In(time.UTC)
}

按照時間分解 (Temporal Decomposition):下面這塊代碼把整個流程的算賬和打印賬單混寫在一起,可以按照時間順序對齊進行分解:

// bad 
func (customer *Customer) statement() string {
 totalAmount := float64(0)
 frequentRenterPoints := 0
 result := "Rental Record for " + customer.Name + "\n"

 for _, rental := range customer.rentals {
  thisAmount := float64(0)
  switch rental.PriceCode {
  case REGULAR:
   thisAmount += 2
  case New_RELEASE:
   thisAmount += rental.rent * 2
  case CHILDREN:
   thisAmount += 1.5
  }
  frequentRenterPoints += 1
  totalAmount += thisAmount
 }
 result += strconv.FormatFloat(totalAmount,'g',10,64) + "\n"
 result += strconv.Itoa(frequentRenterPoints)

 return result
}
// good 邏輯分解後的代碼
func statement(custom *Customer) string {
 bill := calcBill(custom)

 statement := bill.print()

 return statement
}

type RentalBill struct {
 rental Rental
 amount float64
}

type Bill struct {
 customer             *Customer
 rentals              []RentalBill
 totalAmount          float64
 frequentRenterPoints int
}

func calcBill(customer *Customer) Bill {

 bill := Bill{}
 for _, rental := range customer.rentals {
  rentalBill := RentalBill{
   rental: rental,
   amount: calcAmount(rental),
  }
  bill.frequentRenterPoints += calcFrequentRenterPoints(rental)
  bill.totalAmount += rentalBill.amount
  bill.rentals = append(bill.rentals, rentalBill)
 }
 return bill
}

func (bill Bill) print() string {

 result := "Rental Record for " + bill.customer.name + "(n"

 for _, rental := range bill.rentals{
  result += "\t" + rental.movie.title + "\t" +
   strconv.FormatFloat(rental.amount, 'g', 10, 64) + "\n"
 }
 

 result += "Amount owed is " +
  strconv.FormatFloat(bill.totalAmount, 'g', 10, 64) + "\n"

 result += "You earned + " +
  strconv.Itoa(bill.frequentRenterPoints) + "frequent renter points"

 return result
}

func calcAmount(rental Rental) float64 {
 thisAmount := float64(0)
 switch rental.movie.priceCode {
 case REGULAR:
  thisAmount += 2
  if rental.daysRented > 2 {
   thisAmount += (float64(rental.daysRented) - 2) * 1.5
  }
 case NEW_RELEASE:
  thisAmount += float64(rental.daysRented) * 3
 case CHILDRENS:
  thisAmount += 1.5
  if rental.daysRented > 3 {
   thisAmount += (float64(rental.daysRented) - 3) * 1.5
  }
 }
 return thisAmount
}

func calcFrequentRenterPoints(rental Rental) int {
 frequentRenterPoints := 1
 switch rental.movie.priceCode {
 case NEW_RELEASE:
  if rental.daysRented > 1 {
   frequentRenterPointst++
  }
 }
 return frequentRenterPoints
}

按層分解 (Layer Decomposition):

// bad
func findSphericalClosest(lat float64, lng float64, locations []Location) *Location {
 var closest *Location
  closestDistance := math.MaxFloat64
  for _, location := range locations {
    latRad := radians(lat)
    lngRad := radians(lng)
    lng2Rad := radians(location.Lat)
    lng2Rad := radians(location.Lng)
    var dist = math.Acos(math.Sin(latRad) * math.Sin(lat2Rad) +  
                         math.Cos(latRad) * math.Cos(lat2Rad) *
                         math.Cos(lng2Rad - lngRad) 
                        )
    if dist < closestDistance {
   closest = &location
      closestDistance = dist
    }
  }
 return closet
}
// good
type Location struct {
}

type compare func(left Location, right Location) int

func min(objects []Location, compare compare) *Location {
 var min *Location
 for _, object := range objects {
  if min == nil {
   min = &object
   continue
  }
  if compare(object, *min) < 0 {
   min = &object
  }
 }
 return min
}

func findSphericalClosest(lat float64, lng float64, locations []Location) *Location {
 isCloser := func(left Location, right Location) int {
  leftDistance := rand.Int()
  rightDistance := rand.Int()
  if leftDistance < rightDistance {
   return -1
  } else {
   return 0
  }
 }
 closet := min(locations, isCloser)
 return closet
}
註釋

註釋不應重複代碼的工作。應該去解釋代碼的模型和心智模型的映射關係,應說明爲什麼要使用這個代碼模型,下面的例子就是反面教材:

// bad
/** the name. */
var name string
/** the version. */
var Version string
/** the info. */
var info string

// Find the Node in the given subtree, with the given name, using the given depth.
func FindNodeInSubtree(subTree *Node, name string, depth *int) *Node {
}

下面的例子是正面教材:

// Impose a reasonable limit - no human can read that much anyway
const MAX_RSS_SUBSCRIPTIONS = 1000

// Runtime is O(number_tags * average_tag_depth), 
// so watch out for badly nested inputs.
func FixBrokenHTML(HTML string) string {
 // ...
}

Shorten Process

Shorten Process 的意思是要縮短人腦 “編譯代碼” 的流程。應該避免寫出像小白鼠走迷路一樣又長又繞的代碼。所謂又長又繞的代碼表現在,跨表達式跟蹤、跨多行函數跟蹤、跨多個成員函數跟蹤、跨多個文件跟蹤、跨多個編譯單元跟蹤,甚至是跨多個代碼倉庫跟蹤。

對應的手段可以有:引入變量、拆分函數、提早返回、縮小變量作用域,這些方法最終想達到的目的都是讓大腦喘口氣,不要一口氣跟蹤太久。同樣來看一些具體的例子:

例子

下面的代碼,多種複合條件組合在一起,你看了半天繞暈了可能也沒看出到底什麼情況下爲 true,什麼情況爲 false。

// bad
func (rng *Range) overlapsWith(other *Range) bool {
 return (rng.begin >= other.begin && rng.begin < other.end) ||
  (rng.end > other.begin && rng.end <= other.end) ||
  (rng.begin <= other.begin && rng.end >= other.end)
}

但是把情況進行拆解,每種條件進行單獨處理。這樣邏輯就很清晰了。

// good
func (rng *Range) overlapsWith(other *Range) bool {
 if other.end < rng.begin {
  return false // they end before we begin 
 } 
 if other.begin >= rng.end {
  return false // they begin after we end 
 }
  return true // Only possibility left: they overlap
}

再來看一個例子,一開始你寫代碼的時候,可能只有一個 if ... else...,後來 PM 讓加一下權限控制,於是你可以開心的在 if 裏繼續套一層 if,補丁打完,開心收工,於是代碼看起來像這樣:

// bad 多層縮進的問題
func handleResult(reply *Reply, userResult int, permissionResult int) {
  if userResult == SUCCESS {
    if permissionResult != SUCCESS {
      reply.WriteErrors("error reading permissions")
     reply.Done()
     return
    }
    reply.WriteErrors("")
  } else {
    reply.WriteErrors("User Result")
  }
  reply.Done()
}

這種代碼也比較好改,一般反向寫 if 條件返回判否邏輯即可:

// good
func handleResult(reply *Reply, userResult int, permissionResult int) {
  defer reply.Done()
  if userResult != SUCCESS {
    reply.WriteErrors("User Result")
    return 
  }
  if permissionResult != SUCCESS {
    reply.WriteErrors("error reading permissions")
    return
  }
  reply.WriteErrors("")
}

這個例子的代碼問題比較隱晦,它的問題是所有內容都放在了 MooDriver 這個對象中。

// bad
type MooDriver struct {
 gradient Gradient
  splines []Spline
}
func (driver *MooDriver) drive(reason string) {
  driver.saturateGradient()
  driver.reticulateSplines()
  driver.diveForMoog(reason)
}

比較好的方法是儘可能減少全局 scope,而是使用上下文變量進行傳遞。

// good 
type ExplicitDriver struct {
  
}

// 使用上下文傳遞
func (driver *MooDriver) drive(reason string) {
  gradient := driver.saturateGradient()
  splines := driver.reticulateSplines(gradient)
  driver.diveForMoog(splines, reason)
}

Isolate Process

人腦缺陷是不擅長同時跟蹤多件事情,如果”同時跟蹤 “事物的多個變化過程,這不符合人腦的構造;但是如果把邏輯放在很多地方,這對大腦也不友好,因爲大腦需要” 東拼西湊“才能把一塊邏輯看全。所以就有了一句很經典的廢話,每個學計算機的大學生都聽過。你的代碼要做到高內聚,低耦合,這樣就牛逼了!-_-|||,但是你要問說這話的人什麼叫高內聚,低耦合呢,他可能就得琢磨琢磨了,下面來通過一些例子來琢磨一下。

首先先來玄學部分,如果你的代碼寫成下面這樣,可讀性就不會很高。

一般情況下,我們可以根據業務場景努力把代碼修改成這樣:

舉幾個例子,下面這段代碼非常常見,裏面 version 的含義是用戶端上不同的版本需要做不同的邏輯處理。

func (query *Query) doQuery() {
  if query.sdQuery != nil {
    query.sdQuery.clearResultSet()
  }
  // version 5.2 control
  if query.sd52 {
    query.sdQuery = sdLoginSession.createQuery(SDQuery.OPEN_FOR_QUERY)
  } else {
    query.sdQuery = sdSession.createQuery(SDQuery.OPEN_FOR_QUERY)
  }
  query.executeQuery()
}

這段代碼的問題是由於版本差異多塊代碼流程邏輯 Merge 在了一起,造成邏輯中間有分叉現象。處理起來也很簡單,封裝一個 adapter,把版本邏輯抽出一個 interface,然後根據版本實現具體的邏輯。

再來看個例子,下面代碼中根據 expiry 和 maturity 這樣的產品邏輯不同 也會造成分叉現象,所以你的代碼會寫成這樣:

// bad
type Loan struct {
 start    time.Time
 expiry   *time.Time
 maturity *time.Time
 rating   int
}

func (loan *Loan) duration() float64 {
 if loan.expiry == nil {
  return float64(loan.maturity.Unix()-loan.start.Unix()) / 365 * 24 * float64(time.Hour)
 } else if loan.maturity == nil {
  return float64(loan.expiry.Unix()-loan.start.Unix()) / 365 * 24 * float64(time.Hour)
 }
 toExpiry := float64(loan.expiry.Unix() - loan.start.Unix())
 fromExpiryToMaturity := float64(loan.maturity.Unix() - loan.expiry.Unix())
 revolverDuration := toExpiry / 365 * 24 * float64(time.Hour)
 termDuration := fromExpiryToMaturity / 365 * 24 * float64(time.Hour)
 return revolverDuration + termDuration
}

func (loan *Loan) unusedPercentage() float64 {
 if loan.expiry != nil && loan.maturity != nil {
  if loan.rating > 4 {
   return 0.95
  } else {
   return 0.50
  }
 } else if loan.maturity != nil {
  return 1
 } else if loan.expiry != nil {
  if loan.rating > 4 {
   return 0.75
  } else {
   return 0.25
  }
 }
 panic("invalid loan")
}

解決多種產品邏輯的最佳實踐是 Strategy pattern,代碼如下圖,根據產品類型創建出不同的策略接口,然後分別實現 duration 和 unusedPercentage 這兩個方法即可。

// good
type LoanApplication struct {
 expiry   *time.Time
 maturity *time.Time
}

type CapitalStrategy interface {
 duration() float64
 unusedPercentage() float64
}

func createLoanStrategy(loanApplication LoanApplication) CapitalStrategy {
 if loanApplication.expiry != nil && loanApplication.maturity != nil {
  return createRCTL(loanApplication)
 }
 if loanApplication.expiry != nil {
  return createRevolver(loanApplication)
 }
 if loanApplication.maturity != nil {
  return createTermLoan
 }
 panic("invalid loan application")
}

但是現實情況沒有這麼簡單,因爲不同事物在你眼中就是多進程多線程運行的,比如上面產品邏輯的例子,雖然通過一些設計模式把執行的邏輯隔離到了不同地方,但是代碼中只要含有多種產品,代碼在執行時還是會有一個產品選擇的過程。邏輯發生在同一時間、同一空間,所以 “自然而然” 就需要寫在了一起:

對於多種功能雜糅在一起,比如上面的RenderPage函數,對應解法爲不要把所有事情合在一起搞,把單塊功能內聚,整體再耦合成爲一個單元。

對於多個同步進行的 I/O 操作,可以通過協程把揉在一起的過程分開來:

// bad 兩個I/O寫到一起了
func sendToPlatforms() {
 httpSend("bloomberg", func(err error) {
  if err == nil {
   increaseCounter("bloomberg_sent", func(err error) {
    if err != nil {
     log("failed to record counter", err)
    }
   })
  } else {
   log("failed to send to bloom berg", err)
  }
 })
 ftpSend("reuters", func(err error) {
  if err == DIRECTORY_NOT_FOUND {
   httpSend("reuterHelp", err)
  }
 })
}

對於這種併發的 I/O 場景,最佳解法就是給每個功能各自寫一個計算函數,代碼真正運行的時候是” 同時 “在運行,但是代碼中是分開的。

//good 協程寫法
func sendToPlatforms() {
 go sendToBloomberg()
 go sendToReuters()
}

func sendToBloomberg() {
 err := httpSend("bloomberg")
 if err != nil {
  log("failed to send to bloom berg", err)
  return
 }
 err := increaseCounter("bloomberg_sent")
 if err != nil {
  log("failed to record counter", err)
 }
}

func sendToReuters() {
 err := ftpSend("reuters")
 if err == nil {
  httpSend("reutersHelp", err)
 }
}

有時,邏輯必須要合併到一個 Process 裏面,比如在買賣商品時必須要對參數做邏輯檢查:

// bad
func buyProduct(req *http.Request) error {
 err := checkAuth(req)
 if err != nil {
  return err
 }
 // ...
}

func sellProduct(req *http.Request) error {
 err := checkAuth(req)
 if err != nil {
  return err
 }
 // ...
}

這種頭部有公共邏輯經典解法是寫個 Decorator 單獨處理權限校驗邏輯,然後 wrapper 一下正式邏輯即可:

// good 裝飾器寫法
func init() {
 buyProduct = checkAuthDecorator(buyProduct)
 sellProduct = checkAuthDecorator(sellProduct)
}

func checkAuthDecorator(f func(req *http.Request) error) func(req *http.Request) error {
 return func(req *http.Request) error {
  err := checkAuth(req)
  if err != nil {
   return err
  }
  return f(req)
 }
}

var buyProduct = func(req *http.Request) error {
 // ...
}

var sellProduct = func(req *http.Request) error {
 // ...
}

此時你的代碼會像這樣:

當然公共邏輯不僅僅存在於頭部,仔細思考一下所謂的 strategy、Template pattern,他們是在邏輯的其他地方去做這樣的邏輯處理。

這塊有一個新的概念叫:信噪比。信噪比是一個相對概念,信息,對有用的;噪音,對沒用的。代碼應把什麼邏輯寫在一起,不僅取決於讀者是誰,還取決於這個讀者當時希望完成什麼目標。

比如下面這段 C++ 和 Python 代碼:

void sendMessage(const Message &msg) const {...}
def sendMessage(msg):

如果你現在要做業務開發,你可能會覺得 Python 代碼讀起來很簡潔;但是如果你現在要做一些性能優化的工作,C++ 代碼顯然能給你帶來更多信息。

再比如下面這段代碼,從業務邏輯上講,這段開發看起來非常清晰,就是去遍歷書本獲取 Publisher。

for _, book := range books {
  book.getPublisher()
}

但是如果你看了線上打瞭如下的 SQL 日誌,你懵逼了,心想這個 OOM 真 **,真就是一行一行執行 SQL,這行代碼可能會引起 DB 報警,讓你的 DBA 同事半夜起來修 DB。

SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id

所以如果代碼改成這樣,你可能就會更加明白這塊代碼其實是在循環調用實體。

for _, book := range books {
  loadEntity("publisher", book.publisher_id)
}

總結一下:

  1. 總結

當我們吐槽這塊代碼可讀性太差時,不要把可讀性差的原因簡單歸結爲註釋不夠 或者不 OO,而是可以從人腦特性出發,根據下面的圖片去找到代碼問題,然後試着改進它 (跑了幾年的老代碼還是算了,別改一行線上全炸了:)

歡迎加我的個人微信:709834997。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_fg_oS74DEd4jKqJFHKHzg