Go: Generics

Generics in programming come in a variety of different flavors, and for a while Go did not officially have generics in the language. Despite whether you’re for or against generics, they are part of the language since version 1.18. If you’re not familiar with generics, they’re a way of writing code that is independent of the specific type being used.

To be honest, when generics were first being talked about I was definitely against adding them to the language. My opinion hasn’t changed much, but I do see how they can make some situations a little less verbose and repetitive. In this post, we’ll explore what generics are in Go, how to use them, and how they work under the hood.

Type Parameters

Functions and types now have the ability to have type parameters. At first glance, the syntax of this might look a bit odd.

import "golang.org/x/exp/constraints"

func Min[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

When calling a generic function, you can provide the type argument, which would look something like the following:

package main

import (
	"fmt"

	"golang.org/x/exp/constraints"
)

func main() {
	var a int
	var b int
	a = 20
	b = 25
	min := Min[int](a, b)
	fmt.Println(min)

	var c float32
	var d float32
	c = 3.14
	d = 6.28
	minf := Min[float32](c, d)
	fmt.Println(minf)
}

func Min[T constraints.Ordered](x, y T) T {
	if x < y {
		return x
	}
	return y
}

Some odd-looking syntax, but I’m sure you can figure out what’s going on here. We are passing in the type prior to the arguments of the function, letting the compiler know that we’ll be using this generic with a particular type. However, the types can actually be implicitly inferred by the types that we are passing to the function. This means we don’t actually need to pass in the type constraint explicitly.

package main

import (
	"fmt"

	"golang.org/x/exp/constraints"
)

func main() {
	var a int
	var b int
	a = 20
	b = 25
	min := Min(a, b)
	fmt.Println(min)

	var c float32
	var d float32
	c = 3.14
	d = 6.28
	minf := Min(c, d)
	fmt.Println(minf)
}

func Min[T constraints.Ordered](x, y T) T {
	if x < y {
		return x
	}
	return y
}

If we’ve been keen this far, we might have noticed that there is a package constraints that we’ve been using. What’s that all about? Well, along with having generics in a language, Go needed a way to group types together in a nice way. Out of all of the features introduced in 1.18, type sets are definitely the neatest.

Type sets

In Go, when using generics the types we’re passing to the generics are called type constraints. These type constraints must be defined as interface types; however, prior to the addition of generics, interfaces in Go were just function signatures that a concrete type could implement to implicitly be considered a type of that interface. For more information, check out the post I did on interfaces. In order to help group types together, interfaces in Go were looked at in a new way. This new way was that an interface can define a set of types with some added syntax.

The newly added syntax looks like the bitwise operators | and ~; however, in this particular use case the | token is used like a union of the types and the ~ token means the set of all types whose underlying type is of that particular referred type. The constraints package actually wraps up some of the most common ones we might want to use. For instance, in our previous code block, we used constraints.Ordered which is really defined as the following:

type Ordered interface {
	Integer | Float | ~string
}

The Integer and Float types are themselves interfaces that define unions of built-in numeric types. The ~string means any type whose underlying type is string, so both string and custom types like type Name string would satisfy this constraint.

The above interface declaration defines Ordered to be the set of all Integer, Float, and string types. So far, everything looks pretty simple. Let’s take a look at what kind of Go assembly is produced by some simple examples.

Monomorphization and Dictionary Passing

The process of monomorphization is the generation of specialized versions of generic functions or types at compile time based on the types used when calling a generic function; however, in the case of interfaces where the type information is not known at compile time, Go uses an approach called dictionary passing which involves passing a type dictionary data structure to the function at runtime.

package main

type Numbers interface {
	int | float32
}

func main() {
	var a int
	var b int
	a = 20
	b = 25
	_ = MinGeneric(a, b)

	var c float32
	var d float32
	c = 3.14
	d = 6.28
	_ = MinGeneric(c, d)
}

//go:noinline
func MinGeneric[T Numbers](x, y T) T {
	if x < y {
		return x
	}
	return y
}

main_main_pc0:
        TEXT    main.main(SB), ABIInternal, $32-0
        CMPQ    SP, 16(R14)
        PCDATA  $0, $-2
        JLS     main_main_pc75
        PCDATA  $0, $-1
        PUSHQ   BP
        MOVQ    SP, BP
        SUBQ    $24, SP
        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        LEAQ    main..dict.MinGeneric[int](SB), AX
        MOVL    $20, BX
        MOVL    $25, CX
        PCDATA  $1, $0
        NOP
        CALL    main.MinGeneric[go.shape.int](SB)
        LEAQ    main..dict.MinGeneric[float32](SB), AX
        MOVSS   $f32.4048f5c3(SB), X0
        MOVSS   $f32.40c8f5c3(SB), X1
        NOP
        CALL    main.MinGeneric[go.shape.float32](SB)
        ADDQ    $24, SP
        POPQ    BP
        RET

main_main_pc75:
        NOP
        PCDATA  $1, $-1
        PCDATA  $0, $-2
        CALL    runtime.morestack_noctxt(SB)
        PCDATA  $0, $-1
        JMP     main_main_pc0
        TEXT    main.MinGeneric[go.shape.float32](SB), DUPOK|NOSPLIT|NOFRAME|ABIInternal, $0-16
        FUNCDATA        $0, gclocals·Plqv2ff52JtlYaDd2Rwxbg==(SB)
        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $5, main.MinGeneric[go.shape.float32].arginfo1(SB)
        FUNCDATA        $6, main.MinGeneric[go.shape.float32].argliveinfo(SB)
        PCDATA  $3, $1
        UCOMISS X0, X1
        JLS     main_MinGeneric[go_shape_float32]_pc6
        RET
main_MinGeneric[go_shape_float32]_pc6:
        MOVUPS  X1, X0
        RET
main_MinGeneric[float32]_pc0:
        TEXT    main.MinGeneric[float32](SB), DUPOK|WRAPPER|ABIInternal, $24-8
        CMPQ    SP, 16(R14)
        PCDATA  $0, $-2
        JLS     main_MinGeneric[float32]_pc43
        PCDATA  $0, $-1
        PUSHQ   BP
        MOVQ    SP, BP
        SUBQ    $16, SP
        MOVQ    32(R14), R12
        TESTQ   R12, R12
        JNE     main_MinGeneric[float32]_pc74
main_MinGeneric[float32]_pc23:
        NOP
        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $5, main.MinGeneric[float32].arginfo1(SB)
        FUNCDATA        $6, main.MinGeneric[float32].argliveinfo(SB)
        PCDATA  $3, $1
        LEAQ    main..dict.MinGeneric[float32](SB), AX
        PCDATA  $1, $0
        NOP
        CALL    main.MinGeneric[go.shape.float32](SB)
        ADDQ    $16, SP
        POPQ    BP
        RET
main_MinGeneric[float32]_pc43:
        NOP
        PCDATA  $1, $-1
        PCDATA  $0, $-2
        MOVSS   X0, 8(SP)
        MOVSS   X1, 12(SP)
        CALL    runtime.morestack_noctxt(SB)
        PCDATA  $0, $-1
        MOVSS   8(SP), X0
        MOVSS   12(SP), X1
        JMP     main_MinGeneric[float32]_pc0
main_MinGeneric[float32]_pc74:
        LEAQ    32(SP), R13
        CMPQ    (R12), R13
        JNE     main_MinGeneric[float32]_pc23
        MOVQ    SP, (R12)
        JMP     main_MinGeneric[float32]_pc23
        TEXT    main.MinGeneric[go.shape.int](SB), DUPOK|NOSPLIT|NOFRAME|ABIInternal, $0-24
        FUNCDATA        $0, gclocals·Plqv2ff52JtlYaDd2Rwxbg==(SB)
        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $5, main.MinGeneric[go.shape.int].arginfo1(SB)
        FUNCDATA        $6, main.MinGeneric[go.shape.int].argliveinfo(SB)
        PCDATA  $3, $1
        CMPQ    CX, BX
        JLE     main_MinGeneric[go_shape_int]_pc9
        MOVQ    BX, AX
        RET
main_MinGeneric[go_shape_int]_pc9:
        MOVQ    CX, AX
        RET
main_MinGeneric[int]_pc0:
        TEXT    main.MinGeneric[int](SB), DUPOK|WRAPPER|ABIInternal, $32-16
        CMPQ    SP, 16(R14)
        PCDATA  $0, $-2
        JLS     main_MinGeneric[int]_pc47
        PCDATA  $0, $-1
        PUSHQ   BP
        MOVQ    SP, BP
        SUBQ    $24, SP
        MOVQ    32(R14), R12
        TESTQ   R12, R12
        JNE     main_MinGeneric[int]_pc74
main_MinGeneric[int]_pc23:
        NOP
        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        FUNCDATA        $5, main.MinGeneric[int].arginfo1(SB)
        FUNCDATA        $6, main.MinGeneric[int].argliveinfo(SB)
        PCDATA  $3, $1
        MOVQ    BX, CX
        MOVQ    AX, BX
        LEAQ    main..dict.MinGeneric[int](SB), AX
        PCDATA  $1, $0
        CALL    main.MinGeneric[go.shape.int](SB)
        ADDQ    $24, SP
        POPQ    BP
        RET
main_MinGeneric[int]_pc47:
        NOP
        PCDATA  $1, $-1
        PCDATA  $0, $-2
        MOVQ    AX, 8(SP)
        MOVQ    BX, 16(SP)
        CALL    runtime.morestack_noctxt(SB)
        PCDATA  $0, $-1
        MOVQ    8(SP), AX
        MOVQ    16(SP), BX
        JMP     main_MinGeneric[int]_pc0
main_MinGeneric[int]_pc74:
        LEAQ    40(SP), R13
        CMPQ    (R12), R13
        JNE     main_MinGeneric[int]_pc23
        MOVQ    SP, (R12)
        JMP     main_MinGeneric[int]_pc23

Looking at the assembly output above, we can see that Go generates both wrapper functions and shape-based implementations. Let’s break down what’s happening:

Dictionary Loading and Function Calls

In the main function, we can see how Go handles the generic function calls:

LEAQ    main..dict.MinGeneric[int](SB), AX
MOVL    $20, BX
MOVL    $25, CX
CALL    main.MinGeneric[go.shape.int](SB)

Here, LEAQ main..dict.MinGeneric[int](SB), AX loads the address of the type dictionary for the int instantiation into register AX. The values 20 and 25 (our function arguments) are loaded into registers BX and CX, then the shape-based function main.MinGeneric[go.shape.int] is called.

Similarly, for the float32 call:

LEAQ    main..dict.MinGeneric[float32](SB), AX
MOVSS   $f32.4048f5c3(SB), X0
MOVSS   $f32.40c8f5c3(SB), X1
CALL    main.MinGeneric[go.shape.float32](SB)

The dictionary for float32 is loaded, and the floating-point values 3.14 and 6.28 are loaded into SSE registers X0 and X1 before calling the float32 shape function.

Shape-based Functions

Go generates optimized shape-based functions that contain the actual logic. For integers:

TEXT    main.MinGeneric[go.shape.int](SB), DUPOK|NOSPLIT|NOFRAME|ABIInternal, $0-24
CMPQ    CX, BX          ; Compare y (CX) with x (BX)
JLE     main_MinGeneric[go_shape_int]_pc9  ; Jump if y <= x
MOVQ    BX, AX          ; Return x (BX) in AX
RET
main_MinGeneric[go_shape_int]_pc9:
MOVQ    CX, AX          ; Return y (CX) in AX
RET

This is a straightforward implementation of our if x < y logic using CMPQ (compare quad-word) and conditional jumps. Notice how the function directly operates on the values without any type dictionary overhead.

For floating-point numbers, the shape function uses SSE instructions:

TEXT    main.MinGeneric[go.shape.float32](SB), DUPOK|NOSPLIT|NOFRAME|ABIInternal, $0-16
UCOMISS X0, X1          ; Unordered compare of single-precision floats
JLS     main_MinGeneric[go_shape_float32]_pc6  ; Jump if X0 <= X1
RET                     ; Return X0 (already in place)
main_MinGeneric[go_shape_float32]_pc6:
MOVUPS  X1, X0          ; Move X1 to X0 (return y)
RET

The UCOMISS instruction performs an unordered comparison of single-precision floating-point values, and MOVUPS moves the result to the return register.

Wrapper Functions

Go also generates wrapper functions that handle the dictionary passing protocol. These wrappers are responsible for:

  1. Stack management: Setting up the proper stack frame
  2. Dictionary handling: Loading and passing the type dictionary
  3. Calling the shape function: Delegating to the optimized implementation

For example, the main.MinGeneric[int] wrapper loads the dictionary and calls the shape function:

LEAQ    main..dict.MinGeneric[int](SB), AX  ; Load dictionary
CALL    main.MinGeneric[go.shape.int](SB)   ; Call shape function

This hybrid approach allows Go to balance compilation speed with runtime performance. The wrapper functions handle the type dictionary passing, while the shape-based functions contain the actual optimized logic without any generic overhead.

References