Go Generics: Tips, Tricks, and Pitfalls

Corin Lawson Software Engineer

Corin Lawson

Software Engineer

December 13, 2023

Setting the Stage

Right off the bat, I recommend reading the official Go blog article that introduces generics, it’s authoritative and to the point, which this article is neither. Instead, I want to add some colour to Go generics with a little of my personal experience.

Let’s begin by talking about mocking: I have no argument that disciplined use of mocking objects is best practice, but more often than not, I am frustrated with the existing options for mocking Go interfaces. The Go ecosystem offers a surprising variety of options for generating, building, and integrating mocks into your favourite test rig. Personally, I find that they have numerous and complicated options, and cumbersome APIs. Usually they have at least one limitation, for example they don’t work well with a composite of small interfaces, or dealing with high ordered functions is mind-meltingly difficult. I yearn for a mock that will simply and enthusiastically accept a function that can be tailored to any bespoke test case. So, let’s see if generics will help.

Mocking in Go: A Naive Approach

Consider these simple interfaces for a cache:

Already we see something new: the any type. Not strictly generics, but it was introduced in the same release, v1.18. It is simply an alias for interface{}, and easier to type. Consider this quick-and-dirty mock implementation:

This implementation offers a high degree of flexibility for each test, for example, we can pass a literal mockCache to a fictional store that is under test:

In other words, the test code has control over the code that runs in the mocked object during the test. It also has access to anything in the test fixture that the test author may want accessible. Of course, there are drawbacks too. If the mocked function is called multiple times then things get complicated quickly; or if the function is not called at all, when expected, then a multitude of flags or counters start popping up. More elaborate solutions will maintain counters and a slice of functions instead of a single delegate function. These solutions become extremely repetitive, where only the function name and signature vary.

A Fresh Approach: Introducing the mock Package

Imagine creating a mock object as effortlessly as our literal mockCache. With our future mock package, it should be natural to specify multiple delegates as it is to specify one (or none!) E.g.

This example uses New to create a mock object and ExpectGet to configure the mock object to run our delegate. The delegate now receives a testing.TB value, which allows us to potentially abstract and/or reuse the delegate or mock object. The AssertExpectedCalls function ensures all expectations are met.

The value returned by New must satisfy an interface from the package under test. In order to avoid the type assertion we write New as a generic function:

Caution Ahead: The Limitations of Go Generics

Here we encounter our first limitation of Go’s type inference; the compiler never uses result type information to infer type parameters (at the time of writing). This means that when we instantiate the method (by calling it, for example), the type parameter must be explicitly provided in square brackets ([, ]), i.e. mock.New[mockCache](). The language designers have erred on the side of caution and introduced somewhat arbitrary rules to ensure that the type inference is never ambiguous and also prioritise a fast and simple compiler. However, it must be said, Go is noticeably slower to compile generic code (at the time of writing). In this case, however, we can use the type parameter in the input parameters by adopting the Functional Options pattern:

In this way all the options given to New work together to produce a value of a single type. But now our ExpectGet function hides the explicit type parameter. It also hides the stringiness of the mock package, because to handle delegates in the general case we look them up by name.

It’s important to realise that generics do not remove the need for reflection or type assertions. One of the limitations of generics is that some type sets cannot be expressed. In this instance, it’s not possible to express the set of all function types. Therefore we must use the any type (a.k.a. interface{}) to represent our delegate function in the general case. In order to then call the function we must use reflection. We hide all the messy reflection in a set of helper functions and our mock implementation could easily be written either by a code generator or manually.

These functions, Call0, Call1, Call2, etc., are further instances of generic methods. Notice that the type parameter for mock object is after the type parameters for the result parameters (unfortunately, or perhaps fortunately, there is no variadic form for result parameters). This allows the caller to omit the type parameter and let it be inferred by the compiler.

Just as reflection still has its place, so do type assertions and type switches. Be wary though, a variable of a generic type cannot be the operand of an assert. You must first convert it to the any type:

Final Thought

Go generics is a much-anticipated language feature, however, they come with their own set of challenges. To recap:

  • Type Inference: You’ll sometimes need to specify types explicitly.
  • Compilation Speed: Generics can slow down the compilation process.
  • Reflection, Interfaces and Type Assertions: Generics don’t eliminate the need for these.

Remember, every tool has its limitations, but understanding them helps you use the tool more effectively.

If you’re keen to dive deeper, check out the full implementation of the mock package on Github: http://github.com/Versent/go-mock.

Next Steps

  • Try It Out: Write a generic function or add mock to your next project.
  • Share Your Experience: Leave a comment to share your insights and challenges.
  • Stay Updated: Follow and star Github repo for updates and new features.
Share

Great Tech-Spectations

Great Tech-Spectations

The Versent & AWS Great Tech-Spectations report explores how Aussies feel about tech in their everyday lives and how it measures up to expectations. Download the report now for a blueprint on how to meet consumer’s growing demands.