Diving Deep into Go 1.21: Better Generics, New Libraries, and More
Go 1.21 has impressed me with its nuanced improvements in type inference, intriguing new packages within the core library, and thought-provoking preview of language changes
I recently got my hands on the new Go 1.21 draft release. It was interesting to see that generics are now a significant part of the language — they’re not only in places where you’d expect them to be, but they’ve also been integrated more broadly.
This comprehensive use of generics adds a whole new layer to the language. It makes the code more complex, but in a good way. It’s clear to me that the goal here is to make our lives as programmers easier and our programs sturdier. And to me, that’s a step in the right direction
Built-in: min, max & clear
min, max
min
and max
are used to extract the smallest and largest figures from a specified numeric collection, respectively. Below are their signatures along with some examples:
func min[T cmp.Ordered](x T, y ...T) T
func max[T cmp.Ordered](x T, y ...T) T
// Samples
m := min(5, 2, 10) // m == 2
m := max(5, 2, 10) // m == 10
To those unfamiliar with cmp.Ordered
, I recommend revisiting the Type Constraint segment in my earlier article: Generics in Go: A Belated Personal Guide.
Significantly, for floating point numbers, if any of the parameters equals NaN, the outcome will also be NaN. The precedence for floating point numbers is as follows:
NaN -Inf -0.0 0.0 +Inf NaN
An important caveat is that the …
syntax cannot be used to expand our slice or array into the function as illustrated:
a := []int{1, 2, 3, 4}
_ = min(1, a...) // compile error: invalid operation: invalid use of ... with built-in min
However, I’ve constructed a comparable function that accepts the identical function signature and performs as expected:
// before
_ = min(1, a...) // COMPILE ERROR
// after
_ = min2(1, a...) // OK
func min2[T cmp.Ordered](x T, y ...T) (t T) {
return t // just for compile error testing
}
Clear
The clear
function embedded in Go is designed to wipe a map of all its components or reset all elements of a slice to their zero (default) value:
func clear[T ~[]Type | ~map[Type]Type1](t T)
The function performs differently depending on the nature of its argument:
clear(m map[K]T)
: This call will eradicate all entries in the map, essentially leaving the map empty (the length of mapm
will be 0).clear(s []T)
: This call resets every slice element to its zero value, which fluctuates based on typeT
. For instance, for integers, it becomes 0, for strings, it’s an empty string “”, and for booleans, it’s false.clear(T ~[]Type | ~map[Type]Type1)
: Here,t
is a type parameter where all types in its type set have to be either maps or slices. The clear function then initiates the operation that correlates to the real type argument.clear(nil)
: Does nothing and doesn’t trigger a panic.
To gain a deeper understanding, consider the following examples:
// Map
m := map[string]int{"a": 1, "b": 2, "c": 3}
clear(m)
fmt.Println(m) // Outputs: map[]
// Slice
s := []int{1, 2, 3, 4, 5}
clear(s)
fmt.Println(s) // Outputs: [0 0 0 0 0]
New Libraries
This segment highlights significant libraries in the latest Go release; others won’t be discussed.
slog: New structured logging library
Substituting popular logging libraries like zerolog, logrus, zap,… Go 1.21 introduces the standard library structured slog, as seen in the following code snippet:
package main
import (
"log/slog"
"os"
)
func main() {
// plain text
slog.Info("Hello, World!")
slog.Default().With("key", "value").Info("Hello, World!")
// json
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello", "user", "user1", "age", 10)
}
// 2023/07/30 16:05:40 INFO Hello, World!
// 2023/07/30 16:05:40 INFO Hello, World! key=value
// {"time":"2023-07-30T16:05:40.866412+07:00","level":"INFO","msg":"hello","user":"user1","age":10}
For those unfamiliar with structured logs, read up on it here:
slices: generic slices helper
This package introduces a myriad of generic functions to operate on slices, accommodating slices of any data type, like Max
, Contains
, Replace
, Reverse
, Sort
, Clone
, etc.
func Max[S ~[]E, E constraints.Ordered](x S) E
func Contains(s S, v E) bool
func Replace(s S, i, j int, v ...E) S
func Reverse(s S)
func Sort(x S)
func Clone(s S) S
...
maps: generic map helper
Similar to the slices package, the maps package benefits from Go’s generics, with functions like Clone
, Copy
, DeleteFunc
, Equal
, and EqualFunc
:
func Clone(m M) M
func Copy(dst M1, src M2)
func DeleteFunc(m M, del func(K, V) bool)
// The function Equal examines if the pairings of keys and values in two map inputs are identical. This comparison is made using the == operator.
func Equal(m1 M1, m2 M2) bool
func EqualFunc(m1 M1, m2 M2, eq func(V1, V2) bool) bool
Notable Changes in Existing Libraries
The context package introduces a new function: AfterFunc
. This function is triggered after the context has been canceled. Here’s a simple implementation:
func main() {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
context.AfterFunc(ctx, func() {
fmt.Println("This runs after context has been cancelled")
})
time.Sleep(2 * time.Second) // simulate some delay
}
In the errors package, a new global error ErrUnsupported
is introduced for convenience. It enhances workflow management, as demonstrated below:
func DestroyEverything() error {
return errors.ErrUnsupported
}
func main() {
err := DestroyEverything()
if err != nil {
if errors.Is(err, errors.ErrUnsupported) {
fmt.Println("operation is not supported")
} else {
fmt.Println("An error occurred:", err)
}
}
}
The math/big package now supports the conversion of big integers to float64.
The Int.Float64
method is used for this purpose. It not only converts the value but also provides an Accuracy
value indicating whether the conversion led to truncation or rounding. Here is an example:
func main() {
bigInt := big.NewInt(0)
bigInt.SetString("1234567890123456789012345678901234567890", 10) // 40 digits
// Convert bigInt to a float64
floatVal, accuracy := bigInt.Float64()
fmt.Printf("Float64 value: %f\n", floatVal)
fmt.Printf("Accuracy: %v\n", accuracy)
}
Float64 value: 1234567890123456846996462118072609669120.000000
Accuracy: Above
Here’s the Accuracy
implementation:
// Accuracy describes the rounding error produced by the most recent
// operation that generated a Float value, relative to the exact value.
type Accuracy int8
// Constants describing the Accuracy of a Float.
const (
Below Accuracy = -1
Exact Accuracy = 0
Above Accuracy = +1
)
The sync package has added functions to support lazy initialization: OnceFunc
, OnceValue
, and OnceValues
.
OnceFunc
is used when you want to ensure that a specific function is executed only once, even when called from multiple goroutines:
func main() {
f := func() {
fmt.Println("Executing function...")
}
onceFunc := sync.OnceFunc(f)
go onceFunc()
go onceFunc()
// simulate waiting for goroutines to finish
time.Sleep(1 * time.Second)
}
OnceValue
is used when you want a value to be calculated/initialized only once:
func main() {
calculate := func() int {
fmt.Println("Calculating value...")
return 42
}
onceValue := sync.OnceValue(calculate)
go fmt.Println(onceValue())
go fmt.Println(onceValue())
go fmt.Println(onceValue())
// simulate waiting for goroutines to finish
time.Sleep(1 * time.Second)
}
OnceValues
is similar toOnceValue
but is used when you want a pair of values to be calculated/initialized only once. These updates provide more robustness and flexibility to our coding practices.
Preview a language change of Loopvar
A common pitfall involves the scoping of variables used within for loops, particularly when used with goroutines. The iteration variables in Go are reused in each iteration, which can lead to unexpected behavior:
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}
// 3
// 3
// 3
You might expect the program to print 0, 1, 2 (in some order). However, because the variable i
is shared across all goroutines, it’s possible that the goroutines won’t execute until the loop has finished, at which point i
equals 3. This means the program may print 3, 3, 3 instead of 0, 1, 2.
A typical solution in this case is to re-assign i := i
to create a new i
that is scoped per iteration, so the program will print 0, 1, 2 (in a random order).
The proposed change in Go 1.21 would make the loop variable i
scoped per iteration, not per loop. This means each iteration of the loop would get its own copy of i
, which could help avoid these kinds of bugs.
BUT this is an experimental change; the Go team is seeking feedback from the community before making it a permanent part of the language. I haven’t had a chance to experience it yet.
Predictability Package Initializing
In the latest update, Go has introduced a strict rule for better predictability during package initialization:
Sort all packages by import paths.
Repeatedly initialize the first package in the list where all its dependencies are already initialized, then remove it from the list.
This change can potentially disrupt some of your current services. However, overall, it makes package initialization more deterministic, which improves code reliability.
Type inference improvement
Generic function argument
Now, you can pass generic functions as arguments to other generic functions, and the Go compiler will infer the missing type arguments for both the called function and the function passed as an argument.
func foo[T any](f func(T) T, x T) T {
return f(x)
}
func bar[T any](x T) (t T) {
return t
}
func main() {
// before
foo(bar[int], 1)
// after: No need to specify the T type
foo(bar, 1)
}
Type inference from untyped constants
If you pass untyped constants of different types to a generic function, the compiler will now infer the type instead of raising an error.
type Ordered interface {
~int | ~float64
}
func add[T Ordered](a, b T) T {
return a + b
}
func main() {
const a = 5 // Untyped int constant
const b = 3.5 // Untyped float constant
fmt.Println(add(a, b)) // Go 1.21: 8.5
}
// Before: Default type float64 of b does not match inferred type int for T
// Go 1.21: 8.5