Generics in Go

Martin Sulzmann

Overview

Recent versions of Go support generics.

To get started here is some material provided by the Go development team.

Below you find further material

Why generics

“Generics” allow to abstract over type parameters.

Thus, we can write more type-safe programs.

Without generics

Some collection.

type pair struct {
    left  interface{}
    right interface{}
}

Some convenience functions.

func first(x pair) interface{} {
    return x.left
}

func second(x pair) interface{} {
    return x.right
}

Some application.

    var s string
    var f float32

    s = "Erdinger"
    f = 0.9

    product := pair{s, f}

    var price float32

    price = second(product).(float32) // Type assertions!

Type assertions may fail!

price = first(product).(float32)

With generics

Generic collection.

type pairG[T any, S any] struct {
    left  T
    right S
}

Making the above code more type-safe.

    var s string
    var f float32

    s = "Erdinger"
    f = 0.9

    product := pairG[string, float32]{s, f}

    var price float32

    price = secondG(product)

Consider

    price = secondG(product)

Complete source code

package main

import (
    "fmt"
)

type pair struct {
    left  interface{}
    right interface{}
}

func first(x pair) interface{} {
    return x.left
}

func second(x pair) interface{} {
    return x.right
}

func testPair() {

    var s string
    var f float32

    s = "Erdinger"
    f = 0.9

    product := pair{s, f}
    // string <= interface{}
    // float32 <= interface{}

    var price float32

    price = second(product).(float32) // Type assertions!
    // price = first(product).(float32)
    // Type checks but fails at run-time.

    fmt.Printf("%f", price)

}

// Generics

type pairG[T any, S any] struct {
    left  T
    right S
}

func firstG[T any, S any](x pairG[T, S]) T {
    return x.left
}

func secondG[T any, S any](x pairG[T, S]) S {
    return x.right
}

func testPairG() {

    var s string
    var f float32

    s = "Erdinger"
    f = 0.9

    product := pairG[string, float32]{s, f}

    var price float32

    price = secondG(product)
    // price = firstG(product)
    // Fails to type check!

    fmt.Printf("%f", price)

}

func main() {
    testPair()
    testPairG()
}

Basic examples

Bounded type parameters (with trivial bounds)

func swap[T any](x *T, y *T) {
    tmp := *x
    *x = *y
    *y = tmp
}

The generic swap function.

T is a type parameter.

In Go, type parameters always come with a bound (aka constraint).

We refer to this as bounded type parameters (or bounded types for short). Constrained type parameters is another appropriate name.

In the above example, T is constrained by any where any is a short-hand for interface{} (the empty interface).

Any type satisfies the empty interface. Hence, we can swap values of any type.

Type instances

We can use the swap function to swap Integer values, Boolean values, …

To do so, we need to explicitely provide the type instance.

    x := 1 // x is of type int
    y := 3

    swap[int](&x, &y)

In the above, we wish to swap int values.

The program text swap[int] states that the type parameter T in the definition of swap will be instantiated by int.

Interfaces

Interfaces are another means to write generic functions in Go.

type Show interface {
    show() string
}

type boolean struct {
    val bool
}

func (this boolean) show() string {
    if this.val {
        return "true"
    } else {
        return "false"
    }
}

type number struct {
    val int
}

func (this number) show() string {
    return strconv.Itoa(this.val)
}

The receiver types boolean and number implement the Show interface.

Interfaces are types.

Hence, we can argue that for example a value of type boolean can be used in a context where we expect a value of type Show.

This is referred to as structural subtyping.

Consider the following generic showTwice1 function.

func showTwice1(x Show, y Show) {
    fmt.Printf("\n %s", x.show()+y.show())
}

func testShow() {
    n := number{1}
    b := boolean{true}
    // We write number <= Show to denote that number is a structural subtype of Show.
    // Similarly, we write boolean <= Show.

    showTwice1(n, b)
    showTwice1(n, n)
    showTwice1(b, b)

}

Function showTwice1 is generic because we can apply this function on numbers and booleans.

Bounded type parameters (non-trivial bound)

We take a look at the combination of interfaces and type parameters.

Consider the following generic showTwice2 function.

func showTwice2[T Show](x T, y T) {
    fmt.Printf("\n %s", x.show()+y.show())
}

We find type parameter T where any type instance must satisfy the Show interface.

