Golang Tutorial: Generics

- 16 minutes read - 3336 words

One feature to rule them all.

How often do we encounter significant changes in our preferred programming language? Some languages undergo frequent updates, while others remain traditional and stable. Go falls into the latter category, known for its consistency. “This is not the Go way!” is a phrase that often comes to mind. Most Go releases have focused on refining its existing principles. However, a major shift is on the horizon. The Go team has announced that Generics in Go are becoming a reality, moving beyond mere discussion and into implementation.

Brace yourselves, a revolution is coming.

What are Generics?

Generics allow us to parameterize types when defining interfaces, functions, and structs.

Generics is not a new concept. It has been used since the first version of Ada, through templates in C++, to its modern implementations in Java and C#. To illustrate without delving into complex definitions, let’s examine a practical example. Instead of having multiple Max or Min functions like this:

Without Generics

func MaxInt(a, b int) int {
	// some code
}

func MaxFloat64(a, b float64) float64 {
	// some code
}

func MaxByte(a, b byte) byte {
	// some code
}

we can declare now only one method, like this:

With Generics

func Max[T constraints.Ordered](a, b T) T {
	// some code
}

Wait, what just happened? Instead of defining a method for each type in Go, we utilized Generics. We used a generic type, parameter T, as an argument for the method. With this minor adjustment, we can support all orderable types. The parameter T can represent any type that satisfies the Ordered constraint (we will discuss constraints later). Initially, we need to specify what kind of type T is. Next, we determine where we want to use this parameterized type. In this case, we’ve specified that both input arguments and the output should be of type T. If we execute the method by defining T as an integer, then everything here will be an integer:

Execute Generic Function

func main() {
	fmt.Println(Max[int](1, 2))
}
//
// this code behaves exactly like method:
// Max(a, b int) int

And it doesn’t stop there. We can provide as many parameterized types as we need and assign them to different input and output arguments as desired:

Execute some complex Generic Function

func Do[R any, S any, T any](a R, b S) T {
	// some code
}

func main() {
	fmt.Println(Do[int, uint, float64](1, 2))
}
//
// this code behaves exactly like method:
// Do(a int, b uint) float64

Here we have three parameters: R, S, and T. As we can see from the any constraint (which behaves like interface{}), those types can be, well, anything. So, up to this point, we should have a clear understanding of what generics are and how we use them in Go. Let’s now focus on more exciting consequences.

Speed, give me what I need

Generics in Go are not the same as reflection.

Before delving into complex examples, it’s essential to check the benchmark scores for generics. Logically, we do not expect performance similar to reflection because if that were the case, we would not need generics at all. Generics are not in any way comparable to reflection and were never intended to be. If anything, generics are an alternative for code generation in some use cases. Our expectation is that code based on generics should have similar benchmark results as code executed in a more traditional way. So, let’s examine a basic case:

A Generic Function for Benchmark

package main

import (
	"constraints"
	"fmt"
)

type Number interface {
	constraints.Integer | constraints.Float
}

func Transform[S Number, T Number](input []S) []T {
	output := make([]T, 0, len(input))
	for _, v := range input {
		output = append(output, T(v))
	}
	return output
}

func main() {
	fmt.Printf("%#v", Transform[int, float64]([]int{1, 2, 3, 6}))
}
//
//
// Out:
// []float64{1, 2, 3, 6}

Here are small methods for transforming one Number type to another. Number is our constraint, built on the Integer and the Float constraints from the Go standard library (we will cover this topic later). Number can be any numerical type in Go, from any derivative of int to uint, float, and so on. The Transform methods expect a slice with the first parametrized numerical type S as the slice’s base and transform it into a slice with the second parametrized type T as the slice’s base. In short, if we want to transform a slice of ints into a slice of floats, we would call this method as we do in the main function. The non-generics alternative for our function would be a method that expects a slice of ints and returns a slice of floats. So, that is what we will test in our benchmark:

Benchmark

func BenchmarkGenerics(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Transform[int, float64]([]int{1, 2, 3, 6})
	}
}

func TransformClassic(input []int) []float64 {
	output := make([]float64, 0, len(input))
	for _, v := range input {
		output = append(output, float64(v))
	}
	return output
}

func BenchmarkClassic(b *testing.B) {
	for i := 0; i < b.N; i++ {
		TransformClassic([]int{1, 2, 3, 6})
	}
}
//
//
// Out:
// goos: darwin
// goarch: amd64
// pkg: test/generics
// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
//
// first run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	38454709	        31.80 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36445143	        34.83 ns/op
// PASS
//
// second run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	34619782	        33.48 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36784915	        31.78 ns/op
// PASS
//
// third run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	36157389	        33.38 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	37115414	        32.30 ns/op
// PASS

