Go 每日一庫之 go-cmp

語言: CN / TW / HK

簡介

我們時常有比較兩個值是否相等的需求,最直接的方式就是使用 == 操作符,其實 == 的細節遠比你想象的多,我在 深入理解 Go 之 == 中有詳細介紹,有興趣去看看。但是直接用 == ,一個最明顯的弊端就是對於指標,只有兩個指標指向同一個物件時,它們才相等,不能進行遞迴比較。為此, reflect 包提供了一個 DeepEqual ,它可以進行遞迴比較。但是相對的, reflect.DeepEqual 不夠靈活,無法提供選項實現我們想要的行為,例如允許浮點數誤差。所以今天的主角 go-cmp 登場了。 go-cmp 是 Google 開源的比較庫,它提供了豐富的選項。 最初定位是用在測試中。

感謝 thinkgos 的推薦!

快速使用

先安裝:

$ go get github.com/com/google/go-cmp/cmp

後使用:

package main

import (
  "fmt"

  "github.com/google/go-cmp/cmp"
)

type Contact struct {
  Phone string
  Email string
}

type User struct {
  Name    string
  Age     int
  Contact *Contact
}

func main() {
  u1 := User{Name: "dj", Age: 18}
  u2 := User{Name: "dj", Age: 18}

  fmt.Println("u1 == u2?", u1 == u2)
  fmt.Println("u1 equals u2?", cmp.Equal(u1, u2))

  c1 := &Contact{Phone: "123456789", Email: "dj@example.com"}
  c2 := &Contact{Phone: "123456789", Email: "dj@example.com"}

  u1.Contact = c1
  u2.Contact = c1
  fmt.Println("u1 == u2 with same pointer?", u1 == u2)
  fmt.Println("u1 equals u2 with same pointer?", cmp.Equal(u1, u2))

  u2.Contact = c2
  fmt.Println("u1 == u2 with different pointer?", u1 == u2)
  fmt.Println("u1 equals u2 with different pointer?", cmp.Equal(u1, u2))
}

上面的例子中,我們將 ==cmp.Equal 放在一起做個比較:

  • 在指標型別的欄位 Contact 未設定時, u1 == u2cmp.Equal(u1, u2) 都返回 true
  • 兩個結構的 Contact 欄位都指向同一個物件時, u1 == u2cmp.Equal(u1, u2) 都返回 true
  • 兩個結構的 Contact 欄位指向不同的物件時,儘管這兩個物件包含相同的內容, u1 == u2 也返回了 false 。而 cmp.Equal(u1, u2) 可以比較指標指向的內容,從而返回 true

以下是執行結果:

u1 == u2? true
u1 equals u2? true
u1 == u2 with same pointer? true
u1 equals u2 with same pointer? true
u1 == u2 with different pointer? false
u1 equals u2 with different pointer? true

高階選項

未匯出欄位

預設情況下, cmp.Equal() 函式不會比較未匯出欄位(即欄位名首字母小寫的欄位)。遇到未匯出欄位, cmp.Equal() 直接 panic 。這一點與 reflect.DeepEqual() 有所不同,後者也會比較未匯出的欄位。

我們可以使用 cmdopts.IgnoreUnexported 選項忽略未匯出欄位,也可以使用 cmdopts.AllowUnexported 選項指定某些型別的未匯出欄位需要比較。

package main

import (
  "fmt"

  "github.com/google/go-cmp/cmp"
)

type Contact struct {
  Phone string
  Email string
}

type User struct {
  Name    string
  Age     int
  contact *Contact
}

func main() {
  c1 := &Contact{Phone: "123456789", Email: "dj@example.com"}
  c2 := &Contact{Phone: "123456789", Email: "dj@example.com"}

  u1 := User{"dj", 18, c1}
  u2 := User{"dj", 18, c2}

  fmt.Println("u1 equals u2?", cmp.Equal(u1, u2))
}

執行上面的程式碼會 panic ,因為 cmd.Equal() 比較的型別中有未匯出欄位 contact 。我們先使用 cmdopts.IngoreUnexported 忽略未匯出欄位:

