cover

A Deep Dive into Go Comparable Type

sorcererxwβ€’

Introduction

In the Goreflect package, there is a definition of Comparable for Type:

package reflect

type Type interface {
	// Comparable reports whether values of this type are comparable.
	Comparable() bool
}

As the literal meaning, Comparable means whether a type can be directly compared using operators. Gospec lists all comparable types, which divides comparability into two dimensions (if the requirements are not met, it will report an error directly at compile time):

  • Comparable: can be compared using == and !=, either true or false
  • Ordered: can be compared with >, >=, <, <=, and have a clear concept of size

I have made a simple summary of the conventions for all Go built-in types:

TypeComparableOrderedDescription
Booleanβœ…βŒ
Integerβœ…βœ…
Floatβœ…βœ…
Complexβœ…βŒCompare real and imaginary parts separately, two complex numbers are equal if both parts are equal. If you need to compare the size, you need to compare the real and imaginary parts separately.
Stringβœ…βœ…Compare byte by byte.
Pointerβœ…βŒTwo pointer variables are equal if they point to the same object or both are nil.
Channelβœ…βŒSimilar to Pointer, two Channel variables are equal only when both are nil or point to the same Channel.
Interfaceβœ…βŒTwo interfaces are equal only when their Type and Value are equal at the same time.
Struct⚠️❌A Struct is Comparable only when all its members are Comparable. Two structs are equal if they have the same type and all non-empty member variables are equal.
Array⚠️❌An Array is Comparable only when its members are Comparable. Two Arrays are equal if each element in the two Arrays is equal.
Map❌❌
Slice❌❌
Func❌❌

As we can see above, the vast majority of types in Go can be compared with each other using operators, with the exception of Slice, Map, and Func. The Comparability of container types Struct and Array also depends on the types of their members.

Internal implementation

Now that we know the syntax convention, let's take a look at how reflect specifically determines the Comparable attribute of a variable:

type rtype struct {
	// function for comparing objects of this type
	// (ptr to object A, ptr to object B) -> ==?
	equal func(unsafe.Pointer, unsafe.Pointer) bool
}

func (t *rtype) Comparable() bool {
	return t.equal != nil
}

Simply put, it is to equip each type with an equal comparison function, and if it has this function, it is comparable.

The above rtype structure is included in the memory header of all types:

// emptyInterface is the header for an interface{} value.
type emptyInterface struct {
	typ  *rtype
	word unsafe.Pointer
}

So if you want to know the equal of a type, you need to read the source code of the corresponding type. You can find the comparison function of the corresponding type by compiling SSA.

The specific implementation of the equal function of the interface can be seen in [go/src/runtime/alg.go](https://github.com/golang/go/blob/41d8e61a6b9d8f9db912626eb2bbc535e929fefc/src/runtime/alg.go#L260).

func efaceeq(t *_type, x, y unsafe.Pointer) bool {
	if t == nil {
		return true
	}
	eq := t.equal
	if eq == nil {
		panic(errorString("comparing uncomparable type " + t.string()))
	}
	if isDirectIface(t) { // t.kind == kindDirectIface
		// Direct interface types are ptr, chan, map, func, and single-element structs/arrays thereof.
		// Maps and funcs are not comparable, so they can't reach here.
		// Ptrs, chans, and single-element items can be compared directly using ==.
		return x == y
	}
	return eq(x, y)
}

Real-world pitfalls and applications

Knowing the above settings can help us understand many errors we encounter during development.

errors.Is

We often define the following type when defining errors in a module:

type CustomError struct {
	Metadata map[string]string
	Message string
}

func (c CustomError) Error() string {
		return c.Message
}

var (
	ErrorA = CustomError{Message:"A", Matadata: map[string]string{"Reason":""}}
	ErrorB = CustomError{Message:"B"}
)

func DoSomething() error {
	return ErrorA
}

And when we receive an error externally, we often use errors.Is to judge the error type:

err:=DoSomething()
if errors.Is(err, ErrorA) {
	// handle err
}

But it turns out that the above judgment is always false. Let's study the source code of errors.Is:

func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

	isComparable := reflect.TypeOf(target).Comparable()
	for {
		if isComparable && err == target {
			return true
		}
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
			return true
		}
		if err = errors.Unwrap(err); err == nil {
			return false
		}
	}
}

This is a recursive process on the errortree, the termination condition for the true value is err==target, but the premise is that target itself must be comparable.

A comparison of two interface values with identical dynamic types causes a run-time panic if values of that type are not comparable.

If not, it will cause panic.

So if we put a map into an error struct, it will make this error incomparable and it will never be successfully compared.

The simple solution is to define Error as a pointer type:

var (
	ErrorA = &CustomError{Message:"A", Matadata: map[string]string{"Reason":""}}
	ErrorB = &CustomError{Message:"B"}
)

Pointer types only need to check whether they point to the same object, so they can be compared smoothly.

(*Type)(nil)β‰ nil

This is one of the GoFAQs:

func returnsError() error {
	var p *MyError = nil
	if bad() {
		p = ErrBad
	}
	return p // Will always return a non-nil error.
}

The returned p will never be equal to nil.

Why is that? Because error is an interface, and as we know from above, comparing interfaces requires both their Type and Value to be equal:

  • The comparison rules of Go built-in types
  • Code returns p with empty Value but Type is *MyError

So p!=nil.

In addition to Map, Slice and Func, most types in Go are Comparable, among which Struct and Array depend on the support of member types.

func returnsError() error {
	if bad() {
		return ErrBad
	}
	return nil
}

This issue may occur not only when throwing errors, but also in any scenario that returns an interface.

ContextValueKey

Go's Context can store some global variables, and its storage method is a tree structure. Every time a value is retrieved, it will traverse all the way from the current node to the root node to find the corresponding Key:

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

For example, the value may be overwritten incorrectly because the key of the child node is the same as the key of one of the parent nodes. For example:

ctx = Context.Background()
ctx = context.WithValue(ctx, "key", "123")
ctx = context.WithValue(ctx, "key", "456")
ctx.Value("key") // 456

Since the context is propagated throughout the whole chain, no one can guarantee whether a key will be overwritten by a certain layer. This problem is essentially that: when the type of the key is Integer/Float/String/Complex, it is too easy to "forge" a key with the same value. Then we can use the characteristics of GoComparable to select a type that cannot be "forged" as the key. Two relatively elegant methods are recommended:

  • Pointer types
    var key = byte(0)
    
    ctx = context.WithValue(ctx, &key, "123")
    ctx.Value(&key)

    In this way, except for the functions in the package, no other code can construct the same pointer.

  • Struct type

    Since struct can be directly judged as equal by == as long as the types are the same and the internal values are equal, we can directly use struct as the Key.

    type key struct {}
    
    ctx = context.WithValue(ctx, key{}, "123")
    ctx.Value(key{})

    Similarly, we define struct as private, and the same key cannot be constructed outside the package.

    We know that empty struct does not occupy memory, so compared with pointer type Key, it can reduce memory overhead.