《Go 語言精進之路》筆記

最近看完了這本書,又想撿起來寫文這個輸出型的愛好,所以寫篇筆記整理一下閱讀這本書中的查漏補缺。

目錄

for range 的坑 & 閉坑方式 & go1.22 規避

for range 有兩個常見的坑,閉包延遲和指針類型作用域延遲,先來看閉包延遲

func demo1() {
    var m = [...]int{1, 2, 3, 4, 5}

    for i, v := range m {
        go func() {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }()
    }

    time.Sleep(time.Second * 10)
}

// 輸出結果
4 55555

爲啥會都輸出 4 5 呢,在用:= 聲明時,i, v 會在 for range 循環中被重用,即:

for i, v := range m 
// 等價於
i, v := 0
for i, v = range m

而在上面例子中,新起協程創建的閉包函數里面引用 i,v 的值,i、v 的值在循環和閉包中是重用的,而 sleep 函數執行完後,各協程才能打印結果,此時循環已經執行完畢,所以最終輸出時輸出最後一個循環的值:4, 5。

想解決閉包延遲也很簡單,減小變量作用域,即:循環時傳入參數即可

for i, v := range m {
    go func(i, v int) {
      time.Sleep(time.Second * 3)
      fmt.Println(i, v)
    }(i, v)
}

再來看指針類型作用域延遲的坑:

func main() {
  s := []int{1, 2, 3}
  var ss []*int

  // 等價於 var v int 
  // for _, v = range s ...
  for _, v := range s { 
    ss = append(ss, &v)
  }

  for _, v := range ss {
    fmt.Println(*v) 
  }
}
//  1.19.5 輸出
3
3
3

同樣的問題,由於 v 在 for range 循環中每次都會被重用,所以這段代碼添加的是 s 的最後一個值的指針,最終統一輸出 3,這個問題相信每一個 go 程序員都遇到過,在 1.22 之前解決方案是:

for _, v := range s { 
    v := v 
    ss = append(ss, &v)
  }

在 go1.22 中,go 解決了這個問題,不需要再添加 v:=v 這樣改變作用域的聲明,對於 1.22 之前的 1.21 版本,可以用這個參數來看結果:

GOEXPERIMENT=loopvar go run main.go

defer 使用中的注意點

之前只記得 defer 使用時是按照後進先出的順序執行的,這裏知道一個新的點:defer 函數中有參數時,會先計算參數函數:

func trace(s string) string {
  fmt.Println("entering:", s)
  return s
}

func un(s string) {
  fmt.Println("leaving:", s)
}

func b() {
  defer un(trace("b"))   // trace("b")會被調用
  fmt.Println("in b")
}

func main() {
  b()
}


// 輸出結果:
entering: b
in b
leaving: b

defer 的函數 un(trace("b")) 中的 trace("b") 會最先被執行

函數作爲一等公民的經典應用

很早之前 http 源碼時,被 HTTP 函數的 wrapper 的操作疑惑了很久才搞清楚,此書中對此也有講解,回顧一下:

func greeting(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Welcome, Gopher!\n")
}

func main() {
  http.ListenAndServe(":8080", http.HandlerFunc(greeting))
}

對於 ListenAndServe 函數,它接受一個 http.Handler 的接口類型作爲參數:

// $GOROOT/src/net/http/server.go
func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}


// $GOROOT/src/net/http/server.go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

此接口有一個方法 ServeHTTP,入參是 (http.ResponseWriter, *http.Request)。與 HTTP 請求處理函數 greeting 的參數一致。但這裏不能直接將 greeting 作爲參數值傳入 ListenAndServe,因爲其並沒有實現 http.Handler 的 ServeHTTP 方法,但是在源碼中 HandlerFunc 實現了 ServeHTTP 方法:

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
  f(w, r)
}

而 http.HandlerFunc(greeting) 這條語句是因爲兩個函數的簽名一致,所以可以做類型轉換,就好像:

http.HandlerFunc(greeting)
等價於:
type MyInt int
var x int = 5
y := MyInt(x)

變長參數函數在對象初始化時的妙用

在日常開發中,我們常常需要會一些帶很多參數的初始化對象函數,比如連接 db 時,在初始化客戶端時需要指定其 addr、port、readTimeout、writeTimeout 等參數,現在來看一下如何用變長參數函數一步一步優化一個初始化新對象的函數。