fmt.Println("u1 equals u2?", cmp.Equal(u1, u2, cmpopts.IgnoreUnexported(User{})))

我們在 cmp.Equal() 的呼叫中添加了選項 cmpopts.IgnoreUnexported ,選項引數傳入 User{} 表示忽略 User 的直接未匯出欄位。匯出欄位中的未匯出欄位是不會被忽略的,除非顯示指定該型別。 如果我們將 User 稍作修改:

type Address struct {
  Province string
  city     string
}

type User struct {
  Name    string
  Age     int
  Address Address
}

func main() {
  u1 := User{"dj", 18, Address{}}
  u2 := User{"dj", 18, Address{}}

  fmt.Println("u1 equals u2?", cmp.Equal(u1, u2, cmpopts.IgnoreUnexported(User{})))
}

注意, city 欄位未匯出,這種情況下,使用 cmpopts.IngoreUnexported(User{}) 還是會 panic ,因為 cityAddress 中的未匯出欄位,而非 User 的直接欄位。

我們也可以使用 cmdopts.AllowUnexported(User{}) 表示需要比較 User 的未匯出欄位:

fmt.Println("u1 equals u2?", cmp.Equal(u1, u2, cmp.AllowUnexported(User{})))

浮點數比較

我們知道,計算機中浮點數的表示是不精確的,如果涉及到運算,可能會產生誤差累計。此外,還有一個特殊的浮點數 NaN (Not a Number), 它與任何浮點數都不等,包括它自己 。這樣,有時候會出現一些反直覺的結果:

package main

import (
  "fmt"
  "math"

  "github.com/google/go-cmp/cmp"
)

type FloatPair struct {
  X float64
  Y float64
}

func main() {
  p1 := FloatPair{X: math.NaN()}
  p2 := FloatPair{X: math.NaN()}
  fmt.Println("p1 equals p2?", cmp.Equal(p1, p2))

  f1 := 0.1
  f2 := 0.2
  f3 := 0.3
  p3 := FloatPair{X: f1 + f2}
  p4 := FloatPair{X: f3}
  fmt.Println("p3 equals p4?", cmp.Equal(p3, p4))

  p5 := FloatPair{X: 0.1 + 0.2}
  p6 := FloatPair{X: 0.3}
  fmt.Println("p5 equals p6?", cmp.Equal(p5, p6))
}

執行程式,輸出:

p1 equals p2? false
p3 equals p4? false
p5 equals p6? true

是不是很反直覺? NaN 不等於 NaN0.1 + 0.2 竟然不等於 0.3 !前者是由於標準的規定,後者是浮點數的表示不精確導致的計算誤差。

奇怪的是第三組表示,為什麼直接用字面量運算就不會導致誤差呢?實際上,在 Go 語言中這些字面量的運算直接是在編譯器完成的,可以做到精確。如果先賦值給浮點型別的變數,就像第 2 組所示,受限於變數的儲存空間,就會存在誤差。

關於這一點,我這裡再順帶介紹一個知識點。我們都知道使用 const 定義常量時可以不指定型別,這種常量被稱為無型別的常量,它的值可以超出正常數值的表示範圍,可以相互進行的運算。 只是不能賦值給超過其型別表示範圍的普通變數

package main

import "fmt"

const (
  _  = 1 << (10 * iota)
  KB // 1024
  MB // 1048576
  GB // 1073741824
  TB // ‭1099511627776‬
  PB // ‭1125899906842624‬
  EB // ‭1152921504606846976‬
  ZB // ‭1180591620717411303424‬
  YB // ‭1208925819614629174706176‬
)

func main() {
  // constant ‭1180591620717411303424‬ overflows int
  // fmt.Println(ZB)

  // constant 1208925819614629174706176 overflows uint64
  // var mem uint64 = YB

  fmt.Println(YB / ZB)
}

後面 ZBYB 都已經超出了 uint64 的表示範圍。直接使用時,如 fmt.Println(ZB) 編譯器會自動將其轉為 int 型別,但是它的值超出了 int 的表示範圍,所以編譯報錯。賦值時也是如此。

