Generic Programming in Golang
warning
This article was entirely translated by ChatGPT. If there are any errors, please feel free to point them out in the comments.
The reason for learning generics is that we encountered an issue in the project. In karmem, the code for serialization and deserialization is largely similar.
Therefore, the solution is to write corresponding serialization and deserialization functions for different structs to encapsulate them.
1
2
3
4
5
6 func DeserializeGeneralParams(byteParamsData []byte) (*common.GeneralParams, error) {
generalParams := new(common.GeneralParams)
generalParams.ReadAsRoot(karmem.NewReader(byteParamsData))
return generalParams, nil
}However, this approach introduces another issue: each time a new struct is added, we need to copy the new functions for serialization and deserialization, which can be quite cumbersome. Therefore, we considered whether we could achieve something similar using generics, as shown below.
1
2
3
4
5
6 func DeserializeKarmemStruct[T KarmemStruct](byteParamsData []byte) (*T, error) {
generalParams := new(T)
T.ReadAsRoot(karmem.NewReader(byteParamsData))
return generalParams, nil
}In other words, we aim to consolidate the deserialization of a series of classes generated by Karmem. However, after reviewing various aspects of generics, it seems that we can’t solve this problem.
Parameters and Arguments
In a simple implementation of a function that adds two numbers, the a int, b int
specified in the function signature are the parameters. The values passed in when the function is called are the arguments.
1 | func Add(a int, b int) int { |
By generalizing the concepts of parameters and arguments, we can introduce similar ideas for types, leading to the notions of type parameters and type arguments.
1 | func Add(a T, b T) T { |
T
is the type parameter in this context. It is defined when the function is declared, but the specific type is provided only when the function is called. The concrete type passed in at that time is referred to as the type argument.
The method for passing type arguments:
1 | Add[T=int](100, 200) |
Generics constraints
Question
Considering the example:
1 | type IntSlice []int |
The assignment to b
cannot be achieved because the underlying type of IntSlice
is []int
, and a slice of floating-point numbers cannot be assigned.
If we need slices of different types, we might consider defining a new type for each specific type.
1 | type StringSlice []string |
Solution
Defining a new type for each different member type is extremely cumbersome, which is why we turn to generics.
1 | type Slice[T int|float32|float64] []T |
T
is the type parameter. When defining a slice, the type it represents is uncertain; it serves as a placeholder;int|float32|float64
is the type constraint, which specifies the types of arguments that the type parameter can accept, somewhat similar to defining the types for parameters;T
andint|float32|float64
together form the type parameter list;
Generic function
A generic function by using type parameters to replace the existing types:
1 | func Add[T int|float32|float64] (a T, b T) T { |
Unlike the previous implementation, a type constraint is added here to ensure that the function can only use these three types. To use the generic function, you need to pass in type arguments.
1 | Add[int](1, 2) |
It is also possible to pass in parameters directly and let the compiler infer the type arguments (let the compiler deduce the types and then pass in the parameters).
1 | Add(1, 2) |
It is important to note that the generic function cannot be used in the following situations:
Using undefined type parameters in anonymous functions (although defined type parameters can be used);
Generic methods are not supported, meaning they cannot be used under a receiver;
1
2
3
4type A struct {}
func (receiver A) Add[T int|float32|float64](a T, b T) T {
return a + b
}However, type parameters can be used through a receiver;
1
2
3
4
5
6
7
8
9
10
11
12type A[T int | float32 | float64] struct {
}
func (receiver A[T]) Add(a T, b T) T {
return a + b
}
var a A[int]
a.Add(1, 2)
var aa A[float32]
aa.Add(1.0, 2.0)
Composition and Underlying Types
Consider a generic type with a series of type constraints.
1 | type Slice[T int|int8|int16|int32|...|uint32|uint64] []T; |
To facilitate the maintenance of such code, it is possible to categorize the types and place the type constraints within interface types. This allows for nesting within type constraints.
1 | type Int interface { |
To simplify the final type definition,the type constraints can be nested within the interface
.
1 | type SliceElement interface { |
By nesting through interface
and combining within type constraints, future maintenance of the code becomes more convenient.
However, this approach has a drawback: if the type constraint includes a type type1
, and another type defines type type2 type1
, such a situation will not satisfy the type constraint conditions.
1 | var s1 Slice[int] |
Such code results in an error on the fourth line because, although its underlying type is int
, it is not itself of type int
, and thus does not meet the type constraint.
Therefore, the ~
symbol can be used to represent the underlying type in type constraints, for example, type Slice [T ~int|~float32]
. This way, as long as the underlying type meets the constraint, it is acceptable. The limitation of using this symbols are
- The type following the symbol cannot be an interface.
- The type following the symbol must be an underlying type.
Type sets
Before Go 1.18, the official definition of an interface in Golang was:
An interface type specifies a method set called its interface
For the ReaderWriter
interface, it defines an interface that includes a set of Read
and Write
methods. Any type that defines both of these methods is considered to implement this interface.
1 | type ReaderdWriter interface { |
From another perspective, ReaderWriter
can be seen as a set of types. All types that implement both of its methods belong to the type set represented by the interface. This is what we refer to as a type set.
By using interfaces to simplify type constraints, interfaces now serve the purpose of defining type sets. Originally, they only defined a set of methods, hence referred to as method sets.
1 | type SliceElement interface { |
The type interface SliceElement
is a type set. All types that satisfy the constraints Int|Uint
are included in this type set.
In type Slice[T SliceElement] []T
, the type constraint specifies the set of acceptable types for the type parameter. Only types that belong to this set can replace the type parameter for instantiation.
The original definition of implementing an interface is: A type implicitly implements an interface if it implements all the methods of that interface.
Now, a type T is said to implement an interface I if it meets the following conditions:
- If T is not an interface: Type T is a member of the type set represented by interface I.
- If T is an interface: The type set represented by interface T is a subset of the type set represented by interface I.
Basic/General Interfaces
Basic Interface: In Go versions prior to 1.18, an interface consisting solely of methods is referred to as a basic interface.
General Interface: If an interface includes not only methods but also types, it is referred to as a general interface.
General interface types cannot be used to define variables and can only be used to specify generic constraints.
At this point, we’ve covered the main points. For further details, you can refer to https://segmentfault.com/a/1190000041634906, which provides a more comprehensive explanation.