PROGRAMMING

Go Generics: Tips, Tricks, and Pitfalls

Delve into the transformative power of Go generics, offering developers an experience report to harness their potential.

Corin Lawson
Versent Tech Blog

--

This image was created with the assistance of DALL·E 3

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 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:

type Getter interface {
Get(string) (any, bool)
}
type Putter interface {
Put(string, any) error
}
type Deleter interface {
Delete(string)
}

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:

type mockCache struct {
getFunc func(string) (any, bool)
putFunc func(string, any) error
deleteFunc func(string)
}

func (m *mockCache) Get(key string) (value any, ok bool) {
return m.getFunc(key)
}

func (m *mockCache) Put(key string, value any) error {
return m.putFunc(key, value)
}

func (m *mockCache) Delete(key string) {
m.deleteFunc(key)
}

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:

func TestReadConfig(t *testing.T) {
// Setup
store := config.New(config.WithCache(&mockCache{
getFunc: func(name string) (any, error) {
if name != "test" {
t.Errorf("expected %v, got %v", "test", name)
}
return "test", nil
},
}))
params := ...

// Call the code under test
responder := ReadConfig(store, params)

// Verify the response
...
}

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 may use some or all of the following techniques to ensure full verification:

  • introduce a boolean flag to verify that a function is not called
  • introduce a counter to verify the number of calls
  • introduce a slice of functions to change behaviour between successive calls
  • introduce a map of functions, keyed by one or a combination of the functions parameters, to verify calls that may occur in a non-deterministic order
  • introduce synchronisation primitives (i.e. mutex) to coordinate multiple goroutines

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.

func TestReadConfig(t *testing.T) {
// Setup
cache := mock.New(
ExpectGet(func(t testing.TB, name string) (any, error) {
...
}),
)
...
// Verify the response
mock.AssertExpectedCalls(t, cache)
...
}

This example uses New to create and configure a mock object by taking any number of functions that change the behaviour of the new mock object (see Option type below). For instance, just as we set getFunc in the naive approach, we use 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. Finally, 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:

func New[T any](...) *T {
object := new(T)
...
return object
}

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:

type Option[T any] func(*T)

func New[T any](options ...Option[T]) *T { ... }

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.

func ExpectGet(delegate func(string) (any, error)) mock.Option[mockCache] {
return mock.Expect[mockCache]("Get", delegate)
}

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.

func (m *mockCache) Get(key string) (any, bool) {
return mock.Call2[any, bool](m, "Get", key)
}

func (m *mockCache) Put(key string, value any) error {
return mock.Call1[error](m, "Put", key, value)
}

func (m *mockCache) Delete(key string) {
mock.Call0(m, "Delete", key)
}

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.

func Call0[T any](m *T, name string, in ...any)
func Call1[T1, T any](m *T, name string, in ...any) (v T1)
func Call2[T1, T2, T any](m *T, name string, in ...any) (v1 T1, v2 T2)

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

func New[T any](...) *T {
object := new(T)
special, ok := object.(Special) // invalid operation: object (variable of type *T) is
// not an interface
special, ok := any(object).(Special) // 👌

Final Thoughts

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.

--

--

Corin Lawson
Versent Tech Blog

I share my insights on technology trends, programming best practices, and lessons learned from my experiences in software development.