There are situations when functions don't depend on the data types on which they operate. For example, the algorithm to reverse a slice can process a slice of different types: strings, integers, floats, etc. It doesn't matter what data type the slice stores, the algorithm for reversing the slice is the same.
However, we can't write this algorithm as a single function, because we always need to explicitly declare a data type in the function's signature. We would need to write a slice reverse function for every data type because of this restriction.
In this topic, we'll learn how to use generics in Go, and how to work with generic types and generic functions.
What are generics?
Go version 1.18 adds support for generic programming. In simple terms, generic programming allows you to write generic and reusable functions that can take either any type of parameter or a range of possible types.
To further explain the concept of generic programming, let's take a look at a function that reverses a slice of []string types:
func reverse(a []string) []string {
for i := len(a)/2 - 1; i >= 0; i-- {
opp := len(a) - 1 - i
a[i], a[opp] = a[opp], a[i]
}
return a
}Now, suppose you want to use the same reverse() function to reverse a slice of []int or []float64 types... You would need to create two new functions — reverseInt() and reverseFloat() — that have slices of the []int or []float64 types as parameters.
In such cases, a generic function would come in handy! With generics, you can declare and use functions or types that are written to work with any of a set of types provided by the calling code. Let's go ahead and change reverse() to become a generic function:
// Add [] brackets with type parameters after the function name:
func reverse[T any](a []T) []T {
for i := len(a)/2 - 1; i >= 0; i-- {
opp := len(a) - 1 - i
a[i], a[opp] = a[opp], a[i]
}
return a
}To declare a generic function, we need to use [] brackets after the function name to specify type parameters. In the above example, we add the type parameter [T any] that allows the reverse() function to work for different data types and replace the previous uses of []string with []T instead.
Take notice that Go version 1.18 introduces the built-in type any, which is a predefined keyword for the cases when any data type is suitable.
Instantiation
Now that reverse() is a generic function, it is possible to call it with a non-string type argument via the following syntax:
// Providing the type argument [int] to the 'reverse()' function:
x := reverse[int]([]int{4, 32, 16, 128, 64})
// Making the compiler infer the type argument for the 'reverse()' function:
y := reverse([]int{4, 32, 16, 128, 64})Both of the above calls to the reverse() function can be referred to as instantiations. Instantiation happens in two steps:
The compiler substitutes all type arguments for their respective type parameters throughout the generic function.
The compiler verifies that each type of argument satisfies the respective constraint, in this case, the constraint is the
anytype.
After successful instantiation, we have a non-generic function we can call like any other function, for example:
...
func main() {
intSlice := []int{4, 32, 16, 128, 64}
intReverse := reverse[int] // Providing the type argument [int]
fmt.Println(intReverse(intSlice)) //[64 128 16 32 4]
stringSlice := []string{"us", "among"}
stringReverse := reverse(stringSlice) // Making the compiler infer the type argument
fmt.Println(stringReverse) // [among us]
}Since instantiation creates a non-generic function, take notice that the instantiation stringReverse := reverse(stringSlice) produces what is effectively our original []string slice reverse() function.
Type arguments and constraints
A regular function has a type for each value parameter. For example, if we have a []string type as in the original non-generic reverse() function, the permissible set of argument values is a slice of strings.
Similarly, type parameter lists have a type for every parameter. Since a type parameter is itself a type, the types of type parameters define sets of types, also known as a type constraints.
A type constraint is an interface that defines the set of permissible type arguments for the respective type parameter and controls the operations supported by values of that type parameter.
Currently, reverse() has the type constraint any, however, let's say we wanted to restrict reverse() type parameters to the most common numeric types only. We can create a combined type constraint via the following syntax:
func reverse[T int | float64 | complex128](a []T) []T {...}The | operator is a union element that allows us to combine multiple different data types for our custom constraints, but take notice that it is also possible to create a constraint for a single predefined type.
Now reverse() is restricted to slices of []int, []float64, and []complex128 types only:
x := reverse([]int{4, 32, 16, 128, 64}) // ok
y := reverse([]float64{1.618, 3.1416, 2.718}) // okFinally, if we tried to pass a slice of []string types to reverse(), we would get a compilation error, because string doesn't implement the intended types for the new constraint:
z := reverse([]string{"us", "among"}) // Error: string does not implement int|float64|complex128Naming conventions for type parameters
You might be wondering: why is T used as the type parameter name when creating a generic function? There is a naming convention in generic programming that restricts type parameter name choices to single uppercase letters.
The most commonly used type parameter names in generic programming are:
T– Type;S,U,V, etc. – for the 2nd, 3rd, and 4th types (when a generic function uses multiple type parameters);K– Key;V– Value;E– Element (for slice Elements).
Conclusion
In this topic, we've learned what generic programming is and how to create generic functions in Go. In particular, we've covered the following theory:
How to declare a generic function that can take
anydata type, using the bracket syntax[T any]after the function name;How to instantiate a generic function by providing the type argument or by making the Go compiler infer the type argument;
That a type constraint is an interface that defines the set of permissible type arguments a generic function can take;
That we can create combined type constraints using the
|operator.
Finally, we've also learned what the common naming conventions are that are used in generic programming for type parameters. Now, let's go ahead and work on some tasks to test your knowledge about generics!