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")