go-cmp 提供比較浮點數的選項,我們希望兩個 NaN 的比較返回 true ,兩個浮點數相差不超過一定範圍就認為它們相等:

  • cmpopts.EquateNaNs() :兩個 NaN 比較,返回 true
  • cmpopts.EquateApprox(fraction, margin) :這個選項有兩個引數,第二個引數比較好理解,如果兩個浮點數的差的絕對值小於 margin 則認為它們相等。第一個引數的含義是取兩個數絕對值的較小者,乘以 fraction ,如果兩個數的差的絕對值小於這個數即 |x-y| ≤ max(fraction*min(|x|, |y|), margin) ,則認為它們相等。如果 fractionmargin 同時設定, 只需要滿足一個就行了

例如:

type FloatPair struct {
  X float64
  Y float64
}

func main() {
  p1 := FloatPair{X: math.NaN()}
  p2 := FloatPair{X: math.NaN()}
  fmt.Println("p1 equals p2?", cmp.Equal(p1, p2, cmpopts.EquateNaNs()))

  f1 := 0.1
  f2 := 0.2
  f3 := 0.3
  p3 := FloatPair{X: f1 + f2}
  p4 := FloatPair{X: f3}
  fmt.Println("p3 equals p4?", cmp.Equal(p3, p4, cmpopts.EquateApprox(0.1, 0.001)))
}

執行輸出:

p1 equals p2? true
p3 equals p4? true

Nil

預設情況下,如果一個切片變數值為 nil ,另一個是使用 make 建立的長度為 0 的切片,那麼 go-cmp 認為它們是不等的。同樣的,一個 map 變數值為 nil ,另一個是使用 make 建立的長度為 0 的 map ,那麼 go-cmp 也認為它們不等。我們可以指定 cmpopts.EquateEmpty 選項,讓 go-cmp 認為它們相等:

func main() {
  var s1 []int
  var s2 = make([]int, 0)

  var m1 map[int]int
  var m2 = make(map[int]int)

  fmt.Println("s1 equals s2?", cmp.Equal(s1, s2))
  fmt.Println("m1 equals m2?", cmp.Equal(m1, m2))

  fmt.Println("s1 equals s2 with option?", cmp.Equal(s1, s2, cmpopts.EquateEmpty()))
  fmt.Println("m1 equals m2 with option?", cmp.Equal(m1, m2, cmpopts.EquateEmpty()))
}

切片

預設情況下,兩個切片只有當長度相同,且對應位置上的元素都相等時, go-cmp 才認為它們相等。如果,我們想要實現無序切片的比較(即只要兩個切片包含相同的值就認為它們相等),可以使用 cmpopts.SortedSlice 選項先對切片進行排序,然後再進行比較:

func main() {
  s1 := []int{1, 2, 3, 4}
  s2 := []int{4, 3, 2, 1}
  fmt.Println("s1 equals s2?", cmp.Equal(s1, s2))
  fmt.Println("s1 equals s2 with option?", cmp.Equal(s1, s2, cmpopts.SortSlices(func(i, j int) bool { return i < j })))

  m1 := map[int]int{1: 10, 2: 20, 3: 30}
  m2 := map[int]int{1: 10, 2: 20, 3: 30}
  fmt.Println("m1 equals m2?", cmp.Equal(m1, m2))
  fmt.Println("m1 equals m2 with option?", cmp.Equal(m1, m2, cmpopts.SortMaps(func(i, j int) bool { return i < j })))
}

對於 map 來說,由於本身就是無序的,所以 map 比較差不多是下面這種形式。沒有上面的順序問題:

func compareMap(m1, m2 map[int]int) bool {
  if len(m1) != len(m2) {
    return false
  }

  for k, v := range m1 {
    if v != m2[k] {
      return false
    }
  }

  return true
}

cmpopts.SortMaps 會將 map[K]V 型別按照鍵排序,生成一個 []struct{K, V} 的切片,然後逐個比較。

SortSlicesSortMaps 都需要提供一個比較函式 less ,函式必須是 func(T, T) bool 這種形式,切片的元素型別必須可以賦值給 T 型別, map 的鍵也必須可以賦值給 T 型別。

自定義 Equal 方法

