An Introduction to Go Generics
Generics in Go aims to solve the problem of writing generic and reusable Go code. We will focus specifically on what Go generics means for the language and go through some concepts specific to generics in Go and how the draft proposal has evolved over the years.
Generics in Go aims to solve the problem of writing generic and reusable Go code. We will focus specifically on what Go generics means for the language and go through some concepts specific to generics in Go and how the draft proposal has evolved over the years.
As a developer or library author, you must have come across a use case where you plan to write a generic function only to discover that there is no standard way of doing it in the Go language. If you are new to Go, this may naturally come as a surprise to you, as the only popular way of handling this problem is to make use of the empty interface type or code generators, which come with their own challenges.
Lack of generics has been frequently mentioned as one of the major problems to fix in language design, as every major strongly-typed language, such as Java, Rust or swift, supports generic programming in one way or another. However, generic design and implementation in Go is quite distinct from the implementation in these languages.
The authors of Go wanted to design a language that was easy to understand, simple to use, devoid of complexities and backwards compatible. Due to these principles, adopting various ideas and approaches to implementing generics has been a huge challenge.
The Go generics proposal, highly debated over the past decade via draft proposals and design documents, is a recent feature addition to the language design slated for the 1.18 release implementation [beta implementation here]. In the initial draft design for the implementation, generic programming meant functions that accept type parameters via defined contracts.
However, the contract approach and its initial implementation have now been replaced entirely due to the syntax complexities they introduced to the language and will not be addressed in this post. The idea of contracts has already been widely discussed and tested in the earlier design draft.
The latest design on adopting the right approach for implementing generic programming, now accepted, introduced a new type of constraint, equivalent to interface types. The design supported adding optional `type parameters` to type and function declarations, wherein the type parameters are constrained by interface types.
What is Go Generic
Go generic entails writing functions and data structures using type parameters with type lists inside interfaces serving as constraints. In this case, constraints are a way of expressing requirements for type parameters.
For standardisation, type parameter lists follow the syntax of regular parameter lists (but in the case of generics, type parameters are enclosed inside square brackets).
With generics, types are factored out of a function or data structure definition, which is then represented in a generic form. A generic Go function may only use operations supported by all the types allowed by the constraint. See the signature of a generic Go function below:
func G[T Constraint](p T) { }
In the above function definition, the type constraint accepts a type parameter list in the same way that an ordinary parameter usually has a type. Now let’s go ahead and see how a function that prints the elements of a slice type (extracted from the design draft) can be applied to support generic programming:
// Print prints the elements of any slice.
func Print(type T)(s []T) {
for _, v := range s {
fmt.Println(v)
}
}
In the above Print function, T represents a type parameter. This type parameter can only be known at the point of the function invocation. Also, the parameter s is an equivalent slice of the type parameter. Let’s look at an example of calling the `Print` func by passing a slice of int below:
Print(int)([]int{1, 2, 3})
// returns:
// 1
// 2
// 3
From the above function call, we have instantiated the `Print` function with a slice of int. Note that before we did that, we passed a type argument, int denoted as Print(int). This is because the Print function expects a slice of int as an argument. Note that type parameter lists can be used in the same way as the regular parameters, and in the function body as well.
For function arguments, we also have the ‘any’ constraint, which is a type constraint that allows any arbitrary type which translates to the constraint of an empty interface{} type in this case. With the ‘any’ constraint, any type of argument is allowed for that parameter when the function is instantiated.
This is because the function does not specify a type parameter constraint at the point where it was defined. Also note that, just like functions, types can also have parameter lists, as shown with the signature below:
type T[T any] []T
Furthermore, interface types used as type constraints support embedding more elements that can be used to limit the set of types that must satisfy the constraint.
In summary, with Go generics, the language capabilities would now allow for optional type parameters for types and functions. By extension, this means that functions would work on an arbitrary data type, where the actual data type is only specified or known when the function is called or instantiated.
Note: If you are interested in the early Go design for generics, do check it out here. These earlier generics proposals have now been superseded because of the latest draft design.
Why Generics
Go is statically typed, which means that a predefined type should be specified for the parameters or arguments we pass to function declarations, as well as for the use of variables. Although, not to forget to mention that the language also offers some level of type inference for us.
In some situations, this can lead to code duplication. Due to these limitations, generic functions and types were introduced to solve the problem of writing functions or types with parameter lists.
According to the design document, the goal of adding generics to the language is simple: to enable writing libraries or functions that would work or run on arbitrary data types or values. It follows that generics design had to explicitly cater for type parameter constraints.
Another frequently requested feature for generics in Go is the ability to write compile-time, type-safe containers (map, slices, channel types) in Go. For example, it is anything but straightforward to write a simple `copy` function that can work on the `map` container type without generics. The only way to do it is to write separate functions with the same signature for all types except those being passed.
Let us consider another generic use case from the Go blog with a `Reverse` function that can work on a slice of either bytes or numbers. Without generics, we would either write two separate functions to handle the use case for string arguments or number arguments, keeping in mind that Go is statically typed.
Let us see how we would write a reverse function that accepts a slice of int:
func ReverseInts(s []int) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
Note that the function above can only accept a slice of int as the function argument when called. This means that, to write an equivalent function that can accept, say, a slice of string, we need to write another function with exactly the same signature but a different type parameter or argument when the function is instantiated. See below:
func ReverseStrings(s []string) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
Of course, this is the problem Go generics tries to solve by letting us write generic functions that can accept type parameters, so we can just write the function once and pass any kind of argument we desire, as long as it satisfies the constraint. The reverse function above can be written in a generic manner as follows:
func Reverse (type Element) (s []Element) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
The first and most important thing that generics would bring to the language is the ability to write functions like `Reverse` above without caring about the `Element` type of the slice passed when the function is instantiated.
The idea is to easily factor out that element type. We can then safely write the function just once, write the tests once and use them however we want instead of duplicating the functions based on type, which can lead to code smells.
Therefore, with Go generics, we can write a function once and use it everywhere. Functions can be made more reusable and can even be extended to other user-defined data types.
In summary, the Go generics design attempts to solve the problem of writing reusable Go packages that are independent of types. This is also known as parametric polymorphism with type parameters.
Note: For more details on the reasoning behind generics in Go, see the post Why Generics? on the official Go blog.
Type Constraint
Type constraint in Go has been likened to the way interface types work in the language. In addition, a generic function is more or less synonymous with using values of the interface type. Code written with generics can only use the operations allowed by these constraints (or operations that are allowed for any type that satisfies the requirements of that constraint).
Here is an example extracted from the document:
type Stringer interface {
String() string
}
From the above, we have defined a `Stringer` interface with a method which is a constraint that the caller, in this case the type, must satisfy. The String method allows us to write a generic function that allows us to call String, which should return a string representation of the value.
Now let us explore how we are going to make use of this constraint. It can be listed in the type parameter list as shown below:
func Stringify(type T Stringer)(s []T) (ret []string) {
for _, v := range s {
ret = append(ret, v.String())
}
return ret
}
From the above function call, we can see that the Stringify function calls the String method on each iteration of the function parameter ‘s’, appends the result to the return type and returns the final result.
What did the language rely on before Go generics?
Although interface types are a type of generic programming in Go, they only allow us to capture the common aspects of several types and express them as methods. With generics, the main point is to write functions that would work on different types implemented by interface methods.
In essence, we can write functions that use these interface types. By using the empty interface type, we can check for different types via type switches and type assertions. This means that we can write functions that satisfy the interface constraint based on the allowed types.
The Go community felt it was too tedious and cumbersome to have to write these methods on a type just to do something so simple. With interfaces, we must write the methods ourselves just to define a named type with a couple of methods to perform a particular action.
The caveat here is that these methods have the same signatures for each type. This means that all we have done is move and condense the duplicate code, nothing more.
Another approach is to make use of reflection and type assertions, but we then lose the benefit of static typing. Lastly, we can make use of code generators that take in a type and generate functions for that type. All three methods come with their own challenges.
Conclusion
If you are new to Go, you will soon notice that there is no way to write a simple generic function that works for any type. This does not work because Go is statically typed and requires us to write the exact type of the data structure of its elements, including types and variable declarations. Go is different from other high-level languages like Python and JavaScript in this regard.
Generics would provide many advantages to the language, chief of which are improved flexibility and better code reusability. For example, generics would come in handy when writing Go code that will be applied to different type arguments all at once.
There is no doubt that generic features have their place in the language, and sometimes they are exactly what is needed. However, they are just one more tool in the toolset of Go, as the language seeks to keep its standard library as minimal as possible. This means that the community will need to come up with the best practices for generics use.
It is beyond doubt that generics will make Go more efficient and powerful. They will let developers implement many common solutions with functions or types with type parameters. The greatest benefit of generics is that they would allow sharing and reusing code in the form of libraries and let us build bigger applications more easily.
The latest generic addition to the language is fully backwards-compatible with Go 1, keeping it fully in line with the Go promise. This means that existing Go programs will continue to work exactly as they do today. If you would like to experiment with generics today, the generic playground is available for you to check out.
All things being equal, generics could be added to Go in the 1.18 release at the earliest, which was initially scheduled for March 2022. Cheers and thanks for reading!