Skip to content

Commit

Permalink
Add cmpopts.EquateComparable
Browse files Browse the repository at this point in the history
This helper function makes it easier to specify that comparable types
are safe to directly compare with the == operator in Go.

The API does not use generics as it follows existing options like
cmp.AllowUnexported, cmpopts.IgnoreUnexported, or cmpopts.IgnoreTypes.

While generics provides type safety, the user experience is not as nice.
Our current API allows multiple types to be specified:
	cmpopts.EquateComparable(netip.Addr{}, netip.Prefix{})
While generics would not allow variadic arguments:
	cmpopts.EquateComparable[netip.Addr]()
	cmpopts.EquateComparable[netip.Prefix]()

Fixes #339
  • Loading branch information
dsnet committed Aug 31, 2023
1 parent e250a55 commit d52126c
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 1 deletion.
29 changes: 29 additions & 0 deletions cmp/cmpopts/equate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package cmpopts

import (
"errors"
"fmt"
"math"
"reflect"
"time"
Expand Down Expand Up @@ -154,3 +155,31 @@ func compareErrors(x, y interface{}) bool {
ye := y.(error)
return errors.Is(xe, ye) || errors.Is(ye, xe)
}

// EquateComparable returns a [cmp.Option] that determines equality
// of comparable types by directly comparing them using the == operator in Go.
// The types to compare are specified by passing a value of that type.
// This option should only be used on types that are documented as being
// safe for direct == comparison. For example, [net/netip.Addr] is documented
// as being semantically safe to use with ==, while [time.Time] is documented
// to discourage the use of == on time values.
func EquateComparable(typs ...interface{}) cmp.Option {
types := make(typesFilter)
for _, typ := range typs {
switch t := reflect.TypeOf(typ); {
case !t.Comparable():
panic(fmt.Sprintf("%T is not a comparable Go type", typ))
case types[t]:
panic(fmt.Sprintf("%T is already specified", typ))
default:
types[t] = true
}
}
return cmp.FilterPath(types.filter, cmp.Comparer(equateAny))
}

type typesFilter map[reflect.Type]bool

func (tf typesFilter) filter(p cmp.Path) bool { return tf[p.Last().Type()] }

func equateAny(x, y interface{}) bool { return x == y }
31 changes: 31 additions & 0 deletions cmp/cmpopts/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"io"
"math"
"net/netip"

Check failure on line 13 in cmp/cmpopts/util_test.go

View workflow job for this annotation

GitHub Actions / test (1.15.x, ubuntu-latest)

package net/netip is not in GOROOT (/opt/hostedtoolcache/go/1.15.15/x64/src/net/netip)

Check failure on line 13 in cmp/cmpopts/util_test.go

View workflow job for this annotation

GitHub Actions / test (1.16.x, ubuntu-latest)

package net/netip is not in GOROOT (/opt/hostedtoolcache/go/1.16.15/x64/src/net/netip)

Check failure on line 13 in cmp/cmpopts/util_test.go

View workflow job for this annotation

GitHub Actions / test (1.17.x, ubuntu-latest)

package net/netip is not in GOROOT (/opt/hostedtoolcache/go/1.17.13/x64/src/net/netip)
"reflect"
"strings"
"sync"
Expand Down Expand Up @@ -676,6 +677,36 @@ func TestOptions(t *testing.T) {
opts: []cmp.Option{EquateErrors()},
wantEqual: false,
reason: "AnyError is not equal to nil value",
}, {
label: "EquateComparable",
x: []struct{ P netip.Addr }{
{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
{netip.AddrFrom4([4]byte{1, 2, 3, 5})},
{netip.AddrFrom4([4]byte{1, 2, 3, 6})},
},
y: []struct{ P netip.Addr }{
{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
{netip.AddrFrom4([4]byte{1, 2, 3, 5})},
{netip.AddrFrom4([4]byte{1, 2, 3, 6})},
},
opts: []cmp.Option{EquateComparable(netip.Addr{})},
wantEqual: true,
reason: "equal because all IP addresses are the same",
}, {
label: "EquateComparable",
x: []struct{ P netip.Addr }{
{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
{netip.AddrFrom4([4]byte{1, 2, 3, 5})},
{netip.AddrFrom4([4]byte{1, 2, 3, 6})},
},
y: []struct{ P netip.Addr }{
{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
{netip.AddrFrom4([4]byte{1, 2, 3, 7})},
{netip.AddrFrom4([4]byte{1, 2, 3, 6})},
},
opts: []cmp.Option{EquateComparable(netip.Addr{})},
wantEqual: false,
reason: "not equal because second IP address is different",
}, {
label: "IgnoreFields",
x: Bar1{Foo3{&Foo2{&Foo1{Alpha: 5}}}},
Expand Down
4 changes: 3 additions & 1 deletion cmp/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ func (validator) apply(s *state, vx, vy reflect.Value) {
if t := s.curPath.Index(-2).Type(); t.Name() != "" {
// Named type with unexported fields.
name = fmt.Sprintf("%q.%v", t.PkgPath(), t.Name()) // e.g., "path/to/package".MyType
if _, ok := reflect.New(t).Interface().(error); ok {
if t.Comparable() {
help = "consider using cmpopts.EquateComparable to compare comparable Go types"
} else if _, ok := reflect.New(t).Interface().(error); ok {
help = "consider using cmpopts.EquateErrors to compare error values"
}
} else {
Expand Down

0 comments on commit d52126c

Please sign in to comment.