Golang中常用的程式碼優化點

軒脈刃發表於2022-03-29

Golang中常用的程式碼優化點

大家好,我是軒脈刃。

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

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

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

1 附加資訊:我們希望錯誤出現的時候能附帶一些描述性的錯誤資訊,甚至於這些資訊是可以巢狀的。

2 附加堆疊:我們希望錯誤不僅僅列印出錯誤資訊,也能列印出這個錯誤的堆疊資訊,讓我們可以知道錯誤的資訊。

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

假設我們有一個專案叫errdemo,他有sub1,sub2兩個子包。sub1和sub2兩個包都有Diff和IoDiff兩個函式。

image-20211219170503931

// 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 中丟擲的。除錯的時候會帶來很大的困擾。

image-20211219171226288

而使用 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)
}

image-20211219171614767

看到,除了"diff error" 的錯誤資訊之外,還將堆疊大衣拿出來了,我們能明確看到是sub2.go中第7行丟擲的錯誤。

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

其中在列印error之前會判斷當前列印的物件是否實現了Formatter介面,這個formatter介面只有一個format方法

image-20211219171930031

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

image-20211219172218939

它實現了Format方法。

image-20211219172234195

在初始化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:

image-20211219173237557

方法2:

image-20211219174112164

我們看到方法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
  • WithAge
  • WithDB

以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物件
  • 遍歷options改造這個foo物件

按照這樣改造之後,我們具體使用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,壓縮後,我的命令列的主體邏輯就更清晰了。

image-20211220095540148

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

總結

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

  • 使用pkg/error而不是官方error庫
  • 在初始化slice的時候儘量補全cap
  • 初始化一個類的時候,如果類的構造引數較多,儘量使用Option寫法
  • 巧用大括號控制變數作用域

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

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

相關文章