Go Generics - Everything You Need To Know
Updated: Jun 3, 2022
Introduction
Generics is one of the most anticipated and long awaited features in go. Some also argue that it is in some ways a controversial feature since it seems to go against one of the go language's core design principle "simplicity". This however is a topic of discussion for another day, in this article we will go through everything that you need to get up and running with go generics. Also we will delve into some of the finer details and best practices of go generics to get you an advanced level knowledge on this topic.
The go generics is finally here! The generics feature was added to the language in the Go release, version 1.18.
What is generics in a programming language?
Generics is a programming language paradigm that gives us a way to write code that is not tied to any specific type. It gives us the ability to define a generic or common data structure/function that allows us to work with multiple data types (like int, float, string etc).
Why generics is needed?
Let us understand this with an example. Assume we have a function Add(), that adds two integer types and returns the result as an integer as shown below:
The above function works fine as long as our use case is only to add two integer values. Suppose, tomorrow we have a new requirement where in we are required to support float type addition as well, how can we handle this? We cannot use our earlier function because it takes only integer types as input.
Prior to Go generics this could be solved in one of the two ways:
Defining multiple functions, one for each type.
Using an empty interface and type asserting.
Approach 1:
A natural tendency to solve this is to define a new function that does the exact same thing as our earlier Add() function but with float64 type as shown below.
As you can see this is unnecessary duplication of code. It may not seem like a big deal for the above example as our function only involves a simple logic to add two numbers. But in the real world we may have to deal with a much more complicated logic containing hundreds of lines of code and duplicating these complex functions is a waste of time and effort. Also this introduces a maintenance overhead because every time we need to improve or update some piece of code we would have to do this in all the duplicated blocks, which of course is not the best way to handle this.
Approach 2:
In this approach we use an empty interface that can accept values of any type and in the function body we use type assertion to extract the required type and perform necessary actions.
While this looks cleaner than the first approach, it still involves a lot of boilerplate code and is not the most efficient solution to our problem. Scenarios like these is exactly where generics comes into play.
Go Generics
The generics feature in Go is a major release, according to the official documentation this is the biggest change made to the language since the first open source release. The good news however is that it is fully backward compatible with the code written using earlier versions of Go.
In Go a generic type is generally denoted using the notation T, however we are not restricted to using that, we can name it anything.
Fig.1 shows a sample Go generic function along with its components. Compared to a normal (non-generic) Go function, you can see there is an additional square bracket between the function name and the parameter list. Also the parameter list contains the generic type parameters (denoted by T).
Go generics can be broadly broken down into 3 components:
Type parameters
Type sets or type constraints
Type inferences
Lets discuss each of these components in detail.
Want to master coding? Looking to learn new skills and crack interviews? We recommend you to explore these tailor made courses:
Type Parameters
In Fig. 1, the square brackets and the elements inside it together is called a type parameter or type parameter list. Type parameter defines information about the generic type. It contains information like the name of the generic type, data types supported by the generic type etc.
A type parameter is defined using the syntax:
[T1 constraints, T2 constraints, ...]
Here are a few type parameter definition examples:
Example 1:
[T int | int32 | int64]
Example 2:
[T1 int32 | float64, T2 string | float64]
Example 3:
[T constraints.Ordered]
Note: constraints.Ordered is a type of constraint provided by Go and it is defined in the package golang.org/x/exp/constraints, it supports Integer, Float and string types (More details on the constraints.Ordered type can be found here).
Type parameters can be applied to Go functions and Go types (go type keyword).
1. Type Parameter on Go Functions
The sample function shown in Fig. 1 is an example of how a type parameter can be applied to a Go function.
Let us understand this more clearly by studying how we can redefine our earlier Add() function to support integer and float types using Go generics.
As you can see we can convert our non-generic Add() function to a generic Add() function by adding a type parameter definition after the function name and replacing the specific types (int, float64) with generic type T.
This generic Add() function can be called using both integer and float data types, there is no need to redefine or duplicate the function body like we saw earlier.
How to call a generic function?
Calling a generic function involves two steps: 1. Instantiation 2. Function call
Instantiation
In this step we tell the compiler what specific type we want to pass into our generic type. The compiler then checks whether this data type satisfies the type parameter constraints. For example the constraints, constraints.Integer and constraints.Float types used in our above generic Add() function, supports Integer, Float data types. If anything other than these types is used during instantiation, it throws a compile time error.
The syntax for instantiation is:
funcVariable := function_name[data_type]
For example we can instantiate our above generic add function with int data type as shown below:
AddFunc := Add[int]
For float64 type we need to use float64 inside the square brackets as shown below:
AddFunc := Add[float64]
Function call
The instantiation step returns a func type variable. In this step we call the generic function using this func type variable that we obtained during the instantiation step as shown below:
result := AddFunc(10, 20)
So to summarize, in order to call a generic function we need to first instantiate and then call the function as shown below:
AddFunc := Add[int]
result := AddFunc(10, 20)
Go also supports a simplified syntax where we can combine the instantiation step and function call step into a single line of code:
result := function_name[data_type](argument_list)
This means we can call our Add() function using a single line of code as shown below:
result := Add[int](10, 20)
2. Type Parameter on Go Types
Type parameters can also be applied to types defined using the Go type keyword. Let us understand this again by taking the addition example:
Let us define a custom add type (a generic type) using type parameters. The custom add type struct should have two fields for storing the numbers to be added.
The Add() function should add the values in these two fields and return the result.
In above example we have defined a custom struct type CustomAddType that has two fields num1 and num2. Both num1 and num2 are of type T (generic type). The type parameter is defined after the type name inside square brackets.
We have defined an Add() method for this generic struct type. This method adds the generic types num1 and num2 and returns the result.
To call this add method we need to instantiate the CustomAddType type first and then call the Add() method on it as shown below:
Since num1 and num2 are generic types we can pass both int and float (defined by type constraints) values to it.
Type Sets
In the previous section we have learnt that type parameters can be defined with the syntax:
[T constraints]
The "constraints" part in the type parameter syntax is what we refer to as the type sets or type constraints. In simple words type sets define the range of types or set of types a generic type T supports.
The benefit of using type constraint is that it allows us to define a set of supported types. This approach is unlike the generics implementation in other languages like C#, C++ where type parameters are completely type agnostic. The type constraint way of implementation is intentionally added to Go generics to reduce misuse.
Type Sets are Interfaces
An important thing to note is, everything that we define as a type set is an interface! Yes that's right, every type set is an interface. For example the constraints.Ordered type set we saw earlier, is an interface defined in constraints package. The definition of constraints.Ordered is as shown below:
Similarly, constraints.Integer and constraints.Float types that we used in our generic Add() function are also interfaces.
New Interface Syntax
If you have been using Go for a while now, the interface syntax you see above might look a bit weird to you. Interfaces in Go used to have only methods and other interfaces embedded in them, but this is a little different. That's because, this is a new syntax introduced in Go 1.18 for use in generics. Now we are allowed to have types inside interfaces as well. We are also allowed to specify multiple types inside interfaces separated by the union operator as shown in the example below:
The MyInteger interface shown above defines a new type set with int, int8 and int16 as possible types. The | symbol denotes a union, meaning the MyInteger interface is a union of int, int8 and int16 types.
Similarly we can have interfaces with other types like string, float64 etc.
We can also embed interfaces/type sets inside other interfaces/type sets. Example,
In fact if you observe carefully, constraints.Ordered itself is a union of Integer and Float type sets which are in turn interfaces.
Tilde Operator
If you look at the constraints.Ordered type definition there is a ~ symbol before the string type. A tilde before a data type means the following things:
Allow all values corresponding to that data type.
Allow all values whose underlying type is the current data type.
For example a ~string means 1. Allow all values of string type. 2. Allow all values whose underlying type is string (Ex: type customString string).
Custom Type As Type Constraint
We are also allowed to define our own custom constraints as shown in example below:
type CustomConstraint interface {
int | string
}
We can use these custom type sets in our type parameter declaration as shown below:
[T CustomConstraint]
Interface Literal As Type Constraint
We can also use interface literals as type sets. For example,
[T interface{ int | int8 | int16 }]
Go allows us to simplify the interface literal syntax, we are allowed to skip the interface{} part from the above syntax:
[T int | int8 | int16]
If you go back to Fig.1, this is the reason why we where able to specify [T int | int32 | float64] without the interface{}.
Inbuilt/Pre-Defined Type Constraints
You might aware of the empty interface usage in Go. An empty interface i.e. interface{} in general means that it satisfies all types (since it has no methods inside it). Similarly in the context of type parameters, an empty interface represents a generic type which can be instantiated using any type. For example, the generic add function given below can take any type like int, int32, float32, string etc.
Go 1.18 has introduced a new keyword called any to represent an empty interface, interface{}. Using this keyword, the above add function can be equivalently written as:
In addition to any, Go provides another pre-defined type constraint comparable. comparable denotes the set of all non-interface types that are comparable. Specifically, a type T implements comparable if:
T is not an interface type and T supports the operations == and != or
T is an interface type and each type in T's type set implements comparable.
comparable can also be embedded in other constraints since it is a constraint.
That's it for the type sets section. Yes it can be quite overwhelming at first, given so many syntaxes and shorthands, but you will get used to it as you practice it more and more.
Type Inference
Type inference in the context of Go generics means how the compiler interprets the types that are being passed to a generic type. We can broadly classify type inference in go generics into two categories:
Function argument type inference
Constraint type inference
Let us discuss each of them in detail.
Function argument type inference
We have seen in the previous sections how we can instantiate and call a generic function. Say for example if we have a generic function for multiplying two numbers:
To instantiate and call the above generic function, we would write the following lines of code:
multiply := Multiply[int]
multiply(10,20)
This is one way of doing it. As we learnt the syntax can be simplified by combining the two steps into a single step:
Multiply[int](10,20)
While this is shorter than the first syntax and easier to read, it is still cumbersome. Go simplifies this further by allowing us to skip the type argument instantiation part as shown below:
Multiply(10,20)
As you can see, with this syntax, we do not have to specify [int] while calling our generic multiply function and with this the syntax now is exactly same as a normal function call. How this works is, when you call a generic function this way the compiler internally infers the type from the arguments provided in the function call.
In our example above, the compiler sees that the arguments (10,20) is passed to the function, so it infers the type to be int and substitutes this type for num1 and num2 parameters in the generic function. This way of inferring the type by the compiler in a generic function call by looking at its arguments is called function argument type inference.
The limitation with this approach however is that, if we need a specific type, say for example int32, we will still have to explicitly mention it using the earlier syntax itself. But for other cases this helps us further simplify the syntax and improve the code readability.
One thing to remember about function argument type inference is that it only works for type parameters that are used in the function parameters. This does not apply for type parameters used only in function results or type parameters used only in the function body.
For example, it does not apply to functions like,
that only uses T for result.
Constraint type inference
Another kind of type inference that is supported by Go generics is the constraint type inference.
Look the sample generic function above, you can see the type of the parameter U is defined as ~[]T and the type of parameter T is any. In such cases the compiler infers the type of the parameter U using the type of the parameter T when GenericFunc() is called. For example lets say we call this function like:
The compiler now see that the type U is a slice of T and the type of the parameter T is int. Therefore it can determine from the call the type of the parameter U is of type []int. This way of inferring the the types is referred to as constraint type inference in Go generics.
Go Generics Best Practices
1. Use ~ in type set definition for predefined types
When creating a constraint, that has builtin types and methods in the interface, ensure the constraint specifies any builtin type using the ~ token. If ~ is missing for any of the builtin types, the constraint can never be satisfied since Go builtin types cannot not have methods.
For example lets define a constraint called SampleConstraint that has a String() method in the interface:
Let us write a generic function that uses SampleConstraint:
Now let us define a type called TestType that implements SampleConstraint:
When you run this code using:
you will see the following error:
./prog.go:15:10: TestType does not implement MyConstraint (possibly missing ~ for string in constraint MyConstraint)
Go build failed.
To fix this we need to add a ~ before the string data type as show below:
2. Type parameter constraints should be narrow and explicit
This means when we write a new generic function, when we know what the expected types are, it is better to explicitly specify the type constraints that we expect our generic function to be called with like int, int32, float32 etc. rather than using "any" or interface{} as type constraints. This would allows us to seamlessly add new functionality to our generic function in future without breaking the existing code or having backward compatibility issues.
For example, let say we write a new generic function to display the price of a product and we know for a fact that the type of price we get as input is expected to be of type int64 or float64. But we still go ahead and use "any" as the type constraint:
Now suppose we get a requirement tomorrow to add tax to the price before displaying it, we need to make the following change to our code:
But when we try to compile the code we get the following error:
./prog.go:31:18: cannot convert price (variable of type T constrained by any) to type int
Go build failed.
Instead if we had explicitly constrained our type parameter to int64 or float64 as shown below,
our function would have worked as expected.
This is one of many scenarios where having a generic function with explicit type constraints helps. The other thing is, giving a broader range of values (more than what is needed) gives scope for unexpected errors. This means that our implementation is expected to ensure that we have written all the code needed to handle failure cases for all the types specified by our type constraints.
When Not To Use Generics
Generics is a great tool for writing reusable code, however, it does not mean that it is always the best tool. Majority of situations that we encounter on a daily basis do NOT require generics. As a guideline if you see lots of duplicated code blocks, you could consider replacing it with generic code. If the code you are writing can be constrained down to a couple of types, then perhaps it is better to use interfaces instead.
Summary
Generics is a big feature introduced in go 1.18 and is an important one. Compared to other languages the Go generics implementation is much more intuitive and easy to read. Go generics implementation is designed in such a way that type parameters are not type agnostic, the constraint based implementation that Go follows is a result of years of experimentation in order of to find the right approach by the Go team. The main aim of this approach is to prevent confusion and misuse of generic types.
However, Go generics is still a new feature and it has a long way to go before it could confidently use in production. We are sure, there will be improvements and new code added in the coming days to enhance the stability of the existing version. We will make sure to keep this article updated in case of any new updates or announcements.
That is all for this article, thank you for taking your time to read this. If you have any questions or doubts, please let us know in the comments section below, we will be happy to answer you.
If you found this article useful, do not forget to subscribe to Code Recipe, your support motivates us to bring out more such articles in future (scroll down to the bottom of the page to find the subscription form).
You can explore more such amazing articles from code recipe in our blogs section.
Code Recipe Limited Time Offer: Get 100% discount on Code Recipe Membership Plan. Join now and get exclusive access to premium content for free. Hurry! Offer only available for a limited time - Join now.
Hello Everyone,
Code Recipe is now on YouTube! For videos on latest topic visit our YouTube channel: Code Recipe
https://www.youtube.com/channel/UC9qXo8tTfbXLVQFbc93fiBg
Do not forget to subscribe to our channel if you find the videos useful. Your support means a lot to us!
Happy Learning. Ba bye! 😊