Golang中常用的代碼優化點


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寫法優化點,相信大家在工作生活中也能遇到不少,只要大家平時能多思考多總結多動手,也能積攢出屬於自己的一本小小的優化手冊的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM