Golang 中常用的代碼優化點

寫代碼其實也有很多套路和經驗,這篇介紹幾個讓 golang 代碼更優雅的四個套路。

這篇想和大家聊一聊 golang 的常用代碼寫法。在 golang 中,如果大家不斷在一線寫代碼,一定多多少少會有一些些代碼的套路和經驗。這些經驗是代表你對一些問題,或者一類問題的思考和解決。處理一個問題的方法有很多,如果頻繁遇到同樣的場景和問題,我們會自己思考有沒有更好的方式來解決,所以也就有了一些代碼套路了。這裏,我想和大家分享一下我個人在開發過程中看到和使用到的一些常用的代碼寫法。

文章中總結了四個 golang 中常用的寫法

使用 pkg/error 而不是官方 error 庫

其實我們可以思考一下,我們在一個項目中使用錯誤機制,最核心的幾個需求是什麼?

1 附加信息:我們希望錯誤出現的時候能附帶一些描述性的錯誤信息,甚至於這些信息是可以嵌套的。

2 附加堆棧:我們希望錯誤不僅僅打印出錯誤信息,也能打印出這個錯誤的堆棧信息,讓我們可以知道錯誤的信息。

在 Go 的語言演進過程中,error 傳遞的信息太少一直是被詬病的一點。我推薦在應用層使用 github.com/pkg/errors 來替換官方的 error 庫。

假設我們有一個項目叫 errdemo,他有 sub1,sub2 兩個子包。sub1 和 sub2 兩個包都有 Diff 和 IoDiff 兩個函數。

// sub2.go
package sub2
import (
    "errors"
    "io/ioutil"
)
func Diff(foo int, bar int) error {
    return errors.New("diff error")
}


// sub1.go
package sub1

import (
    "errdemo/sub1/sub2"
    "fmt"
    "errors"
)
func Diff(foo int, bar int) error {
    if foo < 0 {
        return errors.New("diff error")
    }
    if err := sub2.Diff(foo, bar); err != nil {
        return err
    }
    return nil
}

// main.go
package main

import (
    "errdemo/sub1"
    "fmt"
)
func main() {
    err := sub1.Diff(1, 2)
    fmt.Println(err)
}

在上述三段代碼中,我們很不幸地將 sub1.go 中的 Diff 返回的 error 和 sub2.go 中 Diff 返回的 error 都定義爲同樣的字符串 “diff error”。這個時候,在 main.go 中,我們返回的 error,是無論如何也判斷不出這個 error 是從 sub1 還是 sub2 中拋出的。調試的時候會帶來很大的困擾。

而使用 github.com/pkg/errors ,我們所有的代碼都不需要進行修改,只需要將 import 地方進行對應的修改即可。

在 main.go 中使用fmt.Printf("%+v", err) 就能除了打印 error 的信息,也能將堆棧打印出來了。

// sub2.go
package sub2
import (
    "github.com/pkg/errors"
    "io/ioutil"
)
func Diff(foo int, bar int) error {
    return errors.New("diff error")
}


// sub1.go
package sub1

import (
    "errdemo/sub1/sub2"
    "fmt"
    "github.com/pkg/errors"
)
func Diff(foo int, bar int) error {
    if foo < 0 {
        return errors.New("diff error")
    }
    if err := sub2.Diff(foo, bar); err != nil {
        return err
    }
    return nil
}

// main.go
package main

import (
    "errdemo/sub1"
    "fmt"
)
func main() {
    err := sub1.Diff(1, 2)
    fmt.Printf("%+v", err)
}

看到,除了 "diff error" 的錯誤信息之外,還將堆棧大衣拿出來了,我們能明確看到是 sub2.go 中第 7 行拋出的錯誤。

其實 github.com/pkg/errors 的原理也是非常簡單,它利用了 fmt 包的一個特性:

其中在打印 error 之前會判斷當前打印的對象是否實現了 Formatter 接口,這個 formatter 接口只有一個 format 方法

所以在 github.com/pkg/errors 中提供的各種初始化 error 方法(包括 errors.New)就是封裝了一個 fundamental 結構,這個結構中帶着 error 的信息和堆棧信息

它實現了 Format 方法。

在初始化 slice 的時候儘量補全 cap

當我們要創建一個 slice 結構,並且往 slice 中 append 元素的時候,我們可能有兩種寫法來初始化這個 slice。

方法 1:

package main

import "fmt"

func main() {
 arr := []int{}
 arr = append(arr, 1,2,3,4, 5)
 fmt.Println(arr)
}

方法 2:

package main

import "fmt"

func main() {
   arr := make([]int, 0, 5)
   arr = append(arr, 1,2,3,4, 5)
   fmt.Println(arr)
}