No surprises here. The execution time is practically the same for both methods, so using generics does not impact the performance of our application. But are there any repercussions for structs? Let’s try that. Now, we will use structs and attach methods to them. The task will be the same — converting one slice into another:

Another Benchmark

func BenchmarkGenerics(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Transform[int, float64]([]int{1, 2, 3, 6})
	}
}

func TransformClassic(input []int) []float64 {
	output := make([]float64, 0, len(input))
	for _, v := range input {
		output = append(output, float64(v))
	}
	return output
}

func BenchmarkClassic(b *testing.B) {
	for i := 0; i < b.N; i++ {
		TransformClassic([]int{1, 2, 3, 6})
	}
}
//
//
// Out:
// goos: darwin
// goarch: amd64
// pkg: test/generics
// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
//
// first run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	38454709	        31.80 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36445143	        34.83 ns/op
// PASS
//
// second run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	34619782	        33.48 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36784915	        31.78 ns/op
// PASS
//
// third run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	36157389	        33.38 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	37115414	        32.30 ns/op
// PASS

Again, no surprises. Using generics or the classic implementation does not have any impact on the performance of the Go code. Yes, it is true that we did not test too complex cases, but if there were a significant difference, we would have already noticed it. So, we are safe to proceed.

Constraints

If we want to test more complex examples, simply adding any parametrized type and running the application is not enough. If we decide to create a simple example with some variables without any complex calculations, we will not need to add anything special:

A simple Generic Function

func Max[T interface{}](a, b T) (T, T) {
	return a, b
}

func main() {
	fmt.Println(Max(1, 2))
	fmt.Println(Max(3.0, 2.0))
}
//
//
// Out:
// 1 2
// 3 2

If we want to test more complex examples, simply adding any parameterized type and running the application is not enough. Suppose we decide to create a simple example with some variables without any complex calculations. In that case, we will not need to add anything special, except that our method Max does not calculate the maximum value of its inputs but returns them both. There is nothing strange in the example above.

To achieve this, we use a parameterized type T, defined as interface{}. In this example, we should not view interface{} as a type but as a constraint. We use constraints to define rules for our parameterized types and provide the Go compiler with some context on what to expect. To reiterate, we do not use interface{} here as a type but as a constraint. We define rules for the parameterized type, and in this case, that type must support whatever interface{} does. So, practically, we could also use the any constraint here. (To be honest, in all the examples, I have preferred interface{} instead of any, to respect the “good old days”.)

During compile-time, the compiler can take a constraint and use it to check if the parameterized type supports operators and methods that we want to execute in the following code. As the compiler does most of the optimization at runtime (and therefore, we do not impact runtime, as we could see in the benchmark), it allows only the operators and functions defined for particular constraints. So, to understand the importance of constraints, let us finish implementing the Max method and try to compare the a and b variables:

Failed execution of Generic Function

func Max[T any](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(1, 2))
	fmt.Println(Max(3.0, 2.0))
}
//
//
// Out:
// ./main.go:6:5: invalid operation: cannot compare a > b (operator > not defined on T)

When we attempt to run the application, we encounter an error — “operator > not defined on T.” Since we defined the T type as any, the final type can be, well, anything. At this point, the compiler does not know how to handle the > operator.

To resolve this issue, we must define the parameterized type T as a constraint that allows such an operator. Fortunately, thanks to the Go team, we have the Constraints package, which includes such a constraint. The constraint we want to use is called Ordered, and after making this adjustment, our code works perfectly:

Ordered Constraint

func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(fmt.Sprintf("%T", Max(1, 2)))
	fmt.Println(Max(1, 2))

	fmt.Println(fmt.Sprintf("%T", Max(3.0, 2.0)))
	fmt.Println(Max(3.0, 2.0))

	fmt.Println(fmt.Sprintf("%T", Max[int](1, 2)))
	fmt.Println(Max[int](1, 2))

	fmt.Println(fmt.Sprintf("%T", Max[int64](1, 2)))
	fmt.Println(Max[int64](1, 2))

	fmt.Println(fmt.Sprintf("%T", Max[float64](3.0, 2.0)))
	fmt.Println(Max[float64](3.0, 2.0))

	fmt.Println(fmt.Sprintf("%T", Max[float32](3.0, 2.0)))
	fmt.Println(Max[float32](3.0, 2.0))
}
//
//
// Out:
// int --> Max(1, 2)
// 2
// float64 --> Max(3.0, 2.0)
// 3
// int --> Max[int](1, 2)
// 2
// int64 --> Max[int64](1, 2)
// 2
// float64 --> Max[float64](3.0, 2.0)
// 3
// float32 --> Max[float32](3.0, 2.0)
// 3