Consider

func testShow() {
    n := number{1}
    b := boolean{true}

    showTwice2(n, n)
    showTwice2[boolean](b, b)

}

We know that number and boolean both satisfy Show.

Hence, we can call showTwice2 on numbers and booleans.

Go supports simple forms of type inference that extends to type instances.

For example, in the call showTwice2(n, n). Type instance [int] is automatically inferred.

The call showTwice2(n,b) is not accepted. Why?

Type parameter T must be either instantiated to number or boolean. If we pick number, the second argument b leads to a type error. If we pick boolean, the first argument n leads to a type error.

Type sets

Consider

type intOrfloat interface {
    int | float32
}

The above describes the union of types int and float32. Hence, we call this a type set.

Type sets can be used in type bounds.

func myAdd[T intOrfloat](x T, y T) T {
    return x + y
}

However, there’s no value of type intOrfloat and therefore the following variant is rejected

func myAdd[T intOrfloat](x T, y T) intOrfloat {
    return x + y
}

Points to note:

We can use user-defined types such as structs in type sets.

type intOrfloat2 interface {
    int | float32 | number
}

We cannot use interface types in type sets. The following is rejected

type intOrfloat3 interface {
    int | float32 | Show
}

Complete source code

package main

import (
    "fmt"
    "strconv"
)

// Generic swap function.
// Points to note.
//
// 1. T is a type parameter.
// 2. Type parameter come with a bound (aka constraint).
// 3. Bounds are types.
// 4. "any" is a short-hand for "interface{}".
// 5. Any type T satisfies the empty interface.
// 6. Hence, we can swap values of any type.

func swap[T any](x *T, y *T) {
    tmp := *x
    *x = *y
    *y = tmp
}

func testSwap() {
    x := 1
    y := 3

    fmt.Printf("\n x = %d, y = %d", x, y)

    swap[int](&x, &y)

    fmt.Printf("\n x = %d, y = %d", x, y)

}

// Showing values by turning them into strings.
type Show interface {
    show() string
}

type boolean struct {
    val bool
}

func (this boolean) show() string {
    if this.val {
        return "true"
    } else {
        return "false"
    }
}

type number struct {
    val int
}

func (this number) show() string {
    return strconv.Itoa(this.val)
}

// Interfaces are types.
// Valid arguments are values of some type that implement the Show interface.
func showTwice1(x Show, y Show) {
    fmt.Printf("\n %s", x.show()+y.show())
}

// Type parameter T is constrained by Show.
func showTwice2[T Show](x T, y T) {
    fmt.Printf("\n %s", x.show()+y.show())
}

func testShow() {
    n := number{1}
    b := boolean{true}

    showTwice1(n, b)
    showTwice1(n, n)
    showTwice1(b, b)

    // Not accepted, can you guess why?
    // showTwice2(n,b)
    showTwice2(n, n)
    showTwice2[boolean](b, b)

}

// Type sets.

type intOrfloat interface {
    int | float32
}

func myAdd[T intOrfloat](x T, y T) T {
    return x + y
}

/*
// There's no value of type intOrfloat.

func myAdd2[T intOrfloat](x T, y T) intOrfloat {
    return x + y
}
*/

func testAdd() {
    fmt.Printf("\n %d", myAdd[int](1, 2))
    fmt.Printf("\n %f", myAdd[float32](1, 2.0))

    /*
       // x is of type int, there's no automatic conversion to float32 in Go.
        x := 1
        fmt.Printf("\n %f", myAdd[float32](x,2.0))
    */

}

func main() {
    testSwap()
    testShow()
    testAdd()

}

Limitations

Generic methods

The current implementation (applies to Go 1.18 and 1.19) does not quite follow the design that has been described in Featherweight Go (appeared in OOPSLA’21).

For example, if we want to make pairs showable, the “Show” constraint needs to be included in the definition of pairs.

type pair[T Show, S Show] struct {
    left T
    right S
}

func (this pair[T,S]) show() string {
        return "(" + this.left.show() + "," + this.right.show() + ")"
}

This implies that we can only build pairs whose components are showable.

The design proposed in Featherweight Go allows for the following (more flexible) program code.

type ppair[T any, S any] struct {
    left T
    right S
}

func (this ppair[T Show,S Show]) show() string {
        return "(" + this.left.show() + "," + this.right.show() + ")"
}