方法 2 相較於方法 1,就只有一個區別:在初始化 []int slice 的時候在 make 中設置了 cap 的長度,就是 slice 的大小。

這兩種方法對應的功能和輸出結果是沒有任何差別的,但是實際運行的時候,方法 2 會比少運行了一個 growslice 的命令。

這個我們可以通過打印彙編碼進行查看:

方法 1:

方法 2:

我們看到方法 1 中使用了 growsslice 方法,而方法 2 中是沒有調用這個方法的。

這個 growslice 的作用就是擴充 slice 的容量大小。就好比是原先我們沒有定製容量,系統給了我們一個能裝兩個鞋子的盒子,但是當我們裝到第三個鞋子的時候,這個盒子就不夠了,我們就要換一個盒子,而換這個盒子,我們勢必還需要將原先的盒子裏面的鞋子也拿出來放到新的盒子裏面。所以這個 growsslice 的操作是一個比較複雜的操作,它的表現和複雜度會高於最基本的初始化 make 方法。對追求性能的程序來說,應該能避免儘量避免。

具體對 growsslice 函數具體實現同學有興趣的可以參考源碼 src 的 runtime/slice.go 。

當然,我們並不是每次都能在 slice 初始化的時候就能準確預估到最終的使用容量的。所以這裏使用了一個 “儘量”。明白是否設置 slice 容量的區別,我們在能預估容量的時候,請儘量使用方法 2 那種預估容量後的 slice 初始化方式。

初始化一個類的時候,如果類的構造參數較多,儘量使用 Option 寫法

我們一定遇到需要初始化一個類的時候,大部分的時候,初始化一個類我們會使用類似下列的 New 方法。

package newdemo

type Foo struct {
   name string
   id int
   age int

   db interface{}
}

func NewFoo(name string, id int, age int, db interface{}) *Foo {
   return &Foo{
      name: name,
      id:   id,
      age:  age,
      db:   db,
   }
}

我們定義一個 NewFoo 方法,其中存放初始化 Foo 結構所需要的各種字段屬性。

這個寫法乍看之下是沒啥問題的,但是一旦 Foo 結構內部的字段進行了變化,增加或者減少了,那麼這個初始化函數 NewFoo 就怎麼看怎麼彆扭了。參數繼續增加?那麼所有調用方的地方也都需要進行修改了,且按照代碼整潔的邏輯,參數多於 5 個,這個函數就很難使用了。而且,如果這 5 個參數都是可有可無的參數,就是有的參數可以允許不填寫,有默認值,比如 age 這個字段,如果不填寫,在後續的業務邏輯中可能沒有很多影響,那麼我在實際調用 NewFoo 的時候,age 這個字段還需要傳遞 0 值。

foo := NewFoo("jianfengye", 1, 0, nil)

這種語意邏輯就不對了。

這裏其實有一種更好的寫法:使用 Option 寫法來進行改造。Option 寫法顧命思議,將所有可選的參數作爲一個可選方式,一般我們會一定一個 “函數類型” 來代表這個 Option,然後配套將所有可選字段設計一個這個函數類型的具體實現。而在具體的使用的時候,使用可變字段的方式來控制有多少個函數類型會被執行。比如上述的代碼,我們會改造爲:

type Foo struct {
 name string
 id int
 age int

 db interface{}
}

// FooOption 代表可選參數
type FooOption func(foo *Foo)

// WithName 代表Name爲可選參數
func WithName(name string) FooOption {
   return func(foo *Foo) {
      foo.name = name
   }
}

// WithAge 代表age爲可選參數
func WithAge(age int) FooOption {
   return func(foo *Foo) {
      foo.age = age
   }
}

// WithDB 代表db爲可選參數
func WithDB(db interface{}) FooOption {
   return func(foo *Foo) {
      foo.db = db
   }
}

// NewFoo 代表初始化
func NewFoo(id int, options ...FooOption) *Foo {
   foo := &Foo{
      name: "default",
      id:   id,
      age:  10,
      db:   nil,
   }
   for _, option := range options {
      option(foo)
   }
   return foo
}

解釋下上面的這段代碼,我們創建了一個 FooOption 的函數類型,這個函數類型代表的函數結構是 func(foo *Foo) ,很簡單,將 foo 指針傳遞進去,能讓內部函數進行修改。

然後我們定義了三個返回了 FooOption 的函數:

以 WithName 爲例,這個函數參數爲 string,返回值爲 FooOption。在返回值的 FooOption 中,根據參數修改了 Foo 指針。

// WithName 代表Name爲可選參數
func WithName(name string) FooOption {
   return func(foo *Foo) {
      foo.name = name
   }
}

順便說一下,這種函數我們一般都以 With 開頭,表示我這次初始化 “帶着” 這個字段。

