Go Generics Erase the Last Excuse for Copy-Pasting Type Variants
Hello everyone! Today let's talk about generics in the Go language. Haha, of course the title of this article is a bit exaggerated. This article aims to introduce generics in Go in detail and help those who haven't used Go generics to quickly grasp how to use them.
Alright, enough small talk, let's dive right in.
In your daily development work using Go, do you often encounter this frustrating scenario: to handle different data types, you have to copy the exact same logic multiple times, but ultimately it's just because the data types are different.
The best way to learn a new feature of a language is to start with "what pain point does it solve?".
What Happens Without Generics?
To understand why we need generics, we first need to know how troublesome it is without them.
Suppose we have a very simple requirement: write a very simple function that compares two "numeric types" and returns the larger one.
If it's an integer (int), we would write the following code:
// Compare the size of two int types
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
This is indeed fine, but if we encounter another scenario where we need to compare floating-point numbers (float64)?
Then, you can only tearfully write another one:
// Compare the size of two float64 types
func MaxFloat64(a, b float64) float64 {
if a > b {
return a
}
return b
}
Damn! So the question is, what if there are also float32, int32, int64? You might have to copy and paste several times, then change the data type.
Some might say, copying and pasting a few times isn't a big deal. Yeah, it's not a big deal indeed, but you can't stand having many methods of the same type. Suppose there's another requirement: print all elements in a slice.
Then, your code would look something like this:
package main
import "fmt"
func PrintInts(s []int) {
for _, v := range s {
fmt.Println(v)
}
}
If you need to print slices of other types, you have to copy and paste repeatedly...
So, is there a solution?
Since we're talking about it, of course there is.
The Pre-Go 1.18 Workaround: Empty Interface interface{}
Before Go 1.18, experienced developers typically used the empty interface interface{} and reflection mechanisms to solve this problem.
// Use empty interface to handle multiple types
func MaxInterface(a, b interface{}) interface{} {
// This requires a lot of type assertions
// And if you pass in a type that cannot be compared (like map), the program will directly Panic at runtime!
// The code needs to be too long and unsafe, so it's not fully demonstrated here due to space constraints
return nil
}
Fatal Drawbacks of Empty Interface:
- Loss of type safety: The compiler cannot check errors for you during compilation. If you pass the wrong type, the program will only report an error and crash at runtime.
- Performance overhead: Reflection and type assertions bring additional performance overhead (but you absolutely cannot say it's unusable because of performance issues).
- Extremely bloated code: A large number of
switch x.(type)statements are dazzling.
After Having Generics
So, what are generics?
Generics, simply put, are "parameterization of types", passing the data type itself as a parameter.
Actually, if you observe the code written at the beginning of the article, you'll find a pattern: except for the different data types, the code logic is the same!
Now, with generics, you can integrate these methods into a single method and be done with it.
Previously, when we wrote functions, parameters were specific variables (like a = 10); now with generics, the data type itself also becomes a parameter (like telling the function "I want to execute you using the int type now").
Step-by-Step Guide to Writing Your First Generic Function
No amount of text is as practical as writing some code. Let's still use the requirement of comparing two numbers discussed above.
Now, let's rewrite the previous Max function using generics.
Look at the code first, then we'll break it down piece by piece:
package main
import "fmt"
// Define a custom type constraint, indicating these numeric types are all usable
type Number interface {
int | int32 | int64 | float32 | float64
}
// This is a generic function!
// [T Number] is the type parameter list, indicating T can be any type within Number
func Max[T Number](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// Pass in int type
fmt.Println("The largest int is:", Max(10, 20)) // Output: 20
// Pass in float64 type
fmt.Println("The largest float64 is:", Max(3.14, 2.71)) // Output: 3.14
}
Core Syntax Breakdown (Pay attention, this is the key point!)
Let's look closely at the code above: func Max[T Number](a, b T) T
[T Number]: This is called the type parameter list. It immediately follows the function name and is enclosed in square brackets[].
Tis the "code name" we give to the type (you can call itA,MyType, whatever; you can just understand it as a placeholder, but conventionallyTis used to represent Type).Numberis called the type constraint, which tells the compiler: "ThisTcannot be just any cat or dog; it must be one of the numeric types defined inNumber."
(a, b T): The ordinary function parameter list. It indicates that the types of formal parametersaandbare both thatTdata type.- The final
T: Indicates the return type of the function is alsoT.
When you call Max(10, 20), the Go compiler automatically infers that you are passing int, so it will automatically replace T with int under the hood and execute the comparison logic.
Alright, through the simple understanding above, do you have a preliminary grasp of generics now?
The Most Commonly Used Built-in Constraints: any and comparable
Actually, in real development, you might not need to customize a Number like above every time. The Go language has already built in some commonly used type constraints.
1. any Constraint: Can Accept Any Data Type
any is actually an alias for interface{}.
// Print a slice of any type
// [T any] indicates T can be any type
func PrintSlice[T any](s []T) {
for _, v := range s {
fmt.Printf("%v ", v)
}
fmt.Println()
}
func main() {
PrintSlice([]int{1, 2, 3}) // Output: 1 2 3
PrintSlice([]string{"Go", "泛型"}) // Output: Go 泛型
}
2. comparable Constraint: Only Accepts Types Comparable with == and !=
If you need to determine whether two values are equal (like finding a corresponding Key in a Map), you must use comparable.
It supports numbers, strings, booleans, etc., but does not support Slices and Maps, because they cannot be directly compared using ==.
// Find the index of an element in a slice, return -1 if not found
// [T comparable] indicates T must be a type that can use == for comparison
func FindIndex[T comparable](slice []T, target T) int {
for i, v := range slice {
if v == target { // Because of the comparable constraint, == can be used here
return i
}
}
return -1
}
If you can understand the code above, congratulations, you have learned the slices.Index() method in the slices package.
Not Just Functions, But Also Generic Types!
Generics can be used not only on functions but also on structs. This is very useful when defining general-purpose data containers (like stacks, queues, linked lists).
For example, if we want to implement a general-purpose "Stack" that can store various types of data, we can write the code like this:
// Define a generic struct
// [T any] indicates this stack can store any type of data
type Stack[T any] struct {
elements []T
}
// Add a Push method to the generic stack
// Note the receiver should be written as *Stack[T]
func (s *Stack[T]) Push(value T) {
s.elements = append(s.elements, value)
}
// Add a Pop method to the generic stack
func (s *Stack[T]) Pop() (T, bool) {
if len(s.elements) == 0 {
var zero T // Declare a zero value of type T
return zero, false
}
// Get the last element
lastIndex := len(s.elements) - 1
value := s.elements[lastIndex]
// Shrink the slice
s.elements = s.elements[:lastIndex]
return value, true
}
func main() {
// Instantiate a stack specifically for storing ints
var intStack Stack[int]
intStack.Push(100)
// Instantiate a stack specifically for storing strings
var stringStack Stack[string]
stringStack.Push("Hello")
}
Through the code above, we used a single piece of logic to easily create stack data structures for different types. Doesn't the code look very nice now?
Summary
- Why generics are needed: To reduce duplicate code, improve type safety, and bid farewell to the runtime risks brought by
interface{}. - Basic syntax: Add
[T ConstraintType]after the function name or type name. - Constraint types: You can use
any(any type, which is actuallyinterface{}),comparable(equatable types), or useinterfacewith the|symbol to customize a type set.
When you need to handle general logic, write low-level utility libraries, or general data structures, using generics allows you to write more elegant code.
Now you should have some understanding of generics, right? If this article was helpful to you, don't forget to give it a like and wow.
Finally, here's a question for everyone to discuss:
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64
}
Why is a generic constraint sometimes defined like this? What does the ~ in front of ~int mean? Why is it sometimes not needed?
I'll just throw this out there to start the discussion. Welcome all you charming and handsome folks to chat about it!