31.4 comparable: The Constraint for == and !=
Alright, let’s talk about comparable. You’ve probably been banging your head against the wall, wondering why you can’t just write a generic function to check if two things are equal. You try this:
func Equal[T any](a, b T) bool {
return a == b
}
And the compiler slaps you down immediately. “Invalid operation: a == b (operator == not defined on T)”. Rude. The reason is simple and, honestly, sensible: Go is a brutally pragmatic language. The == operator doesn’t work for every single type you could possibly shove into a type parameter. What does == mean for a map? Or a function? Or a slice? It’s undefined, so Go just says “nope” rather than letting you stumble into a runtime panic.
This is where comparable comes in. It’s not a type; it’s a predeclared interface constraint. Think of it as a whitelist of types that have the == and != operators defined in a meaningful way. When you use comparable as a constraint, you’re telling the compiler, “Relax, I promise only to send you types that know how to play the equality game.”
What’s Inside the comparable Club?
The comparable constraint includes all comparable types as defined by the Go spec. This is a concrete set:
- All basic types (like
int,float64,string,bool) except… well, there are no exceptions in the basics. They’re all in. - Pointers (like
*int,*MyStruct) - Channels
- Interfaces (but be careful, we’ll get to that)
- Structs whose all fields are also comparable.
- Arrays whose element type is comparable.
Here’s the function we wanted, now actually working:
func Equal[T comparable](a, b T) bool {
return a == b
}
func main() {
fmt.Println(Equal(42, 42)) // true
fmt.Println(Equal("hello", "hi")) // false
type MyStruct struct {
ID int
Name string
}
fmt.Println(Equal(MyStruct{ID: 1}, MyStruct{ID: 1})) // true
}
The compiler is happy now because it can guarantee that whatever concrete type T becomes, it will support ==.
The Notable Exclusions (The Bouncer’s Blacklist)
This is the important part. Who isn’t allowed in the comparable club? The big ones are slices, maps, and functions. This code will not compile:
func CompareSlices[T comparable](a, b []T) bool { // Invalid: []T does not implement comparable
return a == b
}
The compiler error is your friend here. It’s protecting you from yourself. Even if the slice’s element type is comparable ([]int, []string), the slice itself is not. This is because equality for slices is ambiguous. Should it compare the lengths? The capacities? The contents? The memory address? The Go designers decided it was too ambiguous to define at the language level, so they just disallowed it entirely. You have to write your own slice comparison function using reflect.DeepEqual or a loop, which is a pain, but it forces you to be explicit about your intent.
The Interface Trap
Here’s where things get spicy. Interfaces are considered comparable. The operation is defined: two interface values are equal if they have identical dynamic types and identical dynamic values. This sounds fine until you remember that interfaces can hold any value, including non-comparable ones like slices.
This compiles without a peep:
func IsEqualInterface(a, b any) bool {
return a == b
}
func main() {
slice := []int{1, 2, 3}
fmt.Println(IsEqualInterface(slice, slice)) // ?
}
So what happens? This code will compile, but it will panic at runtime: “runtime error: comparing uncomparable type []int”. Yikes. The comparable constraint prevents this by catching it at compile time. If you try to use a non-comparable type with a comparable constraint, it’s a compile-time error. If you use any (which is just interface{}) and then try to compare, you’re back to runtime danger.
This is the key difference: comparable is a compile-time safety check. It ensures the types you’re working with are safe to compare before you even run the code. Using any and then comparing is like skipping the safety check and hoping you don’t blow your foot off.
Best Practices and The Bottom Line
- Prefer
comparableoveranyfor equality: If your function needs to compare values, useT comparable. It makes your function safer and your intentions clearer. It pushes potential errors from runtime back to compile time, which is always a massive win. - You can’t define your own
comparable: Don’t try to be clever and define an interface with==methods. The==operator is a language-level concept and doesn’t work with interface methods. The built-incomparableis magic done by the compiler. - It works with other constraints: You can use it in union constraints. Need a type that is both
comparableand has aString()method? No problem:[T comparable interface { String() string }].
So, comparable is essentially the compiler’s bouncer. It’s there to keep the riff-raff (slices, maps, functions) out of your equality operations, ensuring the party doesn’t get crashed by a runtime panic. It’s a small keyword that provides a huge amount of safety, and you should use it relentlessly whenever you find yourself reaching for == in a generic function.