而最後 NewFoo 函數,參數我們就改造爲兩個部分,一個部分是 “非 Option” 字段,就是必填字段,假設我們的 Foo 結構實際上只有一個必填字段 id,而其他字段皆是選填的。而其他所有選填字段,我們使用一個可變參數 options 替換。

NewFoo(id int, options ...FooOption)

在具體的實現中,也變化成 2 個步驟:

按照這樣改造之後,我們具體使用 Foo 結構的函數就變爲如下樣子:

// 具體使用NewFoo的函數
func Bar() {
   foo := NewFoo(1, WithAge(15), WithName("foo"))
   fmt.Println(foo)
}

可讀性是否高了很多?New 一個 Foo 結構,id 爲 1,並且帶着指定 age 爲 15,指定 name 爲 “foo”。

後續如果 Foo 多了一個可變屬性,那麼只需要多一個 WithXXX 的方法,而 NewFoo 函數不需要任何變化,調用方只有需要指定這個可變屬性的地方增加 WithXXX 即可。擴展性非常好。

這種 Option 的寫法在很多著名的庫中都有使用到,gorm, go-redis 等。所以我們要把這種方式熟悉起來,一旦我們在需要對一個比較複雜的類進行初始化的時候,這種方法應該是最優的方式了。

巧用大括號控制變量作用域

在 golang 寫的過程中,你一定有過爲 := 和 = 煩惱的時刻。一個變量,到寫的時候,我還要記得前面是否已經定義過了,如果沒有定義過,使用 := ,如果已經定義過,使用 =。

當然很多時候可能你不會犯這種錯誤,變量命名的比較好的話,我們是很容易記得是否前面有定義過的。但是更多時候,對於 err 這種通用的變量名字,你可能就不一定記得了。

這個時候,巧妙使用大括號,就能很好避免這個問題。

我舉一個我之前寫一個命令行工具的例子,大家知道寫命令行工具,對傳遞的參數的解析是需要有一些邏輯的,“如果參數中有某個字段,那麼解析並存儲到變量中,如果沒有,記錄 error”,這裏我就使用了大括號,將每個參數的解析和處理錯誤的邏輯都封裝起來。

代碼大致如下:

var name string
var folder string
var mod string
...
{
   prompt := &survey.Input{
      Message: "請輸入目錄名稱:",
   }
   err := survey.AskOne(prompt, &name)
   if err != nil {
      return err
   }

   ...
}
{
   prompt := &survey.Input{
      Message: "請輸入模塊名稱(go.mod中的module, 默認爲文件夾名稱):",
   }
   err := survey.AskOne(prompt, &mod)
   if err != nil {
      return err
   }
   ...
}
{
   // 獲取hade的版本
   client := github.NewClient(nil)
   prompt := &survey.Input{
      Message: "請輸入版本名稱(參考 https://github.com/gohade/hade/releases,默認爲最新版本):",
   }
   err := survey.AskOne(prompt, &version)
   if err != nil {
      return err
   }
   ...
}

首先我將最終解析出來的最終變量在最開始做定義,然後使用三個大括號,分別將 name, mod, version 三個變量的解析邏輯封裝在裏面。而在每個大括號裏面,err 變量的作用域就完全侷限在括號中了,每次都可以直接使用 := 來創建一個新的 err 並處理它,不需要額外思考這個 err 變量是否前面已經創建過了。

如果你自己觀察,大括號在代碼語義上還有一個好處,就是歸類和展示。歸類的意思是,這個大括號裏面的變量和邏輯是一個完整的部分,他們內部創建的變量不會泄漏到外部。這個等於等於告訴後續的閱讀者,你在閱讀的時候,如果對這個邏輯不感興趣,不閱讀裏面的內容,而如果你感興趣的話,可以進入裏面進行閱讀。基本上所有 IDE 都支持對大括號封裝的內容進行壓縮,我使用 Goland,壓縮後,我的命令行的主體邏輯就更清晰了。

所以使用大括號,結合 IDE,你的代碼的可讀性能得到很大的提升。

總結

文章中總結了四個 golang 中常用的寫法

這幾種寫法和注意事項是在工作過程和閱讀開源項目中的一些總結和經驗,每個經驗都是對應爲了解決不同的問題。

雖然說 golang 已經對代碼做了不少的規範和優化,但是好的代碼和不那麼好的代碼是有一些差距的,這些寫法優化點就是其中一部分。本文列出的只是四個點,當然還有很多類似的 golang 寫法優化點,相信大家在工作生活中也能遇到不少,只要大家平時能多思考多總結多動手,也能積攢出屬於自己的一本小小的優化手冊的。

來自公衆號:軒脈刃的刀光劍影。

軒脈刃的刀光劍影 工作生活中遇到的日常點滴記錄,或許有技術筆記,或許有日常思考。

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