Generics in Go

"Generics permits writing common functions and types that differ only in the set of types on which they operate when used, thus reducing duplication".

Go is a statically typed language, which means every identifier (i.e., value or variable) should explicitly declare its exact type, like int or string.

So far in Go, if we need to sum all the elements in a slice, we need to define separate duplicate functions based on the slice type. To calculate the total value of an int and float slice, we need two functions.

// sum int slice
func sumInts(xs []int) int {
    var total int
    for _, x := range(xs) {
        total += x
    }
    return total
}

// sum float slice
func sumFloats(xs []float64) float64 {
    var total float64
    for _, x := range(xs) {
        total += x
    }
    return total
}

sumInts and sumFloats functions are identical except for type signatures, int and float64 respectively. It creates code duplication and bloats your codebase.

Generics would solve this issue and after a much longer wait, Go added generics support in its latest release.

With generics, we can define the following:

func sum[T int|float64](xs []T) T {
    var total T
    for _, x := range(xs) {
        total += x
    }
    return total
}

Go functions accept an extra type parameter list before the arguments in the function signature. Define the type parameter list inside the square brackets ([). The type parameter is defined similarly to the argument list. It consists of a literal(T) and its type definition (int|float64).

The type parameter T is followed by type int|float64. The int|float64 is a union type, i.e., T is either an int or float64. Then we can use the T as a type in the function. The function takes an argument xs take in a slice of type T and returns type T. We can also use T inside the function as a type declaration (var total T).

We can invoke the sum using the following snippet:

// expects the type to be int
sum[int]([]int{1, 2, 3, 4})
// expects the type to be float64
sum[float64]([]float64{1.8, 2.22, 3.08, 4.9})

The function call also accepts the type parameter list inside the square brackets like sum[T](). We pass in [int] for an int slice and [float64] for a float64 slice. They inform the compiler about the type. To remove this verbosity, Go adds type inference.

Type inference is the ability of a compiler to infer a type when it has enough type information.

In our example above, the parameters provide enough type information for the compiler to understand the type of the function call. Thus we can safely remove the type parameter list in the function call.

// infers the type to be int
sum([]int{1, 2, 3, 4})
// infers the type to be float64
sum([]float64{1.8, 2.22, 3.08, 4.9})

Note: if the compiler does not have enough type information to infer the type, then the compiler will panic.

Type inference is a powerful feature, and it reduces verbosity. Type inference avoids writing some or all of the type arguments.

Approximate types

What if we have another type of integer defined in our code like below:

type SuperSmartInteger int

The above definition is called type alias, and SuperSmartInteger is nothing but an int. But if we now call the sum function for the SuperSmartInteger, it will panic. Because the compiler has no clue about the type SuperSmartInteger.

// fails because sum is not defined for SuperSmartInteger
sum([]SuperSmartInteger{1, 2, 3, 4})

Go now supports approximate types via the ~ keyword. The ~T defines an approximate type T. That is, it supports all types that are basically T. So here, we can redefine the sum function type parameters to add ~int instead of int.

func sum[T ~int|float64] (xs []T) T { ... }

Now the below code will run and produce output.

// runs and produces 10
sum([]SuperSmartInteger{1, 2, 3, 4})

Type Parameters

Go supports defining type parameters in the type definition. The syntax is similar to the type instantiation in functions.

type Vector[T any] []T

Constraints

Go has an interface type to define a set of methods. Any value/variable of the interface type should implement these methods. In other words, values should implement the constraints of the defined interface types.

Go introduces constraints to extend the type parameters. Constraints are interface types.

With Go 1.18, we have a constraints package.

package constraints

// Ordered is a type constraint that matches any ordered type.
// An ordered type is one that supports the <, <=, >, and >= operators.
type Ordered interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 |
        ~string
}

We extend the type parameters with constraints like below:

// Smallest returns the smallest element in a slice.
// It panics if the slice is empty.
func Smallest[T constraints.Ordered](s []T) T {
    r := s[0] // panics if slice is empty
    for _, v := range s[1:] {
        if v < r {
            r = v
        }
    }
    return r
}

Here the type T denotes constraints.Ordered. Thus it supports types like int or float, which supports comparison operators like <, <=, >, or >= on the parameters.

Note: Go 1.18 introduces a syntactic sugar for interface{} to any type.

Generics - the advantages

With generics, we can write and test code once. i.e., we can reuse an efficient implementation and debug. Generics empowers us to write more general-purpose functions and data structures.

Slices and Maps in go can hold values of any data type, and with generics, we can define the functions once and extend them for other data types.

Generics provides powerful building blocks to share and build programs.

Go is a simple language. Go programs are clear, simple, and easy to understand. Generics intends to simplify the code and make it more readable.

Use generics wisely.

Recap

  • Generics support adding type parameter list to the function, type definition, and interfaces. The type parameter list is within the square brackets.
  • ~ is the new token in Go. It helps to define the approximate types.
  • Go 1.18 introduces a new package constraint. Constraints are nothing but interface types. Any type parameter also accepts constraints as their type definition.
  • Use | to define the union types.
  • Type inference eliminates redundant type definitions.
  • New alias any for the empty interface{} type.

To know more


யாதும் ஊரே யாவரும் கேளிர்! தீதும் நன்றும் பிறர்தர வாரா!!

@sendilkumarn