How do you change a package that uses strings to generic types?
Like many I have packages that work with a single type — but solve a general
problem. In this article we look at how convert these to use generics.
Introducing the protagonist
The package has a single public type, a constructor like function, and a few methods.
type Unique
func New() *Unique
func (s *Unique) Distinct() []string
func (s *Unique) Set(key, value string)
func (s *Unique) Clear(key string)
The objective is to change the key parameter to a generic type such that it
can be an int, uint, float64, string, and so on.
Prior to generics, it was required to introduce a compromise—in terms of
type-safety with the empty any type, as a performance and memory penalty by
extra layers of indirection. Or as code duplication by copy-paste similar
implementations.
Before we continue, take a closer look at the current package.
Use New to instantiate local state and Set to create references from keys to
values.
names := unique.New()
names.Set("key-1", "Francis")
names.Set("key-2", "Frida")
names.Set("key-3", "Frida")
Call Distinct() to retrieve a slice of unique values.
values := names.Distinct()
fmt.Printf("%q\n", values)
// Output: ["Francis" "Frida"]
And Clear to value reference for a given key.
names.Clear("key-1")
The goal is to make Set and Clear accept any comparable key parameter type—
and in the next section, we look at how to achieve that.
Adapting generics
How can make methods accept generic type parameters?
There is a new builtin comparable type.
It's an interface implemented by all comparable types. A comparable type
supports the operations == and !=. In Go comparable types are booleans,
numbers, strings, pointers, channels, arrays of comparable types, and structs
whose fields are all comparable types.
To change Clear and Set's signature so that the key parameter accepts types that
implement comparable — we can try this:
func (s *Unique) Clear(key comparable)
func (s *Unique) Set(key comparable, value string)
Yet that doesn't work.
The comparable interface may only be used as a type parameter constraint, not as the type of a variable.
What are type parameters and parameter constraints?
In Go 1.18 the syntax for a function and type declaration accept type parameters. A function or type may include a list of type parameters. The list of type parameters looks like function parameters except that they are enclosed in square brackets. Type parameters limit the set of types you can instantiate a function or type with.
With that in mind, we add the comparable interface type as a type parameter
K for Clear and Set
func (s *Unique) Set[K comparable](key K, value string)
func (s *Unique) Clear[K comparable](key K)
The compiler still complains!
type parameters are not supported on methods
The spec says they are supported on functions and types. Clear and Set are
methods. The type parameter and parameter constraint must be on the type the
methods are bound to.
type Unique[K comparable] struct {
...
}
The methods can then reference K and use it as a generic type for the key
parameter.
func (s *Unique[K]) Set(key K, value string)
func (s *Unique[K]) Clear(key K)
Notice how Unique's definition changed as well. Finally, the parameterized
type can be instantiated with a comparable type—like an int in the example
below
u := &unique.Unique[int]{}
When u is used as a receiver for Set and Clear they expect that the key
argument is an int. Success! The package is changed and the key parameter can
be any type that satisfies the comparable interface.
There is one more problem. What about the New function?
Solving the constructor problem
A common idiom is to have a constructor function that returns a pointer to an instance of a type, with required properties instantiated. It is especially useful with maps and other types that are allocated on the heap.
func New() *Unique
How to instantiate New with type arguments so it
- Returns a parameterized pointer to
Unique - Allocates internal structures with the type parameter
Define New so that it requires a type parameter P that implements the
comparable interface. Then pass P as a type argument to instantiate
Unique and internal structures, such as a map of keys. Finally, change
the return type to be a pointer to a parameterized instance (*Unique[P]).
This is easier to understand with code.
func New[P comparable]() *Unique[P] {
return &Unique[P]{
ids: make(map[P]uint),
...
}
}
At last, the constructor returns an instance of the generic type.
In a nutshell
The package is changed so that Set and Clear accept parameters that satisfy
the builtin comparable interface, the constructor is updated to return a
parameterized instance of the type.
Usage with an int as key.
names := unique.New[int]()
names.Set(1, "Frida")
Or a string.
names := unique.New[string]()
names.Set("key-1", "Frida")