DEV Community

Lane Wagner for Boot.dev

Posted on • Originally published at qvault.io on

How to Use Golang’s Generics

Image description

The post How to Use Golang’s Generics first appeared on Qvault.

Generics in Go are just around the corner! This is one of the most eagerly-awaited features since the release of the language. Many devs have gone so far as to say Go’s previous lack of generic types made the language too painful to use at all. Let’s dive into what generics are, why you might use them in your own projects, and how they work in Go.

What is a generic type?

Put simply, generics allow programmers to write behavior where the type can be specified later because the type isn’t immediately relevant. This is an amazing feature because it permits writing abstract functions that drastically reduce code duplication. For example, the following generic function will split a slice in half, no matter what the types in the slice are.

func splitAnySlice[T any](s []T) ([]T, []T) {
    mid := len(s)/2
    return s[:mid], s[mid:]
}
Enter fullscreen mode Exit fullscreen mode

Think about it, to split a slice into two halves, we don’t really care about whether it’s a slice of integers or a slice of strings, the logic is the same.

Take Action and Learn Go

Our ‘Go Mastery’ courses include 160+ interactive coding lessons to give you all the skills you need to become a successful Go developer.

Learn Go Now

For example, we could call it with the following code.

func main() {
    firstInts, secondInts := splitAnySlice([]int{0, 1, 2, 3})
    fmt.Println(firstInts, secondInts)
    // prints [0 1] [2 3]

    firstStrings, secondStrings := splitAnySlice([]string{"zero", "one", "two", "three"})
    fmt.Println(firstStrings, secondStrings)
    // prints [zero one] [two three]
}
Enter fullscreen mode Exit fullscreen mode

Generics are a feature of many popular strongly-typed programming languages due to their amazing ability to reduce duplicate code. In dynamically typed languages like JavaScript and Python, you wouldn’t need generics, but in Go, it’s an amazing addition to the language.

Generics in Go, the tl;dr

I’ll try to summarize the specification for generics in Go in a few bullet points.

  • Functions and types can have an additional list of type parameters before their normal parameters, using square brackets to indicate the generic types used within the function body. These type parameters can be used like any other parameter in the rest of the definition and in the body of the text. For example,
    • func splitAnySlice[T any](s []T) ([]T, []T)
  • The type parameters are defined using “constraints”, which are interface types. Constraints define the required methods and allowed types for the type argument and describe the methods and operations available for the generic type.
  • Type inference often allows type arguments to be omitted.
  • A special built-in constraint called any behaves similarly to interface{}.
  • A new package called constraints will exist in the standard library that will contain commonly used constraints.

Why should I care about generics?

Go is an amazing language that places an emphasis on simplicity and backward compatibility. In other words, Go has purposefully left out many features other languages boast about because it counterintuitively makes the language better (at least in some people’s opinion, and for some use-cases). Go code in one codebase looks like Go code in another codebase. Generally speaking, there is “one way to do it”.

According to historical data from Go surveys, Go’s lack of generics has always been listed as one of the top three biggest issues with the language. At a certain point, the cons associated with the lack of a feature justify the added complexity to the language. The community and the core team deliberated about it for years, but support for generics is overwhelming at this point it seems.

In short, you should care about generics because they mean you don’t have to write as much code, especially if you’re in the business of writing packages and tooling. It can be frustrating to write utility functions without generics support. Think about common data structures like binary search trees and linked lists. Why would you want to rewrite them for every type they could possibly contain? int, bool, float64, and string aren’t the end of the list, because you may want to store a custom struct type.

Generics will finally give Go developers an elegant way to write amazing utility packages.

What is a constraint?

Sometimes you need the logic in your generic function to know a thing or two about the types in question. Constraints are interfaces that allow you to write generics that only operate within the constraint of a given interface type. In the first example above, we used the any constraint, which is comparable to the empty interface{}, because it means the type in question could be anything.

Any constraint

The any “constraint” works great if you’re treating the value like a bucket of data, maybe you’re moving it around, but you don’t care at all about what’s in the bucket.

According to the propsal, the operations permitted for the any type are as follows.

  • declare variables of those types
  • assign other values of the same type to those variables
  • pass those variables to functions or return them from functions
  • take the address of those variables
  • convert or assign values of those types to the type interface{}
  • convert a value of type T to type T (permitted but useless)
  • use a type assertion to convert an interface value to the type
  • use the type as a case in a type switch
  • define and use composite types that use those types, such as a slice of that type
  • pass the type to some predeclared functions such as new

If you do need to know more about the generic types you’re working on you can constrain them using interfaces. For example, maybe your function will work with any type that can represent itself as a string.

type stringer interface {
    String() string
}

func concat[T stringer](vals []T) string {
    result := ""
    for _, val := range vals {
        result += val.String()
    }
    return result
}
Enter fullscreen mode Exit fullscreen mode

Comparable constraint

The comparable constraint is a predefined constraint as well, just like the any constraint. When using the comparable constraint instead of the any constraint, you can use the != and == operators within your function logic.

