Let’s talk about generics
Some days ago there was a long discussion about generics on one of the Discord servers that I frequent. It was rather topical: Go is getting support for generic typing in 1.18, and the question arose: What are they good for? It's a divisive issue for sure - half the developers rejoiced that generics are finally landing, while the other half either hate it, or just don't understand why Go needs generics.
I've been writing code in a couple of languages in the past decade, with both languages that have generics and some that don't, so I had my fair share of run ins with this feature set.
I don't want to go into the question of why they are being added to Go, because I haven't followed the Go developer space for quite some time, and frankly, I don't know all the reasons.
What I would like to do, however, is try to shed some light on what generics are, and what you can use them for.
What are generics?
When we're talking about generics, we usually mean that something is generic over something else. It's usually about functions being generic over some input parameters, or structs / types / classes being generic over some of their fields.
But what does that mean?
It means slightly different things in different contexts, and we'll explore each of them in a bit.
But first, let me share a useful framing device: When designing a function, or writing up a struct or a class, it's useful to think about what kind of restrictions we're putting into place for our users (other developers, in this case), because our choices can mean the difference between a good API that is easy to use, and an API you wish you weren't forced to use.
On to the main course; I categorize generics into 4 different types:
- Concretions
- Slot generics
- Generics over functionality
- Full generics
Concretions
All right, I admit, concretions aren't generics. In fact, they're exactly the opposite. I still wanted to include it, because if we look at a scale of "how generic is this thing", concretions are at the absolute start: none at all.
For example, imagine we have a web app and have created this struct to help us return results over the wire:
type Result struct {
Status uint
Payload string
}
We have a struct, which has a uint
and a string
field. This is a concretion, because we are restricted to only using a uint
and a string
for those fields, respectively. In other words, as the designer of this struct, we have restricted our users to these two types.
What if we wanted to return a list of items? Well:
type ListResult struct {
Status uint
Payload []string
}
Though this is better, we're still looking at a concretion: we can only return list of strings. If we wanted to return a list of, say, numbers, we'd need a NumberListResult
.
Having a non-trivial application, we can see where this may lead us: A proliferation of structs that are each good for one specific purpose.
This can get considerably worse if we're a library author and want to provide some convenience functions on instantiating our numerous Result
structs. We can easily explode the codebase to handle all the use cases we can think of.
And then there's the domain of use cases that we can't think of. What if our users want to have a MapStringStringResult
, and we absolutely loath using map
s for anything, so much so that our brain ignores the existence of maps? There's little chance that we'd have a constructor function for that use-case.
Let's look at how Generics can help us solve this problem.
Slot generics
When I say slot generics, I mean that a struct (or class) is generic over one or more of the fields it contains. I call this slot generics, because we're specifying one or more slots which can hold a value of a type that will be specified later.
To go back to our previous Result
example, we could have a generic version such that:
type Result[T any] struct {
Status uint
Payload T
}
This is weird syntax, but it's rather straightforward. In the above example, T
is not a concrete type, it's an identifier, or placeholder for a type. You can read this type declaration the following way:
Result is a struct which has a generic typeT
. It has a fieldStatus
of typeuint
, and a fieldPayload
, of type whateverT
will be whenResult
is constructed.
T
can be any
thing. More on this later.How does one construct a generic Result
?
package main
import "fmt"
type Result[P any] struct {
Status uint
Payload P
}
func main() {
// 👇 important bit
result := Result[string]{
Status: 200,
Payload: "Hello there",
}
fmt.Println(result)
// 👇 also important bit
result2 := Result[[]string]{
Status: 200,
Payload: []string{"Hello there", "General Kenobi"},
}
fmt.Println(result2)
}
// output
{200 Hello there}
{200 [Hello there General Kenobi]}
Above we've constructed a Result
struct with both a string
and a []string
. The syntax may seem weird again, but it's as simple as the type declaration was:
Result[string]
is aResult[T any]
whereT = string
Result[[]string]
is aResult[T any]
whereT = []string
The best part is since T
can be any
thing, we can have nested generics (if we want, of course):
But wait! Go already has the empty interface (interface{}
), why do we need generics? We could just plop in interface{}
and get the same results, right?
Yep, same result. Does this mean that Generics is just a more verbose syntactic sugar (or in this case.. salt?) for using interface{}
?
Not exactly. Generics has two aces up its sleeve, and to show that, let's first see what happens here:
package main
import "fmt"
type IResult struct {
Status uint
Payload interface{}
// 👆 The interface version
}
func main() {
iresult3 := IResult{
Status: 301,
// 👇 When instantiating, Payload gets an IResult as its value
Payload: IResult{
Status: 404,
Payload: 404,
},
}
fmt.Println(iresult3)
// 👇 Overwriting the IResult with a string
iresult3.Payload = "oh no"
fmt.Println(iresult3)
}
In this instance, we're using the empty interface. Is this valid Go code? Let's ask the compiler:
$ go run prog.go
{301 {404 404}}
{301 hehe}
...yep. Alright, moving to the one with generics:
package main
import "fmt"
type Result[T any] struct {
Status uint
Payload T
// 👆 The generic version
}
func main() {
// 👇 Typing it to an embedded Result
iresult3 := Result[Result[uint]]{
Status: 301,
Payload: Result[uint]{
Status: 404,
Payload: 404,
},
}
fmt.Println(iresult3)
// 👇 Trying to overwrite it with a string
iresult3.Payload = "oh no"
fmt.Println(iresult3)
}
And the compiler says...
./prog.go:20:21: cannot use "oh no" (untyped string constant) as Result[uint] value in assignment
Go build failed.
Oh no. The compiler yelled at me. And it feels good (your mileage may vary).
What's the difference here? Well, the empty interface, per the wonderful Tour of Go:
...may hold values of any type. (Every type implements at least zero methods.)
That's the absolute opposite of using a concrete type. While concretions restricts our users (devs) to using one specific type, the empty interface allows anything, even changing the value to another type within the same struct! And that's dangerous.
We can thus change the value of a uint
to a value of a string
, because they are both interface{}
types. The compiler is happy - we're still upholding the restrictions we've put in place.
Another drawback of using the empty interface for this is that once we put a type in, we have lost information about what that is. We can of course get it back later by doing type assertions, and, sure, it works:
package main
import "fmt"
type IResult struct {
Status uint
Payload interface{}
// 👆 hello interface
}
func main() {
iresult3 := IResult{
Status: 301,
Payload: "hehe",
}
fmt.Println(iresult3)
// Type assertion right here 👇
val, ok := iresult3.Payload.(string)
if ok {
fmt.Println("it's a string: ", val)
}
}
// result
{301 hehe}
it's a string: hehe
But we've essentially delegated type checking to happen at runtime instead of at compile time, which can be the source of issues that the compiler could catch for us.
This is one of the points where generics shine: As soon as we create a Result[string]
, the type of Payload
for that struct will always be string
. And it is a string
, through and through:
type Result[T any] struct {
Status uint
Payload T
// 👆 generic type
}
func main() {
// 👇 instantiating with a string
result3 := Result[string]{
Status: 200,
Payload: "yep",
}
list := make([]string, 0)
// 👇 Adding Payload to a slice of strings
list = append(list, result3.Payload)
fmt.Println(list)
}
// output
[yep]
We couldn't say the same for the empty interface version without doing a type assertion:
./prog.go:26:22: cannot use iresult3.Payload (variable of type interface{}) as type string in argument to append: need type assertion
The other thing that generics lets us do is that once a T
is specified, anything that has that type parameter must be of that type.
For example, let's look at the following:
package main
import "fmt"
type Pair[T any] struct {
First T
Second T
// 👆 Yes, you can use the same type parameter for multiple fields
}
func main() {
// 👇 T = string, so both First and Second are string
p := Pair[string]{
First: "Ball",
Second: "Food",
}
fmt.Println(p)
}
// output
{Ball Food}
Once we instantiate Pair[string]
, both First
and Second
within Pair
has to be a string
. We can't do p.Second = 142
, because we'll be yelled at again:
./prog.go:31:13: cannot use 142 (untyped int constant) as string value in assignment
This is tremendously useful, and a huge benefit over using interface{}
for both fields, as that would allow any combination of types both at instantiation and later down the struct's life.
One more thing
Go already has slot generics, all the way back from day one. I'm sure every go programmer has used them already, especially since it's part of the Tour of Go as well.
Arrays (and by extension, slices) and maps are both generic types in Go. They fit our restrictions of slot generics perfectly, although they have different syntax to what Go is getting with 1.18:
// Arrays
// primes is an array with 6 int elements
primes := [6]int{2, 3, 5, 7, 11, 13}
// names is an array with 2 string elements
names := [2]string{"Alice", "Bob"}
Arrays specify the length of its elements, as well as the type of said elements. The same holds true for maps as well:
// Maps
type Vertex struct {
Lat, Long float64
}
// m is a map which has string keys and Vertex values
var m = map[string]Vertex{
"Bell Labs": Vertex{
40.68433, -74.39967,
},
"Google": Vertex{
37.42202, -122.08408,
},
}
All in all, Go 1.18 is getting the same kind of flexibility that arrays, slices, and maps already had from day one (with regards to specifying the types at instantiation).
Generic over functionality
We can call this generics just as much as we can call concretions generics. Not really.
It's interfaces. Interfaces aren't considered to be generic, but in some sense, when they are used, they kinda are.
Going back to our previous example, what if all we cared about for the payload is that it can be turned into a string
?
package main
import "fmt"
type SResult struct {
Status uint
Payload fmt.Stringer
// 👆 A wild interface appears!
}
type Msg struct {
message string
}
// 👇 Msg implements fmt.Stringer because of this.
func (m Msg) String() string {
return m.message
}
func main() {
result := SResult{
Status: 500,
Payload: Msg{message: "Internal Server Error"},
}
fmt.Println("result is:", result)
}
// output
result is: {500 Internal Server Error}
Naturally, the above is a silly example, but it shows that interfaces can also be used for typing our structs. In that case, we're essentially telling the compiler (and our users [devs]), that we don't care what's in there, as long as it implements the Stringer
interface.
That's a form of generics, in my book. The SResult
struct above is generic over the Stringer
interface. From a restrictions standpoint, we've restricted the allowed types to only those that fulfill the Stringer
contract. The compiler will check this for us, and the compiler will yell at people to provide what is required if they want the code to compile.
I don't really want to dwell too much on this - Go has this feature, and I believe it's well understood by pretty much all the devs. Nevertheless I felt that it was an important point to make, because...
Full generics
What a segue
Remember that 13 Page-Ups ago, I mentioned that
We also see thatT
can beany
thing. More on this later.
The best part about generics is how easy it is to further restrict what our code can do. For example, to get the same functionality as the SResult
above (that Payload
must be a type that implements fmt.Stringer
), we can do:
package main
import "fmt"
// 👇 This is the only part that's different from the above example
type SResult[T fmt.Stringer] struct {
Status uint
Payload T
}
type Msg struct {
message string
}
func (m Msg) String() string {
return m.message
}
func main() {
// 👇 Okay, and this as well
result := SResult[Msg]{
Status: 500,
Payload: Msg{message: "Internal Server Error"},
}
fmt.Println("result is:", result)
}
// output
result is: {500 Internal Server Error}
It's the same thing, we just moved some characters around. Some languages allow for a bit more powerful features when it comes to generics, for example, Rust can require that a type fulfills more than one trait (which for our purposes, are kinda like interfaces)
use std::cmp::Ord;
use std::fmt::Display;
// 👇 T must implement both Display (~fmt.Stringer) and Ord (can be ordered)
struct SResult<T: Display + Ord> {
status: usize,
payload: T,
}
fn main() {
let r = SResult {
status: 200,
payload: "Success!",
// 👆 &str implements both Display and Ord, so can be used here
};
}
Unfortunately, at the moment Go doesn't seem to allow more complex restrictions, such as specifying that the type must implement more than one interfaces (or I haven't found how to do it yet). I'm hopeful it'll be possible though, as this can be rather useful.
What it does allow, is creating type unions:
package main
import (
"fmt"
)
// 👇 Number is either a uint, an int, or a float64
type Number interface {
uint | int | float64
}
type SResult[T Number] struct {
Status uint
Payload T
}
func main() {
// 👇 in this case, we type it as an int
result1 := SResult[int]{
Status: 500,
Payload: -10,
}
fmt.Println("result1 is:", result1)
// 👇 in this case, we type it as a float64
result2 := SResult[float64]{
Status: 500,
Payload: 10.0,
}
fmt.Println("result2 is:", result2)
// 👇 in this case, we type it as a uint
result3 := SResult[uint]{
Status: 500,
Payload: uint(10),
}
fmt.Println("result3 is:", result3)
}
// output
result1 is: {500 -10}
result2 is: {500 10}
result3 is: {500 10}
..but as soon as we try to use a type that is not one of the ones we specified:
result1 := SResult[string]{
Status: 500,
Payload: "oh no",
}
fmt.Println("result1 is:", result1)
// output
./prog.go:17:21: string does not implement Number
This is important - the compiler is yelling at us again. But how is this useful? Well, one thing I haven't talked about yet is that functions can be generic as well:
Two powerful mechanisms are at work here:
Type restriction on the arguments
Add
is generic over its input arguments. It requires that both a
, b
, and its return type is a Number
. In addition, it is also required that a
, b
, and the return type must be of the same type. For example, the following causes a compile error, even though both uint
and int
satisfy the Number
interface:
Add(uint(10), int(39))
// output:
./prog.go:20:16: type int of int(39) does not match inferred type uint for T
And that's huge. It allows us to move a myriad of type checking from runtime to compile time, as well as writing code that can leverage such restrictions, just like the silly little example for Add
above.
Type inference
Have you noticed that Go's type inference leapt into action? There's three things we didn't have to do:
- Specify what types we're calling
Add
with. The compiler was able to deduce that since we're invoking it with a valid type, we don't have to explicitly state the type like with the struct versions (Yes, I did have to adduint
, but that was to make sure we're using that version of the function). - In case where we had to cast one of the arguments (
uint(10)
), we didn't have to do the same for the second. The compiler figured out that sinceAdd
requires that both its arguments have to beT
, the second argument must also be auint
, so it did the "cast" for us. - The return value is typed accordingly as well, as can be seen from the output, so that was inferred for us as well. No need for type assertions 🎉
Some closing thoughts
I hope by this point you can see just how powerful generics can be, if used in the right places. Not all places are good for generics, mind you, sometimes all we need is a sturdy type and that's it. Other times we can use interfaces, but there are some situations where using generics is just a better approach altogether.
Go's map
does this as well, and it even uses some restrictions. If we read through the post about maps on the official Go language blog, we'll see the following section:
A Go map type looks like this:map[KeyType]ValueType
whereKeyType
may be any type that is comparable (more on this later), andValueType
may be any type at all, including another map!
...later on (emphasis mine):
As mentioned earlier, map keys may be of any type that is comparable. The language spec defines this precisely, but in short, comparable types are boolean, numeric, string, pointer, channel, and interface types, and structs or arrays that contain only those types. Notably absent from the list are slices, maps, and functions; these types cannot be compared using ==
, and may not be used as map keys.
This sounds a lot like generics to me. You can use anything for the KeyType as long as those things can be compared to one another, and everything goes for the values.
I'd argue Go's maps are powerful. I'd argue that the doors generics open are also powerful.
Lastly, one thing I didn't mention, but is an important notion: You can have as many generic types on a struct / function as you want, and have different sets of restrictions for each of them. This is fair game:
type Tuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any] struct {
First T1
Second T2
Third T3
Fourth T4
Fifth T5
Sixth T6
}
Of course, you'd probably want to stop at one or two generic types, anything above that can become hard to understand. As with any tools, one can go overboard and abuse it, generics isn't immune to that.
Code responsibly, and use the right tools for the job.
Until next time 👋