cover

In-depth Understanding of Go Comparable Type

sorcererxwβ€’

Introduction

In the Go reflect 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 indicates whether a type can be directly compared using operators. Go spec lists all comparable types, dividing comparability into two dimensions (if the requirements are not met, it will directly report an error during compilation):

  • Comparable: Can be compared using == and !=, it's either black or white.
  • Ordered: You can use > >= < <= for size comparison, which has a clear concept of size.

I have briefly organized the conventions of all Go built-in types:

TypeComparableOrderedDescription
Booleanβœ…βŒ
Integerβœ…βœ…
Floatβœ…βœ…
Complexβœ…βŒCompares the real and imaginary parts separately, if both are equal then the two complex numbers are equal. If you need to compare sizes, developers need to compare the real and imaginary parts separately.
Stringβœ…βœ…Compares based on bytes one by one.
Pointerβœ…βŒIf two pointers point to the same object or both are nil, then they are equal.
Channelβœ…βŒSimilar to Pointer, two Channel variables are only equal when both are nil, or when they point to the same Channel.
Interfaceβœ…βŒTwo interfaces are equal only when their Type and Value values are equal at the same time.
Struct⚠️❌Only when all members inside the Struct are Comparable, is the Struct Comparable. If two structs are of the same type, and all non-empty member variables are equal, then they are equal.
Array⚠️❌Only when the members are Comparable, is the Array Comparable. If every element in the two Arrays is equal one by one, then the two Arrays are equal.
Map❌❌
Slice❌❌
Func❌❌

As can be seen from the above, most types in Go can be compared with each other using operators, with the sole exceptions being Slice, Map, and Func. Also, whether container types like Struct and Array are Comparable depends on the types of their members.

Internal Implementation

Knowing the syntax conventions, we can 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
}

It's quite simple, in fact, each type is equipped with an equal comparison function, and if this function exists, the type 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
}

Therefore, if you want to know the equal of a certain type, you need to refer to the source code of the corresponding type. The comparison function of the corresponding type can be found through compiling SSA.

For example, the specific implementation of the equal function for interface can be seen under go/src/runtime/alg.go:

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)
}

Practical Pitfalls and Applications

After understanding the above settings, we can comprehend many errors we encounter during development.

errors.Is

We often define the following types when defining errors within 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 often, after receiving an error externally, we use errors.Is to determine the type of error:

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

However, you will find that the above judgment is always false. Let's take a look at 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
		}
	}
}

As we can see, this is a recursive process on the error tree. The termination condition for the truth 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.

As described above, if this constraint is not added, it will cause a panic.

So, if we put a map into an error struct, it makes the error incomparable, and it can never be successfully compared.

The solution is also simple, which is to define the Error as a pointer type:

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

The comparison of pointer types only needs to check whether they point to the same object, and then they can be compared smoothly.

(*Type)(nil) β‰  nil

This is one of the entries in the Go FAQ:

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 this? Because error is an interface, as we can see above, the comparison between interfaces requires that both the Type and Value of the two are equal:

  • The nil within the language can be understood as an interface with both Type and Value being empty.
  • Although the returned p in the code has an empty Value, its Type is *MyError.

So, p!=nil.

The correct code should be like this:

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

This issue does not only occur when throwing errors, but also needs to be taken into account in any scenario that returns an interface.

Context Value Key

Go's Context can store some global variables, which are stored in a tree-like structure. Each time a value is retrieved, it will traverse from the current node all the way to the root node, searching for the corresponding Key:

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

So, there might be a situation where the Key of a child node is the same as one of its parent nodes, leading to the Value being incorrectly overwritten. For example:

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

Since Context is transparent throughout the entire chain, no one can guarantee whether a Key will be overwritten at some layer. The essence of this problem is: when the type of Key is Integer/Float/String/Complex, it's too easy to "forge" a Key with the same value. Therefore, we can utilize the characteristics of Go Comparable to choose a type that cannot be "forged" as the Key. Two recommended elegant methods are:

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

    In this way, apart from the functions within the package, no other code can construct the same pointer.

  • Struct Type

    From the above, we know that as long as the types of structs are the same and the internal values are equal, we can directly use == to determine equality. Therefore, we can directly use structs as Keys.

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

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

    We know that an empty struct does not occupy memory, which, compared to a pointer type Key, can reduce memory overhead.