書中舉了個裝修的例子,每個人想要的裝修風格不一樣,所以在初始化時會提供一些選項,於是最簡單的 Newxx 函數,就是把所有參數的值傳進去:

type FinishedHouse struct {
    style                  int    // 0: Chinese; 1: American; 2: European
    centralAirConditioning bool   // true或false
    floorMaterial          string  // "ground-tile""wood"
    wallMaterial           string // "latex" "paper""diatom-mud"
}

func NewFinishedHouse(style int, centralAirConditioning bool,
    floorMaterial, wallMaterial string) *FinishedHouse {

    h := &FinishedHouse{
        style:                  style,
        centralAirConditioning: centralAirConditioning,
        floorMaterial:          floorMaterial,
        wallMaterial:           wallMaterial,
    }

    return h
}

func main() {
    fmt.Printf("%+v\n", NewFinishedHouse(0, true, "wood""paper"))
}

這樣寫的缺點是 NewFinishedHouse 拓展性差,如果現在要新增一個參數,且這個函數是可導出的接口,發佈之後就變成變成 api 的一部分,此時爲了兼容只能寫類似於 NewFinishedHouseWithxxx 的新 api。

稍微優化一下,可以用結構體來封裝參數:

type FinishedHouse struct {
    style                  int    // 0: Chinese; 1: American; 2: European
    centralAirConditioning bool   // true或false
    floorMaterial          string  // "ground-tile""wood"
    wallMaterial           string // "latex" "paper""diatom-mud"
}

type Options struct {
    Style                  int    // 0: Chinese; 1: American; 2: European
    CentralAirConditioning bool   // true或false
    FloorMaterial          string // "ground-tile""wood"
    WallMaterial           string // "latex" "paper""diatom-mud"
}

func NewFinishedHouse(options *Options) *FinishedHouse {
    // 如果options爲nil,則使用默認的風格和材料
    var style int = 0
    var centralAirConditioning = true
    var floorMaterial = "wood"
    var wallMaterial = "paper"

    if options != nil {
        style = options.Style
        centralAirConditioning = options.CentralAirConditioning
        floorMaterial = options.FloorMaterial
        wallMaterial = options.WallMaterial
    }

    h := &FinishedHouse{
        style:                  style,
        centralAirConditioning: centralAirConditioning,
        floorMaterial:          floorMaterial,
        wallMaterial:           wallMaterial,
    }

    return h
}

func main() {
    fmt.Printf("%+v\n", NewFinishedHouse(nil)) // 使用默認值
    fmt.Printf("%+v\n", NewFinishedHouse(&Options{
        Style:                  1,
        CentralAirConditioning: false,
        FloorMaterial:          "ground-tile",
        WallMaterial:           "paper",
    }))
}

當我們用結構體把參數封裝好,當有添加新的參數時,只需要在結構體中新增參數,不需要修改函數的參數了;但是這種方式的缺點是,每次傳遞參數 options 時,都要爲所有字段進行顯式複製,並沒有實現默認值的操作,於是繼續優化:

functional option 初始化模式:

type FinishedHouse struct {
    style                  int    // 0: Chinese; 1: American; 2: European
    centralAirConditioning bool   // true或false
    floorMaterial          string  // "ground-tile""wood"
    wallMaterial           string // "latex""paper""diatom-mud"
}

type Option func(*FinishedHouse)

func NewFinishedHouse(options ...Option) *FinishedHouse {
    h := &FinishedHouse{
        // default options
        style:                  0,
        centralAirConditioning: true,
        floorMaterial:          "wood",
        wallMaterial:           "paper",
    }

    for _, option := range options {
        option(h)
    }

    return h
}

func WithStyle(style int) Option {
    return func(h *FinishedHouse) {
        h.style = style
    }
}

func WithFloorMaterial(material string) Option {
    return func(h *FinishedHouse) {
        h.floorMaterial = material
    }
}

func WithWallMaterial(material string) Option {
    return func(h *FinishedHouse) {
        h.wallMaterial = material
    }
}

func WithCentralAirConditioning(centralAirConditioning bool) Option {
    return func(h *FinishedHouse) {
        h.centralAirConditioning = centralAirConditioning
    }
}