對於有些型別來說, go-cmp 內建的比較結果不符合我們的要求,這時我們可以自定義 Equal 方法來比較該型別。例如我們想要表示 IP 地址的字串比較時 127.0.0.1localhost 相等:

package main

type NetAddr struct {
  IP   string
  Port int
}

func (a NetAddr) Equal(b NetAddr) bool {
  if a.Port != b.Port {
    return false
  }

  if a.IP != b.IP {
    if a.IP == "127.0.0.1" && b.IP == "localhost" {
      return true
    }

    if a.IP == "localhost" && b.IP == "127.0.0.1" {
      return true
    }

    return false
  }

  return true
}

func main() {
    a1 := NetAddr{"127.0.0.1", 5000}
    a2 := NetAddr{"localhost", 5000}
    a3 := NetAddr{"192.168.1.1", 5000}

    fmt.Println("a1 equals a2?", cmp.Equal(a1, a2))
    fmt.Println("a1 equals a3?", cmp.Equal(a1, a3))
}

很簡單,只需要給想要自定義比較操作的型別提供一個 Equal() 方法即可,方法接受該型別的引數,返回一個 bool 表示是否相等。如果我們將上面的 Equal() 方法註釋掉,那麼比較輸出都是 false

自定義比較器

如果 go-cmp 預設的行為無法滿足我們的需求,我們可以針對某些型別自定義比較器。我們使用 cmp.Comparer() 傳入比較函式,比較函式必須是 func (T, T) bool 這種形式。所有能轉為 T 型別的值,都會呼叫該函式進行比較。所以如果 T 是介面型別,那麼可能傳給比較函式的引數的實際型別並不相同,只是它們都實現了 T 介面。我們使用 Comparer() 重構一下上面的程式:

type NetAddr struct {
  IP   string
  Port int
}

func compareNetAddr(a, b NetAddr) bool {
  if a.Port != b.Port {
    return false
  }

  if a.IP != b.IP {
    if a.IP == "127.0.0.1" && b.IP == "localhost" {
      return true
    }

    if a.IP == "localhost" && b.IP == "127.0.0.1" {
      return true
    }

    return false
  }

  return true
}

func main() {
  a1 := NetAddr{"127.0.0.1", 5000}
  a2 := NetAddr{"localhost", 5000}

  fmt.Println("a1 equals a2?", cmp.Equal(a1, a2))
  fmt.Println("a1 equals a2 with comparer?", cmp.Equal(a1, a2, cmp.Comparer(compareNetAddr)))
}

這種方式與上面介紹的自定義 Equal() 方法有些類似,但更靈活。有時,我們要自定義比較操作的型別定義在第三方包中,這樣就無法給它定義 Equal 方法。這時,我們就可以採用自定義 Comparer 的方式。

Exporter

從前面的介紹我們知道預設情況下,未匯出欄位會導致 cmp.Equal() 直接 panic 。前面也介紹過兩種方式處理未匯出欄位,這裡再介紹一種方式—— cmp.Exporter 。通過傳入一個函式 func (t reflec.Type) bool ,返回傳入的型別是否比較其未匯出欄位。例如,下面程式碼中,我們指定需要比較型別 User 的未匯出欄位:

type Contact struct {
  Phone string
  Email string
}

type User struct {
  Name    string
  Age     int
  contact Contact
}

func allowUnExportedInType(t reflect.Type) bool {
  if t.Name() == "User" {
    return true
  }

  return false
}

func main() {
  c1 := Contact{Phone: "123456789", Email: "dj@example.com"}
  c2 := Contact{Phone: "123456789", Email: "dj@example.com"}

  u1 := User{"dj", 18, c1}
  u2 := User{"dj", 18, c2}

  fmt.Println("u1 equals u2?", cmp.Equal(u1, u2, cmp.Exporter(allowType)))
}

cmp.Exporter 的使用不多,且可以通過 AllowUnexported 選項來實現。

轉換器

轉換器可以將特定型別的值轉為另一種型別的值。轉換器有很多用法,下面介紹兩種。

忽略欄位

如果我們想忽略結構中的某些欄位,我們可以定義轉換,返回一個不設定這些欄位的物件:

type User struct {
  Name string
  Age  int
}

func omitAge(u User) string {
  return u.Name
}

type User2 struct {
  Name    string
  Age     int
  Email   string
  Address string
}

func omitAge2(u User2) User2 {
  return User2{u.Name, 0, u.Email, u.Address}
}

func main() {
  u1 := User{Name: "dj", Age: 18}
  u2 := User{Name: "dj", Age: 28}

  fmt.Println("u1 equals u2?", cmp.Equal(u1, u2, cmp.Transformer("omitAge", omitAge)))

  u3 := User2{Name: "dj", Age: 18, Email: "dj@example.com"}
  u4 := User2{Name: "dj", Age: 28, Email: "dj@example.com"}

  fmt.Println("u3 equals u4?", cmp.Equal(u3, u4, cmp.Transformer("omitAge", omitAge2)))
}

如果一個型別,我們只關心一個欄位,忽略其它欄位,那麼直接返回這個欄位就行了,如上面的 omitAge 。如果該型別有多個欄位,我們只忽略很少的欄位,我們要返回一個同樣的型別,不設定忽略的欄位即可,如上面的 omitAge2

轉換值

上面我們介紹瞭如何使用自定義 Equal() 方法和 Comparer 比較器的方式來實現 IP 地址的比較。實際上轉換器也可以實現同樣的效果,我們可以將 localhost 轉換為 127.0.0.1

type NetAddr struct {
  IP   string
  Port int
}

func transformLocalhost(a NetAddr) NetAddr {
  if a.IP == "localhost" {
    return NetAddr{IP: "127.0.0.1", Port: a.Port}
  }

  return a
}

func main() {
  a1 := NetAddr{"127.0.0.1", 5000}
  a2 := NetAddr{"localhost", 5000}

  fmt.Println("a1 equals a2?", cmp.Equal(a1, a2, cmp.Transformer("localhost", transformLocalhost)))
}

遇到 IPlocalhost 的物件,將其轉換為 IP127.0.0.1 的物件。

Diff

除了能比較兩個值是否相等, go-cmp 還能彙總兩個值的不同之處,方便我們檢視。上面介紹的選項都可以用在 Diff 中:

type Contact struct {
  Phone string
  Email string
}

type User struct {
  Name    string
  Age     int
  Contact *Contact
}

func main() {
  c1 := &Contact{Phone: "123456789", Email: "dj@example.com"}
  c2 := &Contact{Phone: "123456879", Email: "dj2@example.com"}
  u1 := User{Name: "dj", Age: 18, Contact: c1}
  u2 := User{Name: "dj2", Age: 18, Contact: c2}

  fmt.Println(cmp.Diff(u1, u2))
}

我們著重介紹一下輸出的格式:

main.User{
-  Name: "dj",
+  Name: "dj2",
   Age:  18,
   Contact: &main.Contact{
-    Phone: "123456789",
+    Phone: "123456879",
-    Email: "dj@example.com",
+    Email: "dj2@example.com",
   },
  }

相信使用過 SVN 或對 Linux 的 diff 命令熟悉的童鞋對上面的格式應該不會陌生。我們可以這樣認為,第一個物件為原來的版本,第二個物件為新的版本。這樣上面的輸出我們可以想象成如何將物件從原來的版本變為新版本。沒有字首的行不需要改變,字首為 - 的行表示新版本刪除了這一行,字首 + 表示新版本增加了這一行。

總結

go-cmp 庫大大地方便兩個值的比較操作。原始碼中大量使用我們之前介紹過的 選項模式 ,提供給使用者簡潔、一致的介面。這種設計思想也值得我們學習、借鑑。本文介紹了這是 go-cmp 的一部分內容,還有一些特性如 過濾器 感興趣可自行探索。

大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue:smile:

參考

  1. go-cmp GitHub: https://github.com/google/go-cmp
  2. Go 每日一庫 GitHub: https://github.com/darjun/go-daily-lib

我的部落格: https://darjun.github.io

歡迎關注我的微信公眾號【GoUpUp】,共同學習,一起進步~

分享到: