Go 每日一庫之 go-cmp

簡介

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

感謝thinkgos的推薦!github

快速使用

先安裝:golang

$ 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放在一塊兒作個比較:less

  • 在指針類型的字段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忽略未導出字段:ui

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

咱們在cmp.Equal()的調用中添加了選項cmpopts.IgnoreUnexported,選項參數傳入User{}表示忽略User的直接未導出字段。導出字段中的未導出字段是不會被忽略的,除非顯示指定該類型。若是咱們將User稍做修改:google

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😄

參考

  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】,共同窗習,一塊兒進步~

相關文章
相關標籤/搜索