By using the Ordered constraint, we achieved the desired result. One interesting aspect of this example is how the compiler interprets the final type T, depending on the values we pass to the method. Without specifying the actual type in square brackets, as shown in the first two cases, the compiler can deduce the type used for the arguments — in the case of Go, this would be int and float64.

However, if we intend to use types other than the default ones, such as int64 or float32, we should explicitly provide these types in square brackets. This way, we give the compiler precise information about what to expect. If we wish, we can extend the functionality of the Max function to support finding the maximum value within an array:

Zero values

func Max[T constraints.Ordered](a []T) (T, error) {
	if len(a) == 0 {
		return T(0), errors.New("empty array")
	}
	max := a[0]

	for i := 1; i < len(a); i++ {
		if a[i] > max {
			max = a[i]
		}
	}

	return max, nil
}

func main() {
	fmt.Println(Max([]string{}))
	fmt.Println(Max([]string{"z", "a", "f"}))
	fmt.Println(Max([]int{1, 2, 5, 3}))
	fmt.Println(Max([]float32{4.0, 5.0, 2.0}))
	fmt.Println(Max([]float32{}))
}
//
//
// Out:
//  empty array
// z <nil>
// 5 <nil>
// 5 <nil>
// 0 empty array

In this example, two interesting points emerge:

  1. After defining type T within square brackets, we can use it in various ways within the function signature, whether as a simple type, a slice type, or even as part of a map.

  2. To return the zero value of a specific type, we can use T(0). The Go compiler is intelligent enough to convert the zero value into the desired type, such as an empty string in the first case.

We’ve also seen how constraints work when comparing values of a certain type. With the Ordered constraint, we can use any operator defined on integers, floats, and strings. However, if we wish to use the == operator exclusively, we can utilize the new reserved word comparable, a unique constraint that only supports this operator and nothing else:

Comparable Constraint

func Equal[T comparable](a, b T) bool {
	return a == b
}

func Dummy[T any](a, b T) (T, T) {
	return a, b
}

func main() {
	fmt.Println(Equal("a", "b"))
	fmt.Println(Equal("a", "a"))
	fmt.Println(Equal(1, 2))
	fmt.Println(Equal(1, 1))
	fmt.Println(Dummy(5, 6))
	fmt.Println(Dummy("e", "f"))
}
//
//
// Out:
// false
// true
// false
// true
// 5 6
// e f

In the example above, we can observe the proper usage of the comparable constraint. It’s worth noting that the compiler can infer the actual types even without the need to strictly define them within square brackets.

An interesting point to highlight in this example is that we used the same letter, T, for both parameterized types in two different methods, Equal and Dummy. It’s important to understand that each T type is defined within the scope of its respective method (or struct and its methods), and these T types do not refer to the same type outside of their respective scopes. This means that you can use the same letter T in different methods, and the types will remain independent of each other.

Custom constraints

Creating custom constraints in Go is straightforward. You can define a constraint as any type, but using an interface is often the best choice. Here’s how you can do it:

Custom Constraint

type Greeter interface {
	Greet()
}

func Greetings[T Greeter](t T) {
	t.Greet()
}

type EnglishGreeter struct{}

func (g EnglishGreeter) Greet() {
	fmt.Println("Hello!")
}

type GermanGreeter struct{}

func (g GermanGreeter) Greet() {
	fmt.Println("Hallo!")
}

func main() {
	Greetings(EnglishGreeter{})
	Greetings(GermanGreeter{})
}

//
//
// Out:
// Hello!
// Hallo!

We’ve created an interface called Greeter to use it as a constraint in the Greetings method. While we could use a Greeter variable directly instead of generics, we’ve used generics here for demonstration purposes.

Type sets

Every type has an associated type set. The type set of an ordinary non-interface type T consists of just T itself, represented as the set {T}. In the case of an interface type (for this discussion, we are focusing solely on ordinary interface types without type lists), the type set comprises all types that declare all the methods of that interface.

The definition mentioned above comes from a proposal regarding type sets, and it has already been incorporated into the Go source code. This significant change has opened up new possibilities for us. Notably, our interface types can now embed primitive types such as int, float64, and byte, not limited to other interfaces. This enhancement allows us to define more versatile constraints. Let’s explore the following example:

Custom Comparable Constraint as a Type Set

