Skip to content

Commit

Permalink
Add cmpopts.EquateComparable (#340)
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]()

Bump mininimum supported Go to 1.18 for net/netip type.
Start testing on Go 1.21.

Fixes #339
  • Loading branch information
dsnet committed Aug 31, 2023
1 parent e250a55 commit c3ad843
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 2 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Expand Up @@ -6,7 +6,7 @@ jobs:
test:
strategy:
matrix:
go-version: [1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x]
go-version: [1.18.x, 1.19.x, 1.20.x, 1.21.x]
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
Expand All @@ -19,5 +19,5 @@ jobs:
- name: Test
run: go test -v -race ./...
- name: Format
if: matrix.go-version == '1.20.x'
if: matrix.go-version == '1.21.x'
run: diff -u <(echo -n) <(gofmt -d .)
29 changes: 29 additions & 0 deletions cmp/cmpopts/equate.go
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
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"io"
"math"
"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
2 changes: 2 additions & 0 deletions cmp/options.go
Expand Up @@ -234,6 +234,8 @@ func (validator) apply(s *state, vx, vy reflect.Value) {
name = fmt.Sprintf("%q.%v", t.PkgPath(), t.Name()) // e.g., "path/to/package".MyType
if _, ok := reflect.New(t).Interface().(error); ok {
help = "consider using cmpopts.EquateErrors to compare error values"
} else if t.Comparable() {
help = "consider using cmpopts.EquateComparable to compare comparable Go types"
}
} else {
// Unnamed type with unexported fields. Derive PkgPath from field.
Expand Down

0 comments on commit c3ad843

Please sign in to comment.