Gopher refers to north
What are generics
With generics, you can write data structures and functions first and specify their types later. In current Go, functions have arguments, of course, and with generics, functions can have a new kind of argument called a “type argument.” And the type itself, which previously could not have any arguments, could also have type arguments. Functions and types that take type parameters can be instantiated using type arguments.
For type parameters, we say “instantiate” instead of calling. This is because the operations take place entirely at compile time and not at run time. Type parameters have constraints that limit the allowed set of type arguments, just as normal parameters have the allowed set of type arguments. For example, the following function takes a map[string]int and returns a slice of all the keys in that type.
func MapKeys(m map[string]int) []string{
var s []string
for k := range m {
s = append(s, k)
}
}
return s
Copy the code
You can easily write this function for any particular map type, but you need to write a different copy of the function for each map type you want to use. Alternatively, you can write this function using the Reflect package, but it is laborious to write and the function is relatively slow to run. The process of using the Reflect package is quite complicated, so I won’t give you any examples. Now, you can also use type parameters, and with type parameters you only need to write this function once to apply to all mapping types. At the same time, the compiler does a full type check.
func MapKeys[K comparable.V any](m map[K]V) []K {
var s []K
for k := range m {
s = append(s, k)
}
return s
}
Copy the code
In the above code, the type parameters are called K and V. The common parameter m used to have type map[string]int. Now it has type map[K]V. The type parameter K is the key type of the mapping and therefore must be comparable, which is made clear by pre-declaring the Comparable constraint for K, which you can think of as a meta type for that type parameter. The type parameter V can be of any type, so its constraint is the pre-declared constraint any. The function body is the same as before, except that the variable S is now a type slice of k instead of a type slice of string. There are many other details about generics that I won’t discuss here, but those interested can read the official user manual. It is important to note, though not shown in this example, that types themselves can actually have type parameters.
When do generics apply
Without further ado, today’s discussion is not about what generics are or how to use them, but when generics should apply and when they shouldn’t. It should be made clear that this lecture is a general guide and is not a rule of thumb. However, if you are not sure, refer to the guidelines I will discuss.
First, I’ll talk about the general guidelines for programming with Go. We write Go programs by writing code rather than by defining types.
Write code, don't design types
Copy the code
When it comes to generics, you may be going in the wrong direction if you start your program by defining type parameter constraints. First, you should write functions, and then easily add them when it is clear that type arguments can be used.
To illustrate this point, let’s now look at situations in which type parameters might be useful.
- One situation where type parameters can be useful is functions that operate on special types defined in the language. For example, slicing, mapping, and channels.
Using type parameters can be useful if the function has parameters of these types and the function code does not make any specific assumptions about element types. For example, the MapKeys function we saw earlier. This function returns all the keys in the map, and the code makes no assumptions about the type of MapKeys, so this function is ideal for type parameters. As I mentioned earlier, the usual alternative to using type parameters for such functions is to use reflection. However, this is a more cumbersome programming model because not only is it impossible to do type checking statically. And it usually runs slower.
- Another similar situation where type parameters can be useful is in generic data structures.
By generic data structures I mean data structures such as slicing or mapping that are not built into the language. For example, linked lists or binary trees. Currently, programs that require such data structures are written with specific element types or use interface types. Replacing a specific element type with a type parameter yields a more generic data structure. Replacing an interface type with a type parameter is often a more efficient way to store data. In some cases, using type parameters instead of interface types can mean that the code avoids type assertions and can do full type checking at compile time. For example, a binary tree data structure with type parameters might look like this.
type Tree[T any] struct {
cmp func(T, T) int
root *leaf[T]
}
type leaf[T any] struct {
val T
left, right *leaf[T]
}
Copy the code
Each leaf node in the tree contains the value of the type parameter T, and when the binary tree is instantiated with a specific type argument, the value of that type argument is stored directly in the leaf node rather than as an interface type.
The following example shows methods in a generic binary tree.
func (bt *Tree[T]) find(val T) *leaf[T] {
pl := bt.root
forpl ! =nil {
switch cmp := bt.cmp(val, pl.val); {
case cmp < 0: pl = pl.left
case cmp > 0: pl = pl.right
default: return pl
}
}
return pl
}
Copy the code
Please do not care too much about the details of the above code, in the actual use of the process do not have to copy the above code as a template. The point is that this is a reasonable use of type parameters, because the code in the tree data structure and find methods is largely independent of the element type T. The tree data structure really needs to know how to compare the values of the element type T, and it uses a function passed in to do so. You can see this in the fourth line of the code in the call to bt.cmp, except that the type parameter does nothing else. In the meantime, this binary tree example demonstrates another general rule.
- When you need something like a comparison function, it’s best to use functions rather than methods.
We could have defined the tree type as requiring a compare or less method for the element type. So you can write a constraint that requires a compare or less method, which means that any argument used to instantiate a tree type needs to have that method, and also means that if anyone wants to use a tree with a simple data type like int, they must define their own int using compare. And anyone who wants to use a tree with a custom datatype must define a compare method for their datatype (even if they don’t have to). If we define the tree to accept a function, as in the above code, we can easily pass in the required comparison function. If the element happens to already have a compare method, we simply pass in the method expression. In other words, it’s much easier to convert a method to a function than to add a method to a type. Therefore, for generic data types, it is better to use functions rather than write constraints that require methods.
- Another situation where type parameters can be useful is when different types need to implement some common methods, and implementations for all types look the same.
For example, consider using the standard library’s sort.Interface in the Sort package, which requires that each type implement three methods, namely len, swap, and less. The following example is a generic type that implements sort.interface for any slice type.
type SliceFn(T any) struct {
s []T
cmp func(T, T) bool
}
func (s SliceFn[T]) Len(a) int { return len(s.s) }
func (s SliceFn[T]) Swap(i, j int) {
s.s[i], s.s[j] = s.s[j], s.s[i]
}
func (s SliceFn[T]) Less(i, j int) int {
return s.cmp(s.s[i], s.s[j])
}
Copy the code
The len and swap methods are identical for any slice type, and the less method requires a comparison function, the “Fn” part of the slicFn name. As with the previous tree example, we will pass in a function when we create the sliceFn. The following example shows how sliceFn can be used to sort any slice by comparison function.
func SortFn[T any](s []T, cmp func(T, T) bool) {
return sort.Sort(SLiceFn[T]{s, cmp})
}
Copy the code
In this example, type parameters are a good fit. Because the methods corresponding to all slice types look exactly the same. It makes sense to use type parameters when you need to implement methods that look the same for all related types.
When are generics not applicable
Now, let’s turn to the other side of the coin. When generics are not applicable.
When is it a bad idea to use type parameters? Go has interface types, which already allow for some generic programming. For example, the widely used IO.Reader interface provides a generic mechanism for reading data from any value that contains information (such as a file) or generates information (such as a random number generator).
- If you only need to call a method on a value of a type, use the interface type instead of the type parameter.
IO.Reader is easy to read, effective and efficient. Reading data from a value, such as calling the Read method, does not require type arguments. For example, don’t write code like this.
func ReadFour[T io.Reader](r T) ([]byte, error) {
buf := make([]byte.4)
_, err := io.ReadFull(r, buf)
iferr ! =nil {
return nil, err
}
return buf, nil
}
Copy the code
The same function could have been written in the above code without using type parameters, and omiting type parameters would have made the function easier to write and read, and probably take the same time to execute.
One final point worth emphasizing is that one might assume that functions instantiated with specific types of arguments tend to be slightly faster than code that uses interface methods. Of course, in Go, the exact details will depend on the compiler, and a function instantiated with type parameters will most likely not be faster than similar code using interface methods. Therefore, do not use type parameters for efficiency purposes. The reason to use them is to make your code clearer. If they complicate your code, don’t use them.
Now, back to the choice between type parameters and interface types. When different types use a common method, consider the implementation of that method.
- We said earlier that if a method implementation is the same for all types, use type parameters, whereas if each type implementation is different, use different methods and do not use type parameters.
For example, the implementation of reading from a file is completely different from that of reading from a random number generator, which means we write two different reading methods, and neither should use type parameters.
Although I’ve only mentioned it a few times today, Go also has reflection, and reflection does allow for some kind of general programming. It allows you to write code that works for any type. Interface types do not work if some operations must support types that have no methods, and use reflection if operations of each type are different. An example of this is the JSON-encoded package. We don’t require the Marshal JSON method to be supported for every type we code, so we can’t use interface types. Also, encoding integer types is completely different from encoding structural types, so we cannot use type parameters. Reflection is used in the package, the related code is too complex to show here, but if you are interested please check the relevant source code.
conclusion
The whole discussion above can be reduced to a simple criterion.
- If you find yourself writing the exact same code multiple times, and the only difference between versions is that the code uses different types, consider whether you can use type parameters. Another way to put it is to avoid using type parameters until you notice that you are writing the exact same code multiple times.
Finally, I hope that you will use generics carefully and rationally in Go, and I sincerely hope that this article will be of some help to you.