type parameters
Type parameters are one or more name-type parings that look visually similar to
our standard parameters; the only difference being type params are surrounded by
square brackets, not parentheses. The square brackets, thankfully, are a
consistent syntax you’ll see used in struct declarations and variable
initialization.
[a, b constraint1, c constraint2]
We can now replace our strongly typed numeric like int32
or float64
with a
far more permissible type parameter T
.
func Max[T constraints.Ordered](x,_y_T) T {
if x > y {
return x
}
return y
}
When we call this function, we have to explicitly pass the type argument as part of the functions instantiation. Instatiation is a two part process where the compiler...
- Substitutes the type argument for all instances of the respective type
parameter. In our case, the two
T
arguments and one return value are swapped to beint
specifically. - Checks that the two function arguments implement the constraints. The
compiler will fail to instantiate if this step fails. Again, in our case, the
compiler checks that 3 and 4 satisfy the
Ordered
constraint.
max := Max[int](3,4)
It’s also worth pointing our that the function call above both instantiates and runs the function. We could instantiate the function separately, which might be a slight optimisation in some cases.
maxInt := Max[int]
max := maxInt(3,4)
As for data structures, these type parameters work the same way. Types can optionally have a type parameter list, and methods of that type must declare matching type lists in the receiver.
type Grid[T any] struct {
values []T
height, width int
}
func (g *Grid[T]) At(x, y int) T {
return g.Values[(g.height * y) + x]
}
var grid Grid[int]
Notice the any keyword? It’s now an alias for interface{}
!
type sets and constraints
Constraints are a new package in the standard library that describe type sets.
Type sets are just lists of types which satisfy some target behavior. For
example, the Signed
constraint is the set of all signed integer types, and the
Integer
constraint is the union of Signed
and Unsigned
. To check if a type
satisfies a constraint, the compiler just checks if that type is an element in
the constraint’s type set.
At the time of writing this, there are only six, simple constraints: Signed
,
Unsigned
, Integer
, Float
, Complex
, and Ordered
. Ordered
is the most
permissive and includes all floats, integers, and strings.
// Signed is a constraint that permits any signed integer type.
// If future releases of Go add new predeclared signed integer types,
// this constraint will be modified to include them.
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// Integer is a constraint that permits any integer type.
// If future releases of Go add new predeclared integer types,
// this constraint will be modified to include them.
type Integer interface {
Signed | Unsigned
}
These constraints are actually interfaces under the hood. Traditionally, interfaces have defined a ‘method set’ and every type which implements those methods implements that interface.
The other perspective, and one which is more relevant to generics, is that interfaces describe a set of types and the method set is only a means by which we filter the set of all types – the empty interface. It seems only reasonable then that we should be able add a specific type to that list directly.
In summary, type constraints are just interfaces and the types which satisfy those constraints are those enumerated by the interface. When you’re defining a generic function with a constraint, you’re basically defining a big list of all possible argument types.