Generic programming for statically typed languages is a tool for building maintainable software. Generics have a lot of advantages and can create infinitely reusable code, they do however, have a history of being overused and misunderstood leading to anti-patterns1. Due to the regularity of misuse the Go team has been reluctant to add generics to the language.
Beginning in February 2022, Go will introduce generics support in its 1.18 release2. The Go team spent many years researching and experimenting with different implementations of generics and their implications for Go. The Go team believes they have found an implementation that is both effective and, more importantly, maintains the simplicity of the language.3
Table Of Contents
History of Generics in Go
One of the first concerns raised by engineers (back in 20094, before the first release of Go) about the Go language was the lack of generics support. For many years5 the community has been asking for generics and, for many years, there has been pushback from the Go language team.
Generics may well be added at some point. We don’t feel an urgency for them, although we understand some programmers do.
- Originally posted to the Go FAQ
My Original Opinion on Generics in Go
I have to admit my skepticism regarding the need for generics in Go. I have been using Go for years now and never thought: “This code would be easier if I had generics.”
I can definitively say – I was wrong.
I began, reluctantly, playing with the new generics support after the official announcement at Gophercon 20212. I built generics libraries in the past and I found the Go generics syntax to be intuitive and easy to understand. As I started building out more than just “test the language feature” code, I was hooked.
As I look back to my pre-Go days (C#, C++, etc…) I realized a great deal of the code I wrote used generics (lists, collections, etc…). With Go became accustomed to re-writing helper functions or duplicating the owner pattern regularly enough I created a snippet in order to maintain sanity.
The worst problems arose when I needed to fix a bug in a standard pattern, causing cascading changes through each of my codebases due to boilerplate duplication.
Talk about a lot of duplication!
So, I can confidently say, generics in Go have already made me much more efficient and effective. The benefits generics provide: consistency, single-change fixes, and testability, will greatly improve my (and the Go community’s) overall code quality and stability going forward.
First Things First
Generic support is currently in beta
and is not yet available in a stable
release form. If want to use generics, or want to follow along with this post,
you will need to install go1.18beta1
6 or gotip
. Personally, I recommend
using gvm
to install the dev branch of go.
It’s simple to install (I built it, so I’m biased) and it will install the
latest development branch of Go with a simple (gvm next
) command.
If you haven’t read my post on Building a Go Version
Manager, you should check it out. The post
walks through the process of installing gvm
7 and using the development
branch of Go.
The new “any” Predeclared Identifier
The introduction of generics also adds a small, but not insignificant, new type
alias (a.k.a. predeclared identifier)8 as a replacement for the empty
interface (interface{}
). The new keyword (any
) is fully backwards compatible
with the empty interface and can be used as a replacement for it.
So, how exactly do generics work in Go?
Generics in Go are similar to other statically typed languages. Type parameters9 are configured on a function, or a struct, to indicate its generic nature. One of the primary differences in Go (when compared to other languages like C#, C++, etc…) is the type parameters are not completely without restriction.
Type Parameters and Type Constraints
Type Parameters
In similar fashion to other statically typed languages, type parameters are
denoted using a capital letter (i.e. T
) wrapped in square brackets []
immediately following the function or struct name (i.e. func f[T any](t T)
).
The type parameter is then used in the execution of the function, or
instantiation of the struct, to indicate the type of the parameter.
1// Define the function f with a type parameter T
2func f[T any](t T) {
3 // ...
4}
5
6// ...
7
8// Call the function with a concrete type
9f[int](1)
10
11// Define a struct with a type parameter T
12type s[T any] struct {
13 t T
14}
15
16// Instantiate the struct with a concrete type
17myInstance := s[int]{t: 1}
Explicit reference to the concrete type when executing a method, or instantiating a class, is not always necessary (more on this when we talk about type inference).
Type Erasure
With Go type parameters the full metadata of a type is available through the reflection library. As stated below by the Go team.
Most complaints about Java generics center around type erasure. This design does not have type erasure. The reflection information for a generic type will include the full compile-time type information.
– Go Generics Proposal - August 20, 2021
Ian Lance Taylor & Robert Griesemer
Type Constraints
In languages, like C#, C++, etc…, the type parameters are completely type agnostic, but in Go they are not. The Go team experimented for years to find the right implementation for generics resulting in a constraint-based approach.10
Constraints are used to create a union (denoted by |
) of allowed types.
For example, consider the following type constraint:
1type MyConstraint interface {
2 int | int8 | int16 | int32 | int64
3}
The above constraint indicates when used as a type parameter, the type a generic
function, or struct, can accept is limited to the types in the union (int
,
int8
, int16
, int32
, int64
).
Example of Type Constraints: Valid and Invalid Usage
1// Example of a generic function using
2// the MyConstraint type parameter
3func MyFunc[T MyConstraint](input T) {
4 // ...
5}
6
7func main() {
8 // Valid because type int8 is in the type set
9 // (int | int8 | int16 | int32 | int64)
10 MyFunc[int8](1)
11
12 // Invalid because type string is not in the type set
13 MyFunc[string]("hello")
14}
In the screenshot below, you can see the error the compiler generates when the
MyFunc
function is called with a type not in the type set.11
Benefits of Constraints
One of the benefits of using constraints is they allow you to define a set of allowed types represented by the type parameter. Constraint enforcement helps reduce possible misuse.
Another benefit of type constraints is their explicit nature; helping redirect the thought process for creating generic implementations, away from a type free-for-all towards a pre-defined expected behavior.
Constraints lead to more maintainable, testable, and reusable code.
Caveats when Defining Constraints with Methods
1type MyConstraint interface {
2 Integer | Float | ~string
3 String() string
4}
BEST PRACTICE:
When creating a constraint, that adds a method to the interface withbuiltin
types, ensure the constraint specifies anybuiltin
using the~
token. If the~
is missing the constraint can never be satisfied since Gobuiltin
types do not have methods.
Underlying Types and the ~ Operator
One of the newly announced elements of the generic implementation for Go is the
~
token12. With type definition overrides there was need for a way to
indicate a type parameter should allow any type within the constraint if the
underlying type matched the constraint. The ~
token was added to support this
requirement.
Example of Type Re-definition with Generics
1type MyEnumeration int16
2
3const (
4 MyEnumeration_A MyEnumeration = iota
5 MyEnumeration_B
6 MyEnumeration_C
7)
8
9func Process[T int16](value T) {
10 // ...
11}
12
13func test() {
14 Process(MyEnumeration_A)
15}
The example above defines an enumeration type definition for int16
(the
underlying type). If we attempt to execute the Process
function using the
MyEnumeration
type compilation will fail.
If we update the constraint on the Process
function to T ~int16
then the
compiler understands MyEnumeration
can be used because its underlying type is
int16
(the compiler no longer errors as seen below).
Comparability and Ordering of Constrained Types
The new comparable
keyword, in Go 1.18, was added for specifying types that
can be compared with the ==
and !=
operators.
Comparable types include: structs, pointers, interfaces, channels, and builtin
types. comparable
can also be embedded in other constraints since it is a
constraint.13
From the Go 1.18 documentation:
type - comparable
comparable is an interface that is implemented by all comparable types (booleans, numbers, strings, pointers, channels, interfaces, arrays of comparable types, structs whose fields are all comparable types). The comparable interface may only be used as a type parameter constraint, not as the type of a variable.14
One of the first things I learned, when starting with Go generics, was the any
type does not satisfy the comparable
constraint.15 any
violates the
comparability constraint due to the fact that under the hood any
is just the
empty interface. Empty interface can be a nil
, a struct type, a pointer type,
a slice, a map, a function literal, etc… Function literals, and maps for
example, cannot be compared in non-generic code and the same is true in generic
code, but they can be assigned to an empty interface. Thus the inability to use
any
as a comparable type makes sense.
The operations permitted for the
any
type are:15
- declare variables of those types
- assign other values of the same type to those variables
- pass those variables to functions or return them from functions
- take the address of those variables
- convert or assign values of those types to the type interface{}
- convert a value of type T to type T (permitted but useless)
- use a type assertion to convert an interface value to the type
- use the type as a case in a type switch
- define and use composite types that use those types, such as a slice of that type
- pass the type to some predeclared functions such as new
– Ian Lance Taylor & Robert Griesemer
The Go team created a package of constraints (constraints
) that can be
imported and used for the most generic of contraint types. One important
constraint is constraints.Ordered
, which allows the use of the <
, <=
, >
,
and >=
operators.
1type Ordered interface {
2 Integer | Float | ~string
3}
The definition of the above Ordered
constraint makes sense it would allow the
use of the <
, <=
, >
, and >=
operators since these types are normally
comparable outside of a generic scope.
NOTE: Only operations pertaining to ALL types of a union operation in a type constraint can be used against the type parameter in a generic struct or function.
Type Inference
The statically typed nature of Go created a unique opportunity syntactically when implementing generics. If we examine the process of calling a generic function, or instantiating a generic struct, we see the type of the parameters is pre-defined. Since the type of the parameters is known, a priori, the compiler can infer the type of parameters in most situations.16
Example of Type Inference
|
|
Type inference makes it possible to exclude the type parameter from the call to
a function, or instantiation of a struct, in most cases. In the example above,
the call to MyFunc
on line 11 explicitly declares the type to be used but in
the call on line 14 the type of the parameter is inferred from the type of the
variable declared on line 8 (int
). In other words, [int]
can be safely
omitted.
Type Inference Failure
There will be times the compiler is unable to infer the type of a parameter. In the case of inference failure, simply specify the type of the parameter in the function call or struct instantiation.
Type Chaining and Constraint Type Inference
Type Chaining
Type chaning is a technique that allows you to define a type parameter that is a composite of another type parameter. The composite nature of type parameters is useful when you define a secondary type in a generic struct or function.
1// Example of Type Chaining
2func ToChan[U ~[]T, T any](t U) <-chan T {
3 c := make(chan T)
4
5 // ...
6
7 return c
8}
9
10//...
11
12c := ToChan([]int{1, 2, 3})
Constraint Type Inference
In the example above, the type of the parameter U
is defined as ~[]T
(a
composite of T
) and the type of the parameter T
is defined with a constraint
of any
. The type of the parameter U
can be inferred using the type of the
parameter T
when the ToChan method is called.17
The compiler knows when ToChan
is called the incoming type parameter U
must
be a slice of T
and the type of the parameter T
is int
. The compiler can
determine from the call the type of the parameter U
is of type []int
.
BEST PRACTICE:
Type parameters should be written “in line”
Creating a Generic Struct
Creating a generic struct in Go is possible with type parameters. For general purpose data structures this allows data to be stored in a generic struct and that is great, however, it is recommended to use a generic method when manipulating the data structure so the function is not tied to the specific struct and is more widely usable.
For general purpose data types prefer a function, rather than writing a constraint that requires a method.
– Ian Lance Taylor
When to Use Generics
Generics are a great tool for creating reusable code, however, they are not always the best tool. The majority of situations do NOT require generics and a guiding principle is to replace duplicated boilerplate code with generic code.18 If the code you are writing can be constrained down to a couple of types, then perhaps use interfaces instead.19
It is essential to have good tools, but it is also essential that the tools should be used in the right way.
– Wallace D. Wattles
Below is an example of when generics are the right tool for the job:
Example: Code Duplication Pre-Generics
|
|
Breaking down the functionality of each method above shows this is a prime use case for generics. The code on lines 3-11, 15-23, and 32-40 are all exactly the same. The above methods can be consolidated into a single generic function (see below), reducing the code by 16 lines (and reducing code duplication by a factor of 66%).
Example: Code Consolidation Post-Generics
1// Example of channel conversion using generics
2func ToAnyChan[T any](in <-chan T) <-chan any {
3 out := make(chan any)
4 go func() {
5 defer close(out)
6
7 for s := range in {
8 out <- s
9 }
10 }()
11 return out
12}
13
14ToAnyChan(mystringchan)
15ToAnyChan(myintchan)
16ToAnyChan(mystructchan)
The next post will dive head first into building useful generics in Go.
What the introduction of Generics means for the Go Language
Working with Go generics for the last six weeks has been a great experience and I think the community is going to quickly adopt them. The reduction in duplicated code is important and the benefits outweigh the cost of rewriting existing duplicated code. In terms of stability and maintainability, we as a community will start to see the improvements quickly.
Reducing the Learning Curve for New Go Engineers
The concurrent design nature of Go can create a high barrier to entry for new and inexperienced engineers. The introduction of generics in Go can help reduce the learning curve by providing access to stable, and tested generic use libraries for concurrent programming.
I am most excited about the genericization of concurrency patterns which I will cover in subsequent posts.