Go 1.18 added support for a long awaited feature: type parameterization — also referred to as generics. This article provides a short walk-through of the feature.
1. Motivation
As a motivating example, let’s start with a function that takes a slice of integers and removes duplicate values from it.
// UniqueInts removes duplicate integers from a slice
// and returns the new slice.
// The original slice is unusable after this operation runs.
func UniqueInts(items []int) []int {
newItems := items[:0]
seen := make(map[int]struct{})
for _, item := range items {
if _, ok := seen[item]; !ok {
newItems = append(newItems, item)
seen[item] = struct{}{}
}
}
return newItems
}
💡 Tip
|
The snippet above uses a standard pattern to filter the slice without creating a new one. See Filter Go slices without allocating if this pattern is new to you. |
This function is used like so:
items := []int{1, 2, 3, 1, 4, 2, 5, 3, 6}
items = UniqueInts(items)
// [1 2 3 4 5 6]
Take a step back and consider the core logic of the function:
seen := make(map[int]struct{}) (1)
for _, item := range items { (2)
if _, ok := seen[item]; !ok { (3)
newItems = append(newItems, item) (4)
seen[item] = struct{}{} (5)
}
}
-
Create a map to track items we’ve seen.
-
For each item in the original slice…
-
…if we haven’t already seen the item…
-
…add the item to the new slice…
-
…and mark it as seen.
There’s nothing int
-specific about this except it references int
.
We can copy-and-paste the same code,
swap out the int
for string
,
and get a UniqueStrings
function.
-
Diff
-
Result
-func UniqueInts(items []int) []int {
+func UniqueStrings(items []string) []string {
newItems := items[:0]
- seen := make(map[int]struct{})
+ seen := make(map[string]struct{})
for _, item := range items {
if _, ok := seen[item]; !ok {
newItems = append(newItems, item)
seen[item] = struct{}{}
}
}
return newItems
}
func UniqueStrings(items []string) []string {
newItems := items[:0]
seen := make(map[string]struct{})
for _, item := range items {
if _, ok := seen[item]; !ok {
newItems = append(newItems, item)
seen[item] = struct{}{}
}
}
return newItems
}
However, instead of trying to anticipate every type we need this function for, generics allow us to generalize this function so that it works with any type usable as a map key.
2. Generic functions
Let’s generalize the function to any type that can be used as a map key. Make the following change, and leave the rest of the function as-is.
-
Diff
-
Result
-func UniqueInts(items []int) []int {
+func Unique[T comparable](items []T) []T {
newItems := items[:0]
- seen := make(map[int]struct{})
+ seen := make(map[T]struct{})
for _, item := range items {
if _, ok := seen[item]; !ok {
newItems = append(newItems, item)
seen[item] = struct{}{}
}
}
return newItems
}
func Unique[T comparable](items []T) []T {
newItems := items[:0]
seen := make(map[T]struct{})
for _, item := range items {
if _, ok := seen[item]; !ok {
newItems = append(newItems, item)
seen[item] = struct{}{}
}
}
return newItems
}
There’s some new syntax here:
func Unique[T comparable](items []T) []T
The […]
next to the function defines the type parameters
expected by the function and the constraints they must satisfy — as opposed to (…)
which defines value parameters and their types.
|
type parameter |
constraint |
|
value parameter |
type |
So read [T comparable]
as,
"T is a type
that satisfies the comparable
constraint."
This constraint allows us to write code that uses T
as a map key.
You can use the new version of Unique
like so:
fmt.Println(Unique[int]([]int{1, 2, 3, 1, 4, 2, 5, 3, 6}))
// [1 2 3 4 5 6]
fmt.Println(Unique[string]([]string{"foo", "bar", "foo", "baz"}))
// [foo bar baz]
In many cases — like this one — you can omit some or all of the type parameters if the Go type system can infer them from the value parameters.
fmt.Println(Unique([]int{1, 2, 3, 1, 4, 2, 5, 3, 6}))
// [1 2 3 4 5 6]
fmt.Println(Unique([]string{"foo", "bar", "foo", "baz"}))
// [foo bar baz]
3. Generic types
Let’s build upon this. Instead of making a slice unique after the fact, we’d like to keep a collection of unique items — a set.
Before generics we would pass a map[T]struct{}
around
for different kinds of sets we need in our code.
With generics we can build a Set[T]
type
and leave the map as an implementation detail.
Let’s do that.
Declare a Set
with a T comparable
type parameter.
Inside it, declare a map[T]struct{}
field.
type Set[T comparable] struct {
items map[T]struct{}
}
Set[T comparable]
is new syntax,
but it’s similar to the previously used func Unique[T comparable](…)
.
It declares a new type Set
parameterized over any type T
that satisfies the comparable
constraint.
Add a constructor for Set[T]
.
func NewSet[T comparable]() *Set[T] {
return &Set[T]{
items: make(map[T]struct{}),
}
}
Let’s add a few methods:
-
Put
places items into the set:func (s *Set[T]) Put(item T) { s.items[item] = struct{}{} }
-
Has
checks if something exists in it:func (s *Set[T]) Has(item T) bool { _, ok := s.items[item] return ok }
-
Items
returns a slice of items in the set:func (s *Set[T]) Items() []T { items := make([]T, 0, len(s.items)) for item := range s.items { items = append(items, item) } return items }
They may be used like so:
ints := NewSet[int]()
ints.Put(1)
ints.Put(2)
ints.Put(1)
fmt.Println(ints.Has(1)) // true
fmt.Println(ints.Has(2)) // true
fmt.Println(ints.Has(3)) // false
fmt.Println(ints.Items()) // [1 2] or [2 1] (1)
-
Items
works by iterating over themap[T]struct{}
. In Go, the iteration order of a map is indeterminate, so we cannot make guarantees about the order of items in the slice.
3.1. Transformation
Suppose we have a Set[int]
and we need to build a Set[string]
from it.
The Go standard library includes strconv.Itoa
to turn an integer to a string.
package strconv
func Itoa(i int) string
It would be nice if we could do something like this:
strs := ints.Transform(func(i int) string {
return strconv.Itoa(i)
})
fmt.Println(strs.Has("1")) // true
Let’s try to implement this:
func (s *Set[T]) Transform[V comparable](fn func(T) V) *Set[V] { (1)
vs := NewSet[V]()
for item := range s.items {
vs.Put(fn(item))
}
return vs
}
-
The new method declares a new type parameter
V
, expects a function that takes aT
and returns aV
, applies that function to every element in the set, and returns aSet[V]
.
Compile it, and we’ll see.
syntax error: method must have no type parameters
That didn’t work. As of writing this, generics in Go do not support type parameters on methods. Methods can be generic only over the type parameters that the associated type defined for itself.
What does this limitation mean for Transform
?
We can’t implement Transform
as a method.
We can still implement it as a top-level function.
Design around it
Turn the Transform
method into a function
and merge the type and value parameters.
Everything else remains the same.
-
Diff
-
Result
-func (s *Set[T]) Transform[V comparable](fn func(T) V) *Set[V] {
+func Transform[T, V comparable](s *Set[T], fn func(T) V) *Set[V] {
vs := NewSet[V]()
for item := range s.items {
vs.Put(fn(item))
}
return vs
}
func Transform[T, V comparable](s *Set[T], fn func(T) V) *Set[V] {
vs := NewSet[V]()
for item := range s.items {
vs.Put(fn(item))
}
return vs
}
Use it like so:
strs := Transform(ints, func(i int) string {
return strconv.Itoa(i)
})
fmt.Println(strs.Has("1")) // true
fmt.Println(strs.Has("2")) // true
fmt.Println(strs.Has("3")) // true
3.2. Specialized methods
Let’s look at the Set.Items
method again:
func (s *Set[T]) Items() []T {
items := make([]T, 0, len(s.items))
for item := range s.items {
items = append(items, item)
}
return items
}
This has a small annoyance in its behavior as noted earlier: there are no guarantees about the order of items in the returned slice because we build the slice by iterating through the map, and the iteration order over maps in Go is indeterminate.
It would be nice if we could have a SortedItems
method
that sorts these items if they can be sorted.
Perhaps, we can use the constraints.Ordered
constraint
to determine if we can sort something,
and sort the slice with slices.Sort
.
Let’s try to implement this:
func (s *Set[T constraints.Ordered]) SortedItems() []T {
items := s.Items()
slices.Sort(items)
return items
}
Compile it, and we’ll see.
syntax error: unexpected constraints, expecting ]
That didn’t work. Generics in Go do not support methods that exist only if other additional constraints are satisfied.
Okay, how about we turn it into a top-level function like the previous section?
Design around it
Turn the method into a top-level function, turning the receiver into a parameter:
-
Diff
-
Result
-func (s *Set[T constraints.Ordered]) SortedItems() []T {
+func SortedItems[T constraints.Ordered](s *Set[T]) []T {
items := s.Items()
slices.Sort(items)
return items
}
func SortedItems[T constraints.Ordered](s *Set[T]) []T {
items := s.Items()
slices.Sort(items)
return items
}
This works now.
strs := Transform(ints, func(i int) string {
return strconv.Itoa(i)
})
fmt.Println(SortedItems(strs)) // [1 2]
We can also declare a SortedItemsFunc
function
that accepts a custom comparison criteria with
slices.SortFunc
.
func SortedItemsFunc[T comparable](s *Set[T], less func(a, b T) bool) []T {
items := s.Items()
slices.SortFunc(items, less)
return items
}
Example usage:
reverse := SortedItemsFunc(strs, func(a, b string) bool {
return b < a
})
fmt.Println(reverse) // [2 1]
4. Conclusion
In summary, generics are a powerful feature. With them, you can build functions and types parameterized over other types. You can reduce copy-paste boilerplate in specific scenarios with them. They come with some limitations, but you can design around these limitations to some degree.
My recommendation for folks new to generics is this: use them when you need them, but don’t look for places to use them. Or, quoting Ian Lance Taylor:
write Go programs by writing code, not by defining types
When To Use Generics