However, the above method definition is rejected by Go 1.18 and 1.19.

Type assertions

Once you have generics, you might not want to use type assertions at all! However, there may be reasons use them (reflection, …).

The following code is valid.

func assert1(x any) {
    y := x.(boolean)
    fmt.Printf("\n%s", y.show())
}

The following code is invalid.

func assert2[T any](x T) {
    y := x.(boolean)
    fmt.Printf("\n%s",y.show())
}

The Go compiler reports:

invalid operation: cannot use type assertion on type parameter value x (variable of type T constrained by any)

But there is no real reason why both shouldn’t be accepted.

Complete source code

package main

import "fmt"

// Generics in Go as implemented have some limitations.

///////////////////////////////////////////////////////
// Showing values by turning them into strings.

type Show interface {
    show() string
}

type boolean struct {
    val bool
}

func (this boolean) show() string {
    if this.val {
        return "true"
    } else {
        return "false"
    }
}

type pair[T Show, S Show] struct {
    left  T
    right S
}

func (this pair[T, S]) show() string {
    return "(" + this.left.show() + "," + this.right.show() + ")"
}

func testShow() {
    p := pair[boolean, boolean]{boolean{true}, boolean{false}}
    fmt.Printf("\n %s", p.show())

}

// Point to note.
// Pairs are only showable (by defining the show method),
// if we constraint the left and right component to implement the Show interface.

type ppair[T any, S any] struct {
    left  T
    right S
}

/*

The following is not accepted.
In a method definition, we cannot "specialize" type bounds.

func (this ppair[T Show,S Show]) show() string {
        return "(" + this.left.show() + "," + this.right.show() + ")"
}

*/

// On the other hand, this is not a problem for function definitions.
// Hence, the current Go design feels uneven.

func showFunc[T Show, S Show](this ppair[T, S]) string {
    return "(" + this.left.show() + "," + this.right.show() + ")"
}

func testShowFunc() {
    p := ppair[boolean, boolean]{boolean{true}, boolean{false}}
    fmt.Printf("\n %s", showFunc[boolean, boolean](p))

}

// The use of type sets is strictly limited to built-in primitives.

func plus3[T int | float32 | float64](x T, y T, z T) T {
    return x + y + z
}

type onOff struct {
    val bool
}

func (this onOff) show() string {
    if this.val {
        return "On"
    } else {
        return "Off"
    }
}

// The following is rejected.
// func showOnlyOnOffandPair[T boolean | onOff](x T) string { return x.show() }

// Type assertions
///////////////////////////////

func assert1(x any) {
    y := x.(boolean)
    fmt.Printf("\n%s", y.show())
}

func assert1b(x Show) {
    y := x.(pair[boolean, boolean])
    fmt.Printf("\n%s", y.show())
}

/*

// "invalid operation: cannot use type assertion on type parameter value x (variable of type T constrained by any)"
// But there's no real reason why this shouldn't be accepted.
func assert2[T any](x T) {
    y := x.(boolean)
    fmt.Printf("\n%s",y.show())
}


// Invalid again.
// Variant of assert2.
func assert2b[T Show](x T) {
        y := x.(pair[boolean,boolean])
    fmt.Printf("\n%s",y.show())
}


*/

/*
// Invalid again.
func assert3[T Show](x pair[T,T]) {
        y := x.(pair[boolean,boolean])
    fmt.Printf("\n%s",y.show())
}
*/

func testAssert() {
    assert1(boolean{true})
    // assert1(1)
    // fails at run-time
    assert1b(pair[boolean, boolean]{boolean{false}, boolean{true}})
    // assert1b(boolean{false})
    // fails at run-time
}

func main() {
    testShow()
    testShowFunc()
    testAssert()

}

Implementation details

In an efficient implementation, Go’s features such as method overloading and interfaces are implemented by translation to a simple(r) target language. Go has structural subtyping. This is an important aspect that also needs to be considered.

The translation of Go without generics is covered here

For formal details check out A Dictionary-Passing Translation of Featherweight Go also appeared in APLAS’21.

The translation of Go with generics is covered by the following works:

Generic Go to Go

A Type-Directed, Dictionary-Passing Translation of Featherweight Generic Go

The last work proposes a more general translation scheme that that does not come with any of the limitations mentioned earlier.