func indexOf[T comparable](s []T, x T) (int, error) {
    for i, v := range s {
        if v == x {
            return i, nil
        }
    }
    return 0, errors.New("not found")
}

func main() {
    i, err := indexOf([]string{"apple", "banana", "pear"}, "banana")
    fmt.Println(i, err)
    // prints 1 <nil>
}
Enter fullscreen mode Exit fullscreen mode

Custom constraints

Parametric constraints

Your interface definitions, which can later be used as constraints can take their own type parameters.

type vehicleUpgrader[C car, T truck] interface {
    Upgrade(C) T
}
Enter fullscreen mode Exit fullscreen mode

Type lists

From the proposal, we can simply list a bunch of types to get a new interface/constraint.

// 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
}
Enter fullscreen mode Exit fullscreen mode

Mixed

We can also mix up parameterized declarations and type lists to get new interfaces.

type ComparableStringer interface {
    comparable
    String() string
}
Enter fullscreen mode Exit fullscreen mode

Self referential

Cloneable interface {
    Clone() Cloneable
}
Enter fullscreen mode Exit fullscreen mode

Generic Types vs Generic Functions

So we know that we can write functions that use generic types, but what if we want to create a custom type that can contain generic types? For example, a slice of order-able objects. The new proposal makes this possible.

type comparableSlice[T comparable] []T

func allEqual[T comparable](s comparableSlice[T]) bool {
    for i := range s{
        if i == 0 {
            continue
        }
        if s[i] != s[i-1] {
            return false
        }
    }
    return true
}

func main() {
    fmt.Println(allEqual([]int{4,6,2}))
    // false

    fmt.Println(allEqual([]int{1,1,1}))
    // true
}
Enter fullscreen mode Exit fullscreen mode

Limitations of generics

Currrent proposal has no simple way to denote the zero value of a generic

type stringer interface {
    String() string
}

func getStringer[T stringer](idx int, stringers []T) (T, error) {
    for i, val := range <meta charset="utf-8">stringers {
        if i == idx {
            return val, nil
         }
    }
    // no good way to write the 0 value of a stringer
    return ?, errors.New("not found")
}
Enter fullscreen mode Exit fullscreen mode

No switching on a generic’s underlying type

// DOES NOT WORK
func is64Bit[T Float](v T) T {
    switch (interface{})(v).(type) {
    case float32:
        return false
    case float64:
        return true
    }
}
Enter fullscreen mode Exit fullscreen mode

The only way to get around this is to use an interface directly and perform a runtime type switch.

Generics vs interfaces

Interfaces in Go are a form of generic programming in that they let us require disparate types to implement the same APIs. We then write functions that implement those interface types, and those functions will work for any type that implements those methods. Tada, we have a beautiful abstraction layer.

The problem with this approach in many cases is that it requires each type to rewrite its logic, even if the logic is identical. Generics in Go use interfaces as constraints because interfaces are already the perfect thing to enforce the requisite APIs, but generics add a new feature, type parameters, which can DRY up our code.

Generics vs code generation

Go programmers have had a history of using code generation, the toolchain even has go generate built-in. In short, due to Go’s lack of generics, many developers in the past used code generation to work around the problem. They would generate copies of nearly identical functions, where the only real differences were the parameter’s types.

Now, with generics, we can stop generating so much code! Code generation will still have a place in solving other problems, but anywhere we need to write the same logic for multiple types we should just use generics. Generics are a much more elegant solution.

Using generics now

You can play with generics today on Golang.org’s generics playground. You can also use the beta compiler locally.

How do generics work under the hood?

Generics are really just syntactic sugar, nothing fundamental about your code’s runtime speed will be impacted much by using generics. Since the implementation isn’t fully released yet, we don’t quite know exactly what the performance impacts will be. That said, here are my guesses.

  1. Compilation time will take longer by some (likely negligible) nonzero factor. It doesn’t make sense to me that adding a new compile time feature would help the compiler run any faster.
  2. The runtime of generics vs single-type functions (whether written by hand or generated by code) will be nearly identical.
  3. Generics will generally outperform interfaces at runtime by some (likely negligible) nonzero factor. Interfaces seem likely to have some additional runtime overhead due to type assertions and such.

Will the standard library use generics now?

For new functions, types, and methods the answer is yes. However, for existing APIs, the Go team seems to remain committed to not breaking backward compatibility, which is a great decision in my opinion. Russ Cox opened a discussion to talk about this issue and has a proposal to rewrite the types and functions that clearly would use generics if we wrote them today.

He suggests adopting an “Of” suffix for the updated functions. For example, sync.Pool becomes sync.PoolOf.

Do generics change what is “idiomatic”?

Definitely. A trivial example is that it used to be wise to use float64 by default if you need a numeric type. Now there are many cases where you can use some kind of numeric constraint and open your code up to more reuse.

I’m excited to see what new best practices emerge as generics make their way into production code and we all get to play around with them.

Ready to get coding?

Start writing code for free

Join our Discord community

Have questions or feedback?

Follow and hit me up on Twitter @q_vault if you have any questions or comments. If I’ve made a mistake in the article, please let me know so I can get it corrected!

Discussion (0)