Unleashing the Power of Go Generics: Best Practices and Workarounds

Rohan Chauhan
Qoala Engineering
8 min readApr 6, 2023

--

An Introduction to Generics in Go

Go has long been criticised for its lack of support for generics, which are a powerful tool for writing reusable and type-safe code. But with the release of Go 1.18, the language has finally added support for generics in the form of type parameters and constraints. In this blog post, we will explore the basics of generics in Go and how they can be used to write more expressive and reusable code.

What are Generics?

Generics are a way of writing code that can work with a variety of different types. They allow you to define functions and data structures that can work with any type, without having to define separate implementations for specific types.

Generics typically come in two flavours: parameterised types and generic functions.

Parameterised types allow you to define a type that can work with different types of values. For example, a generic stack data structure can be defined to hold any type of value, rather than a specific type such as integers or strings.

Generic functions, on the other hand, allow you to write a function that can work with different types of arguments. For example, a generic function for calculating the sum of elements in a slice can work with slices of integers, floats, and even strings provided they can be added.

However, before go 1.18 version, Go does not support generic programming. Earlier Go’s designers have made a conscious decision not to include generic programming features in the language. One of the primary reasons for this decision is the desire to keep the language simple and easy to understand. Generics can add complexity to the language, and the Go team felt that the benefits of generics did not outweigh the costs of adding them to the language.

Another factor is the desire for fast compilation times. Generics can make the compilation process slower, and the Go team wanted to keep the language’s compilation times as fast as possible.

Pre-Go 1.18 Workarounds using Empty Interfaces, Reflection, and Code-Generation

One of the most common ways is to use the interface{} type.

The interface{} type can be used to create functions and data structures that can work with any type of data. However, the downside of using interface{} is that type safety is lost, and the code becomes more error-prone.

Let’s take an example to see how we can achieve generic-like behavior using the interface{} type.

In the example above, the PrintSlice function takes a slice of empty interfaces ([]interface{}), which can hold any type of data. This allows the function to work with slices of any data type. However, We can start noticing a couple of issues:

  • First, it increases boilerplate code. Indeed, whenever we want to add a case, it will require duplicating the range loop.
  • Meanwhile, the function now accepts an empty interface, which means we are losing some of the benefits of Go being a typed language. Indeed, checking whether a type is supported is done at runtime instead of compile-time. Hence, we also need to return an error if the provided type is unknown.
  • As the key type can be either int or string, we are obliged to return a slice of empty interfaces to factor out key types. This approach increases the effort on the caller-side as the client may also have to perform a type check of the keys or extra conversion.

Another approach is to use reflection to write code that can work with arbitrary types. Reflection allows programmers to examine and manipulate the structure of types at runtime, which can be useful for implementing generic-like behaviour. However, reflection can be slower and more complex than using interfaces or type-specific code, so it is not always the best approach.

Lastly, code-generation is another technique used to achieve generic-like behaviour in Go. This involves writing code that generates specific instances of functions or data types for different combinations of type parameters. This allows you to write generic-like code that is optimised for the specific types being used, while avoiding the performance overhead of reflection. Tools like generics-gen, go generate, and templating libraries can be used for code-generation in Go. However, this approach can be challenging to maintain and can result in bloated codebases.

Power of Generics in Golang: Post-1.18 Updates

In Go, generics are implemented using type parameters and constraints. Type parameters are used to represent a type that can be passed as an argument to a generic function or type. Constraints are used to restrict the set of types that can be used as a type parameter.

Type Parameters

Type parameters are represented using square brackets [] and are placed after the name of the function or type. They can be any valid identifier and represent a type that will be passed as an argument when the function or type is used.

Here’s an example of a generic function that takes two arguments of the same type:

In this example, T is the type parameter, and we're using the comparable constraint to allow any type to be used as T. The Equals function takes two arguments of type T and returns a boolean value indicating if they are equal. The benefit of using a generic function versus a non-generic function is that Equals can be used with any type that supports the == operator without having to write a different function for each type.

Constraints

Constraints restrict the set of types that can be used as type parameters. There are pre-defined sets of constraints allowed in go, some frequently used are:

1. Complex is a constraint that permits any complex numeric type.

2. Float is a constraint that permits any floating-point type.

3. Integer is a constraint that permits any integer type.

4. Ordered is a constraint that permits any ordered type: any type that supports the operators < <= >= >.

5. Signed is a constraint that permits any signed integer type.

6.Unsigned is a constraint that permits any unsigned integer type.

Here’s an example of a generic function that uses the Numeric constraint and returns the sum of the elements of a slice:

As you can see, the Ordered constraints restricts T to only numeric types. We then pass a slice of values of type T to the function, sum them up and then return the result. The return type of the function is inferred based on the type of the argument slice.

Parameterised Types

Parameterised types are represented using the same syntax as generic functions with type parameters and constraints in square brackets. For example, a generic stack data structure can be defined like so:

In this example, T is the type parameter, and we define a generic version of the Stack type. We define the Push and Pop methods for Stack which now are implemented using T rather than the specifics of the stack elements.

Common Uses

Data Structures

One of the most common use cases for generics in Go 1.18 is for implementing data structures. With generics, a developer can create type-safe and efficient data structures that can be used for any data type.

For example, here’s an implementation of a dynamic array using generics:

package main
import (
"fmt"
)
type DynamicArray[T any] []T
func NewDynamicArray[T any]() DynamicArray[T] {
return make(DynamicArray[T], 0)
}
func (da *DynamicArray[T]) Push(item T) {
*da = append(*da, item)
}
func main() {
var da DynamicArray[int] = NewDynamicArray[int]()
da.Push(1)
da.Push(2)
da.Push(3)
fmt.Println(da)
}

In this example, the DynamicArray type is defined using a type parameter T. This allows it to be used with any data type. A NewDynamicArray function is defined to create a new instance of the array, and the Push method is defined to add items to the array.

Algorithms

Generics can also be useful for implementing algorithms that work with collections of data. With generics, a developer can write an algorithm once and reuse it with any data type.

For example, here’s an implementation of the merge sort algorithm using generics:

In this example, the MergeSort function is defined using a type parameter T. This allows it to be used with any data type that is comparable. The merge function is also defined as a separate function, also using a type parameter T that is comparable.

Common Misuses

Overuse

Although generics can provide a lot of benefits, overusing them can still lead to code that is difficult to read and maintain.

For example, using too many type parameters can lead to code that is difficult to understand:

func Foo[A, B any](a A, b B) {
// ...
}
func Bar[A, B, C any](s []A, t map[B]C) {
// ...
}

In this example, the Foo function has two type parameters, which may be difficult to understand. The Bar function has three type parameters, which could make it even more difficult to understand.

Complexity

Generics can also lead to code that is more complex than it needs to be. It’s important to consider the trade-offs of using generics and ensure that code is easy to read and understand.

For example, using generics to implement a simple function can make the code harder to read:

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

In this example, a generic Max function is defined that can be used with any comparable type. However, this may be overkill for a simple function like this, and make the code harder to read than just defining separate functions for each data type.

Performance

Generics can also have a performance impact, especially if they are used in a way that increases memory usage or requires runtime type checks. It’s important to optimise code where necessary to ensure it runs efficiently.

For example, using a generic slice type rather than a concrete slice type can lead to higher memory usage:

type GenericSlice[T any] []T
func main() {
var gs GenericSlice[interface{}] = make([]interface{}, 0, 10)
gs = append(gs, 1)
gs = append(gs, "foo")
gs = append(gs, true)
fmt.Println(gs)
}

In this example, a GenericSlice type is defined that can be used with any data type. However, because it uses an interface{} slice internally, it will use more memory than a concrete slice type.

Conclusion

Generics have been a long requested feature in Go, and their introduction in Go 1.18 is a significant step forward in making Go more expressive and flexible. By allowing Go programmers to write generic functions and types, and express types using constraints, Go 1.18 makes it possible to write more flexible, reusable, and type-safe code, with a lot less boilerplate code.

Thank you for your interest in this article, if you like the content feel free to follow, clap and share it ❤️🙏

Further Reading

--

--