Preface
Original text: A gentle introduction to generics in Go by Dominik Braun
Wan Junfeng Kevin: After reading it, I thought the article was very simple and easy to understand, so I asked the author's consent and translated it and shared it with you.
This article is an easy-to-understand introduction to the basic ideas of generics and their implementation in Go, and is also a simple summary of various performance discussions around generics. First, let’s take a look at the core problems solved by generics.
question
Suppose we want to implement a simpletree
Data structure. Each node holds a value. Prior to Go 1.18, a typical method of implementing this structure is as follows.
type Node struct { value interface{} }
This works well in most cases, but it also has some downsides.
first,interface{}
It can be anything. If we want to limitvalue
Possible types, such as integers and floating-point numbers, we can only check this limit at runtime.
func (n Node) IsValid() bool { switch .(type) { case int, float32, float64: return true default: return false } }
This is not possible to limit types at compile time. Type judgments like the above are very common in many Go libraries. Here are examples from the go-zero project.
Second, processing values in Node is very cumbersome and error-prone. Doing anything with a value involves some type of assertion, even if you can safely assume that the value holds oneint
value.
number, ok := .(int) if !ok { // ... } double := number * 2
These are just usedinterface{}
Some of the inconveniences that it does not provide type safety and may cause difficult to recover runtime errors.
Solution
We do not intend to accept any data type or specific type, but define aT
the placeholder type as the type of value. Please note that this code will not be compiled yet.
type Node[T] struct { value T }
First, you need to declare the generic typeT
, this is used in square brackets after the structure or function name.
T
Can be any type only if instantiating aNode
hour,T
It will be deduced to this type.
n := Node[int]{ value: 5, }
GenericsNode
Instantiated asNode[int]
(Integer Node), soT
It's oneint
。
Type constraints
In the above implementation,T
The declaration lacks one necessary information: type constraints.
Type constraints for further limitations can be used asT
possible types of . Go itself provides some predefined type constraints, but custom type constraints can also be used.
type Node[T any] struct { value T }
Any type (any) constraints allowT
Actually any type. If the node values need to be compared, there is acomparable
Type constraint, types that satisfy this predefined constraint can be used==
Make a comparison.
type Node[T comparable] struct { value T }
Any type can be used as a type constraint. Go 1.18 introduces a newinterface
Syntax, can be embedded in other data types.
type Numeric interface { int | float32 | float64 }
This means that an interface can define not only a set of methods, but also a set of types. useNumeric
The interface is a type constraint, meaning that the value can be an integer or a floating point number.
type Node[T Numeric] struct { value T }
Retrieve type safety
Relative to useinterface{}
, the huge advantage of generic type parameters is thatT
The final type of the ,being derived at compile time. forT
Define a type constraint that completely eliminates runtime checks. If used asT
If the type of the code does not satisfy the type constraint, the code will not be compiled and passed.
When writing generic code, you can already knowT
The final type is the same as the code.
func (n Node[T]) Value() T { return }
The above function returns, its type is
T
. Therefore, the return value isT
,ifT
is an integer, then the return type is knownint
. Therefore, the return value can be used directly as an integer without any type assertion.
n := Node[int]{ value: 5, } double := () * 2
Recovering type safety at compile time makes Go code more reliable and less prone to errors.
Generic usage scenarios
existIan Lance Taylor
The typical usage scenarios of generics are listed in When To Use Generics, which boil down to three main situations:
- Use built-in container types, such as
slices
、maps
andchannels
- Implement common data structures, such as
linked list
ortree
- Write a function whose implementation is the same for many types, such as a sorting function
Generally speaking, when you don't want to make assumptions about the content of the value you are operating on, you can consider using generics. In our exampleNode
Not too concerned about the value it holds.
Generics are not a good choice when different types have different implementations. Also, don'tRead(r )
This interface function signature is changed toRead[T ](r T)
Such a universal signature.
performance
To understand the performance of generics and their implementation in Go, you first need to understand the two most common ways to implement generics in general.
This is a brief introduction to the in-depth study of various performances and the discussions surrounding them. You are most likely not very concerned about the performance of generics in Go.
Virtual method table
One way to implement generics in a compiler is to useVirtual Method Table
. Generic functions are modified to accept only pointers as arguments. These values are then allocated to the heap, and pointers to these values are passed to the generic function. This is done because the pointer always looks the same, regardless of the type it points to.
If these values are objects and generic functions need to call methods of these objects, it can't do this anymore. The function has only one pointer to the object and does not know where their method is. Therefore, it requires a table that can query the memory address of the method:Virtual Method Table
. This so-called dynamic scheduling has been used by interfaces in languages such as Go and Java.
Virtual Method Table
It can be used not only to implement generics, but also to implement other types of polymorphisms. However, deriving these pointers and calling virtual functions is slower than directly calling functions, and usesVirtual Method Table
Will prevent the compiler from optimizing.
Singletonization
An easier way is to singletonize (Monomorphization
), the compiler generates a copy of the generic function for each called data type.
func max[T Numeric](a, b T) T { // ... } larger := max(3, 5)
Since the max function shown above is called with two integers, the compiler will beint
Generate amax
copy of .
func maxInt(a, b int) int { // ... } larger := maxInt(3, 5)
The biggest advantage is thatMonomorphization
The runtime performance is significantly better than that of useVirtual Method Table
. Direct method calls are not only more efficient, but also can apply to the entire compiler optimization chain. However, the cost of this is compile time, and it is time-consuming to generate copies of generic functions for all related types.
Go implementation
Which of these two methods is most suitable for Go? Quick compilation is important, but runtime performance is also important. To meet these requirements, the Go team decided to mix two approaches when implementing generics.
GoMonomorphization
, but attempts to reduce the number of copies of functions that need to be generated. Instead of creating a copy for each type, it generates a copy for each layout in memory:int
、float64
、Node
and other so-called"Value Type"
It looks different in memory, so a generic function will copy a copy for all these types.
Contrary to value types, pointers and interfaces always have the same layout in memory. The compiler will generate a copy of the generic function for the call to the pointer and interface. Just likeVirtual Method Table
Likewise, generic functions receive pointers, so a table is needed to dynamically find method addresses. The performance characteristics of dictionaries and virtual method tables in Go implementations are the same.
in conclusion
The advantage of this mixed method is that you get it in the call using the value typeMonomorphization
performance advantages, but only pay for calls using pointers or interfacesVirtual Method Table
cost.
What is often overlooked in performance discussions is that all these benefits and costs only involve the calls of functions. Normally, most of the execution time is used inside the function. The performance overhead of calling methods may not become a performance bottleneck. Even so, we must consider optimizing the function implementation first and then considering the call overhead.
This is the article about detailed explanation of the implementation principles and usage of generics in Go. For more related content on Go, please search for my previous articles or continue browsing the related articles below. I hope you will support me in the future!