Understanding the Power of Generics in Go

Nahuel Costamagna

Nahuel Costamagna

· 7 min read
Generics in Go 1.18 - Nahuel Costamagna

Introduction

Welcome to this post, where we'll discuss Generics in Go, a powerful feature introduced in Go version 1.18.

Function

We can define a function that receives an integer value, for instance:

func Sum01(v int){
    // TODO
}

If we want to receive different values in the function, we'll define it as an interface.

func Sum01(v interface){
    // TODO
}

Starting from version 1.18, we can use Generics for defining a set of data types, such as: int, int32, int64, float32, or float64.

To achieve this, we define a generic variable (in this case, N) and specify its set of data types within brackets.

The generic data types must be separated by the pipe character (|)

func Sum01[N int | int32 | int64 | float64 | float32]

After that, we define a generic data type in our function's arguments. In this case, we receive an array of our generics.

func Sum01[N int | int32 | int64 | float64 | float32](v []N) {

}

We can also return our generic, where this generic (N) represents a set of data types (int, int32, int64, float64, float32).

In this case, we receive all array elements and perform their summation.

func Sum01[N int | int32 | int64 | float64 | float32](v []N) N{
    var total N
    for _, tV := range v{
        total += tV
    }
    return total
}

We can execute the function with Println to see the result.

v1 := []float64{1.3,5.45,12.223,6.92,78.102}
v2 := []int32{9,23,1,23,8,98}

fmt.Println(Sum01(v1))
fmt.Println(Sum01(v2))
Output:
103.995
162

Interface

We can create an interface to define the data types that represent our generic.

We define an interface with the same data types, our generic is called 'Number'.

type Number interface{
    int | int32 | int64 | float64 | float32
}

And we define our generic with this interface instead of the data types.

func Sum02[N Number](v []N) N{
    var total N
    for _, tV := range v {
        total += tV
    }
    return total
}

We execute the function

v1 := []float64{1.3,5.45,12.223,6.92,78.102}
v2 := []int32{9,23,1,23,8,98}

fmt.Println(Sum02(v1))
fmt.Println(Sum02(v2))
Output:
103.995
162

Any

We can receive any values as parameters using the reserved word 'any.'

In this case, we are receiving 2 generics as 'any' values (v1 and v2).

func anyType[N any](v1, v2 N){
    fmt.Println(v1, v2)
}

we execute the function:

anyType(1,1)
anyType("1","1")
Output:
1 1
1 1

We must take into account that if we send an integer value as a parameter, the generic will be an integer value. If we send a string value, the generic will be a string value. The generic will transform into the value we send. Therefore, we can't send different data types as the same generic.

For example, in the previous example, we defined the generic N as 'any'

This generic (N) will be integer or string, but won't be integer and string, if we execute this code the program will return error

anyType(1,"1")
Output:
mismatched types untyped int and untyped string (cannot infer N)

If we wanted to send 2 different data types, we would define 2 generics:

func anyTypeTwo[N1 any, N2 any](v1 N1, v2 N2){
    fmt.Println(v1, v2)
}

In this way, we can execute the function with integer and string values.

anyTypeTwo(1, "1")
Output:
1 1

Comparable

We won't perform comparisons between 'any' values. For instance, we won't check if v1 is equal to v2.

func anyType[N any](v1, v2 N){
    fmt.Println(v1, v2)
    fmt.Println(v1 != v2)
}
Output:
invalid operation: v1 != v2 (incomparable types in type set)

In this case, we must use the reserved word 'comparable' to compare whether the variables are equal.

func comparableType[N comparable](v1, v2 N){
    fmt.Println(v1, v2)
    fmt.Println(v1 != v2) // != or ==
}

We execute the function

comparableType(4,4)
Output:
4 4
false

Ordered

If we want to compare whether an 'any' type variable 'a' is greater or lesser than another variable 'b', we must use the 'Ordered' interface in the internal 'cmp' package.

func orderedValues[N cmp.Ordered](v1, v2 N){
    fmt.Println(v1, v2)
    fmt.Println(v1 !=v2)
    fmt.Println(v1 < v2)
    fmt.Println(v1 > v2)
}

We execute the function

orderedValues(2,4)
Output:
2 4
true
true
false

The 'cmp' package was released in Go version 1.21. If your project is older than this version, you can use the external 'constraints' package.

go get golang.org/x/exp
import (
    "fmt"
    "golang.org/x/exp/constraints"
)

func orderedValues[N constraints.Ordered](v1, v2 N){
    fmt.Println(v1, v2)
    fmt.Println(v1 == v2)
    fmt.Println(v1 < v2)
    fmt.Println(v1 > v2)
}

Generics with slices

We can define, let's say, a CustomSlice type with integer or string data types that represent a generic slice:

type CustomSlice[V int | string] []V

And we can use our custom slice:

csInt := CustomSlice[int]{1,2,3,4}
fmt.Println(csInt)

csStg := CustomSlice[string]{"a", "b","4"}
fmt.Println(csStg)
Output:
[1 2 3 4]
[a b 4]

Tilde in generics

We are going to do the following examples.

We have our 'Number' interface that represents the following data types.

type Number interface{
    int | int32 | int64 | float64 | float32
}

And we have a function like this, using our 'Number' interface, which returns the lesser value.

func MinNumber[T Number](x, y T) T {
    if x < y {
        return x
    }
    return y
}

We run the function, and it works perfectly

vv := MinNumber(5, 2)
fmt.Println(vv)

however, what happens if we define a type as integer called Point

type Point int

and we use this type to represent this integer value when we execute the function

x, y := Point(5), Point(2)
vv := MinNumber(x,y)

if we do it, the program will return the following error:

Point does not satisfy Number

In this interface we only can use int, int32, int64, float64, float32

type Number interface{
    int | int32 | int64 | float64 | float32
}

But, if we want to use a type (such as Point) that represents these values, we use the tilde character (~).

type Number2 interface {
    ~int |  ~int32 | ~int64 | ~float32 | ~float64
}

The ~ tilde token is used in the form ~T to denote the set of types whose underlying type is T.

Now, we run the program and see the result

x, y := Point(5), Point(2)
vv := MinNumber(x,y)
fmt.Println(vv)
Output:
2

Generics in structs

We are going to see how to use generics with structs.

First, we create those structs with their methods.

type MyFirstData struct { }

type MySecondData struct { }

func (d MyFirstData) PrintOne(){
    fmt.Println("Print first")
}

func (d MySecondData) PrintTwo(){
    fmt.Println("Print second")
}

And we create our generic struct with a field called 'Data' with the 'any' data type.

type MyGenericStruct[D any] struct {
    StrValue string
    Data D
}

Finally, we generate the struct and run the methods we defined for each of the structs that represent this 'any' field.

fd := MyGenericStruct[MyFirstData]{ StrValue: "Test", Data: MyFirstData{}}
fd.Data.PrintOne()

sd := MyGenericStruct[MySecondData]{ StrValue: "Test", Data: MySecondData{}}
sd.Data.PrintTwo()

Conclusion

We have seen how to use generics with some examples. This is a powerful functionality introduced in Go version 1.18. For more information, you can refer to the official documentation

Nahuel Costamagna

Nahuel Costamagna

FullStack Developer & DevOps