func main() {
    fmt.Printf("%+v\n", NewFinishedHouse()) // 使用默認選項
    fmt.Printf("%+v\n", NewFinishedHouse(WithStyle(1),
        WithFloorMaterial("ground-tile"),
        WithCentralAirConditioning(false)))
}

在這個版本中,NewFinishedHouse 內部有默認初始化邏輯,如果用戶想傳入個性化的值,可以通過調用變長函數參數去執行初始化,而由於函數參數是變長的,後續也可以隨意拓展傳入函數參數的個數。我之前在其他源碼中見到了這種初始化方式,驚訝於代碼還能這麼寫,這次又看到了這種用法,還看到是怎麼一步步演變而來的,於是記錄一下。

接口在單測中的應用

在寫單測時,遇到一些需要進行 rpc 操作的場景,很多時候受限於環境或者功能的限制,並沒有辦法真的進行真實的 rpc 操作,這種場景可以用 monkey patch 對 rpc 操作進行模擬。

但如果 api 有定義 rpc 類型的接口,就可以在單測中寫改接口的實現方式來實現僞 rpc 了;如果沒有用接口方式實現,應在代碼編寫就注意這一點,書中同樣列舉一個發郵件的例子:

package mail

import (
    "net/smtp"
    email "github.com/jordan-wright/email"
)

const DISCLAIMER = `xx`

func attachDisclaimer(content string) string {
    return content + "\n\n" + DISCLAIMER
}

func SendMailWithDisclaimer(subject, from string, to []string,
    content string, mailserver string,
    a smtp.Auth) error {
    e := email.NewEmail()
    e.From = from
    e.To = to
    e.Subject = subject
    e.Text = []byte(attachDisclaimer(content))
    return e.Send(mailserver, a)
}

如果要對 SendMailWithDisclaimer 寫單側,單測中需要提供很多郵件信息:

package mail_test

import (
    "net/smtp"
    "testing"
    mail "github.com/bigwhite/mail"
)

func TestSendMail(t *testing.T) {
    err := mail.SendMailWithDisclaimer("gopher mail test v1",
        "YOUR_MAILBOX",
        []string{"DEST_MAILBOX"},
        "hello, gopher",
        "smtp.163.com:25",
        smtp.PlainAuth("""YOUR_EMAIL_ACCOUNT""YOUR_EMAIL_PASSWD!""smtp.163.comv))
    if err != nil {
        t.Fatalf("want: nil, actual: %s\n", err)
    }
}

可以用接口的方式改造,這樣就可以分別實現主流程代碼和不需要發郵件的單測代碼:

type MailSender interface {
    Send(subject, from string, to []string, content string, mailserver string, a smtp.Auth) error
}

func SendMailWithDisclaimer(sender MailSender, subject, from string,
    to []string, content string, mailserver string, a smtp.Auth) error {
    return sender.Send(subject, from, to, attachDisclaimer(content), mailserver, a)
}

接口改造後的單測寫法:

package mail_test

import (
    "net/smtp"
    "testing"
    mail "github.com/bigwhite/mail"
)

type FakeEmailSender struct {
    subject string
    from    string
    to      []string
    content string
}

func (s *FakeEmailSender) Send(subject, from string,
    to []string, content string, mailserver string, a smtp.Auth) error {
    s.subject = subject
    s.from = from
    s.to = to
    s.content = content
    return nil
}

func TestSendMailWithDisclaimer(t *testing.T) {
    s := &FakeEmailSender{}
    err := mail.SendMailWithDisclaimer(s, "gopher mail test v2",
        "YOUR_MAILBOX",
        []string{"DEST_MAILBOX"},
        "hello, gopher",
        "smtp.163.com:25",
        smtp.PlainAuth("""YOUR_EMAIL_ACCOUNT""YOUR_EMAIL_PASSWD!""smtp.163.com"))
    if err != nil {
        t.Fatalf("want: nil, actual: %s\n", err)
        return
    }

    want := "hello, gopher" + "\n\n" + mail.DISCLAIMER
    if s.content != want {
        t.Fatalf("want: %s, actual: %s\n", want, s.content)
    }
}

總結

這本書還是有很多讓人眼前一亮的地方的,是一本查漏補缺的書,有興趣的讀者可以買一本看看

參考

https://book.douban.com/subject/35720728/

https://colobu.com/2023/12/17/Less-Error-Prone-Loop-Variable-Scopin/

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