type Comparable interface {
	~int | float64 | rune
}

func Compare[T Comparable](a, b T) bool {
	return a == b
}

type customInt int

func main() {
	fmt.Println(Compare(1, 2))
	fmt.Println(Compare(customInt(1), customInt(1)))
	fmt.Println(Compare('a', 'a'))
	fmt.Println(Compare(1.0, 2.0))
}

//
//
// Out:
// false
// true
// true
// false

We’ve defined our Comparable constraint, and it might appear a bit unusual, doesn’t it? The new approach with type sets in Go now allows us to create an interface that represents a union of types. To specify a union between two types, we simply include them within the interface and use the | operator to separate them. In our example, the Comparable interface constitutes a union of types: rune, float64, and indeed, int. However, int is designated here as an approximation element.

As demonstrated in the proposal for type sets, the type set of an approximation element T encompasses not just type T itself but also all types whose underlying type is T. Consequently, by employing the ~int approximation element, we can supply variables of our customInt type to the Compare method. You’ll notice that we’ve defined customInt as a custom type with int as its underlying type. If we neglect to include the ~ operator, the compiler will issue an error, preventing the execution of the application. This represents a notable advancement in our understanding of these concepts.

How far can we go?

We have the freedom to go wherever we please. Seriously, this feature has revolutionized the language. I mean, new code is constantly emerging, and this could have a significant impact on packages that rely on code generation, such as Ent. Starting from the standard library, I can already envision many codes being refactored in future versions to incorporate generics. Generics could even pave the way for the development of an ORM, similar to what we’re accustomed to seeing in Doctrine, for instance.

Let’s take a model from the Gorm package as an example:

Gorm Example

type ProductGorm struct {
	gorm.Model
	Name  string
	Price uint
}

type UserGorm struct {
	gorm.Model
	FirstName string
	LastName  string
}

Imagine that we want to implement the Repository pattern in Go for both models (ProductGorm and UserGorm). With the current stable version of Go, we can only choose one of the following solutions:

  1. Write two separate Repository structs.
  2. Write a code generator that uses a template to create those two Repository structs.
  3. Decide not to use the Repository pattern.

Now, with generics, the horizon of opportunities has shifted towards a more flexible approach, and we can do something like this:

Gorm and Generics

type Repository[T any] struct {
	db *gorm.DB
}

func (r *Repository[T]) Create(t T) error {
	return r.db.Create(&t).Error
}

func (r *Repository[T]) Get(id uint) (*T, error) {
	var t T
	err := r.db.Where("id = ?", id).First(&t).Error
	return &t, err
}

So, we have our Repository struct with a parameterized type T, which can be anything. It’s worth noting that we defined T only in the Repository type definition, and we simply assigned its associated functions. In this example, we have only two methods, Create and Get, for demonstration purposes. To simplify our demonstration, let’s create two separate methods for initializing different Repositories:

Declare new Repositories

func NewProductRepository(db *gorm.DB) *Repository[ProductGorm] {
	db.AutoMigrate(&ProductGorm{})

	return &Repository[ProductGorm]{
		db: db,
	}
}

func NewUserRepository(db *gorm.DB) *Repository[UserGorm] {
	db.AutoMigrate(&UserGorm{})

	return &Repository[UserGorm]{
		db: db,
	}
}

These two methods return instances of Repositories with predefined types, essentially serving as shortcuts. Now, let’s conduct the final test of our small application:

Test new Repositories

func main() {
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}

	productRepo := NewProductRepository(db)
	productRepo.Create(ProductGorm{
		Name:  "product",
		Price: 100,
	})

	fmt.Println(productRepo.Get(1))

	userRepo := NewUserRepository(db)
	userRepo.Create(UserGorm{
		FirstName: "first",
		LastName:  "last",
	})
	fmt.Println(userRepo.Get(1))
}
//
//
// Out:
// &{{1 2021-11-23 22:50:14.595342 +0100 +0100 2021-11-23 22:50:14.595342 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}}  100} <nil>
// &{{1 2021-11-23 22:50:44.802705 +0100 +0100 2021-11-23 22:50:44.802705 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}} first last} <nil>

And it works! One implementation for Repository that supports two models, all without the need for reflection or code generation. This is something I never thought I would see in Go.

Conclusion

There’s no doubt that Generics in Go are a monumental change. This change has the potential to significantly alter how Go is used and may lead to numerous refactors within the Go community in the near future. While I’ve been experimenting with generics on a daily basis, exploring their possibilities, I can’t wait to see them in the stable Go version. Viva la Revolution!

Useful Resources

comments powered by Disqus