Generics in Go: A Belated Personal Guide
We'll cover the basics, including the definition of generics, the constraints involved, and type inference
Hey there! I hope you’re doing well.
I’d like to discuss something that has piqued the interest of many Go developers: the introduction of generics in Go 1.18 (yeah I know I’m late).
We’ll cover the basics, including the definition of generics, the constraints involved, and type inference. My aim is to provide you with a solid understanding of this powerful feature while keeping the conversation light and engaging.
Definition
To begin, let’s lay the groundwork for our discussion with a straightforward example. Imagine that prior to Go 1.18, we had two separate functions to handle different data types: Maxf
for float64 and Max
for int.
This example will serve as a basis for understanding the impact of generics in Go.
func Maxf(a, b float64) float64 {
if a > b {
return a
}
return b
}
func Max(a, b int) int {
if a > b {
return a
}
return b
}
By incorporating generics, we can now streamline our code and use just a single function to cover both cases:
func Max[T int | float64](a, b T) T {
if a > b {
return a
}
return b
}
f := Max[int]
f(1, 2) // 2
Now, let’s examine the new concepts introduced in Go 1.18 in relation to generics:
Generic function: A function that utilizes generics like our Max function.
Generic type: A struct that employs generics.
Instantiated type or function: An instance derived from a generic entity, such as
f := Max[int]
in the earlier example.Type parameters: Specified in square brackets, type parameters define the allowable types for a generic entity (e.g.,
[T int | float64]
).Type constraints: These indicate the permitted range of types for a generic entity. In our example,
int | float64
represents a union of the int and float64 types.
Having covered the basics of generics and the new terminology associated with Go generics, we’re now ready to delve into more advanced topics.
Type constraints
In our earlier example, we examined the use of a union type. However, what we really did was define an interface type that encompasses both int and float64 implementations.
type Number interface {
int | float64
}
The type constraints must be an interface{}, also, Go 1.18 introduces any
as an alias to empty interface or interface{}
, it means we can use any types into a generic entity:
type any = interface{}
func PrintAny[T any](a T) {
fmt.Println(a)
}
The change of interface
Prior to Go 1.18, we understood that an interface defines a method set (and interface type) and that any type implementing all of those methods also implements the interface, correct?
This perspective is known as the method set view. However, Go 1.18 introduces a new viewpoint called the type set view. This alternative perspective considers interfaces as defining a set of types that fulfill the interface requirements.
interface {
int | bool | string
}
Both views aim to identify the types that implement an interface.
Mixed Constraints
We utilized the |
token to create a union and define an interface. But have you considered the possibility of an interface that includes both a function and a union?
type NumberWithA interface {
A()
~int | ~float64
}
// or writing like this
type NumberWithA interface {
A(); ~int | ~float64
}
In such a scenario, any type that has a method named A()
and an underlying type of either int or float64 would satisfy this interface. Here's an example:
type Int int
func (i Int) A() {
fmt.Println("A called")
}
// --- generic function
func CallA[A NumberWithA](a A) {
a.A()
}
func main() {
a := Int(1)
CallA(a)
}
Tilde Type
Additionally, Go introduced the ~
(tilde) token to represent all types with underlying types that meet specific requirements. For instance:
interface {
int | bool | ~string
}
type FakeString string
So, this interface is satisfied exclusively by int, bool, or any type that has an underlying type of string (including string itself), such as our previously mentioned FakeString
.
By using this tilde notation, we can effectively express constraints on the underlying types, enabling more precise and flexible control over the types that satisfy a given interface..
More about constraints
Returning to our initial example, let’s revisit the Max function:
func Max[T int | float64](a, b T) T {
if a > b {
return a
}
return b
}
In Go, there’s a built-in package called constraints that provides several predefined types for our convenience. Let's explore what it offers:
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
type Integer interface {
Signed | Unsigned
}
type Float interface {
~float32 | ~float64
}
With this in mind, you can create custom constraint types based on the predefined ones, enhancing the readability of your functions:
type Number interface {
Integer | Float
}
func Max[T Number](a, b T) T {
if a > b {
return a
}
return b
}
Type inference
The Go compiler automatically detects generic types and infers the type parameter by examining the arguments passed to functions:
// instead of
Max[float64](58.5, 39.1)
// do this
Max(58.5, 39.1)
In cases where there are multiple generic types, the Go compiler still manages to infer the type parameters. For example:
func Print[T any, K any](a T, b K) {
fmt.Println(a, b)
}
func main() {
Print("a", 1) // instead of Print[string, int]("a", 1)
}
However, when a generic type is not used as a function argument but rather as the return type, type inference may not work as expected. For example:
func Print[T any, K any](a T) K {
fmt.Println(a)
return *new(K)
}
func TestMe(t *testing.T) {
Print[string, int]("a")
// Print("a") compile error
}
In this case, the compiler is unable to infer the generic types for T
and K
when they are not explicitly specified in the function call, leading to a compile error.