rss resume / curriculum vitae linkedin linkedin gitlab github twitter mastodon instagram
What is new in Go 1.23?
Sep 02, 2024

Disclaimer: This post includes Amazon affiliate links. Clicking on them earns me a commission and does not affect the final price.

Hello πŸ‘‹ It’s that time of the year again! Another minor version of Go was released, superseding Go 1.22, released a few weeks ago on August 13th; this is Go 1.23! πŸ’₯ πŸŽ‰ 🎊

Updating

Although this new version was released less than a month ago, most of the popular operating systems and package managers already have a way to upgrade to Go 1.23; so the typical packages for Windows, Linux and MacOS (including Homebrew!) already have updates available; and also the corresponding Docker images for Debian and Alpine:

For concrete examples:



What is new?

Most of the changes in Go 1.23 are related to the toolchain, runtime, and libraries; the most significant change in this release was adding to the language iterators support when using the for and range keywords. Please consider reading the release notes to get familiar with all the new changes, as well as the official blog post describing the release.

Let’s talk about some of the new features that were added in Go 1.23.

Range over func implementation

Code for this example is available on Github.

The range over func implementation is the highlight of Go 1.23, because it defines a standard way to implement iterators while maintaining backwards compatibility. The way to implement this feature is pretty straightforward: you have to implement one of the following three types:

func(func() bool)
func(func(K) bool)
func(func(K, V) bool)

For example, the following code:

60func MultiplyBy2Iter(vals ...int) func(func(int, int) bool) {
61	return func(yield func(int, int) bool) {
62		for i, v := range vals {
63			if !yield(i, v*2) {
64				return
65			}
66		}
67	}
68}

Will multiply the received values in vals by 2, one by one. This a big difference compared to what we used to do before if we wanted to use for/range:

52func MultiplyBy2(vals ...int) []int {
53	res := make([]int, len(vals))
54	for i, v := range vals {
55		res[i] = v * 2
56	}
57	return res
58}

Where the returned value is allocated in advance without considering if the user of our function will be using all results or not, this change is by far the most significant improvement when using iterators, where use cases that need to be memory efficient and deal will collections of values can perform better while keeping the code looking like it used before.

For example:

104	for i, v := range MultiplyBy2(1, 2, 3, 4, 5, 6) {
105		fmt.Println(i, v)
106	}
107
108	fmt.Println("---")
109
110	for i, v := range MultiplyBy2Iter(1, 2, 3, 4, 5, 6) {
111		fmt.Println(i, v)
112	}

New unique package

Code for this example is available on Github.

The unique package provides facilities for canonicalizing (“interning” or “hash consing”) comparable values. According to Wikipedia Hash Consing:

In computer science, particularly in functional programming, hash consing is a technique used to share values that are structurally equal. When a value is constructed, such as a cons cell, the technique checks if such a value has been constructed before, and if so reuses the previous value, avoiding a new memory allocation

The key part to understand this package is the comparable piece, this means only those types that can be compared can be used. For example:

 8// `comparable`
 9type User struct {
10	Name     string
11	LastName string
12}

Is comparable because all its fields are also comparable, review the spec to understand what types are comparable. Using unique is as easy as calling unique.Make, for example:

21	h1 := unique.Make(User{Name: "Mario", LastName: "Carrion"})
22	h2 := unique.Make(User{Name: "Mario", LastName: "Carrion"})
23
24	fmt.Println("same values?", h1 == h2)
25	fmt.Printf("addresses: %v - %v\n", h1, h2)

Will print out:

same values? true
addresses: {0xADDRESS} - {0xADDRESS}

Of course 0xADDRESS is not the literal value that is printed out, it will change, but the point I’m making here is that both will have the same memory address.

Trying to use non-comparable type, for example:

14// Not `comparable`
15type Group struct {
16	Name  string
17	Users []User
18}

Will not work because the Users field is a slice, a non comparable type.

New iter package

Code for this example is available on Github.

The iter package defines the conventions and guidelines to follow when implementing iterator as well as types that are used to work with iterators, and are referred by other packages (such as slices and maps) in the standard library:

iter.Seq[V any]     func(yield func(V) bool)    // One value
iter.Seq2[K, V any] func(yield func(K, V) bool) // Two values: key-value or index-value pairs

Besides those two types we have two new functions called iter.Pull and iter.Pull2 that correspond to iter.Seq and iter.Seq2 respectively. Pull functions exist to provide a different way to interact with iterators, the standard iterators are “push” iterators, meaning they “push” values back to the for/range, for example an iterator Iter:

13func Iter() func(func(int, int) bool) {
14	return func(yield func(int, int) bool) {
15		var v int
16
17		for {
18			if !yield(v, v+10) {
19				return
20			}
21			v++
22		}
23	}
24}

Will “push” the values back when used as:

27	for i, v := range Iter() {
28		if i == 5 {
29			break
30		}
31
32		fmt.Println(i, v, time.Now())
33	}

However, if the use case you’re trying to implement works better as a “pull” then, that’s where those two functions (Pull/Pull2) are used, for example:

37	next, stop := iter.Pull2(Iter())
38	defer stop()
39
40	for {
41		i, v, ok := next()
42		if !ok {
43			break
44		}
45
46		if i == 5 {
47			break
48		}
49
50		fmt.Println(i, v, time.Now())
51	}

Notice how:

  • L37: iter.Pull2 uses Iter() to return next() and stop(), two functions to literally do what they are named,
  • L40-L51: The for keyword is still used, but the values are pulled using next()
    • next() returns the key-value pair as well as an ok to indicate whether there are more pairs to pull or not,
  • L46-L48: Because of the break the for is completed and the stop function is called therefore indicating the iterator the job is done.

9 new functions added to the slices package to support iterators

Code for this example is available on Github.

The slices package adds 9 functions that support iterators:

  • All returns an iterator over slice indexes and values.
  • AppendSeq appends values from an iterator to an existing slice.
  • Backward returns an iterator that loops over a slice backward.
  • Chunk returns an iterator over consecutive sub-slices of up to n elements of a slice.
  • Collect collects values from an iterator into a new slice.
  • Sorted Sorted collects values from seq into a new slice, sorts the slice, and returns it.
  • SortedFunc is like Sorted but with a comparison function.
  • SortedStableFunc is like SortFunc but uses a stable sort algorithm.
  • Sorted collects values from an iterator into a new slice, and then sorts the slice.
  • Values returns an iterator over slice elements.

These new functions take advantage of the new iter.Seq and iter.Seq2 types. For example:

 9	numbers := []string{"5", "4", "3", "2", "1"}
10	fmt.Printf("numbers = %v\n", numbers)
11
12	// slices.Chunk
13	fmt.Printf("\nslices.Chunk(numbers, 2)\n")
14	for c := range slices.Chunk(numbers, 2) {
15		fmt.Printf("\t%s\n", c)
16	}
17
18	// slices.Collect
19	fmt.Printf("\nslices.Collect(slices.Chunk(numbers, 2))\n")
20	numbers1 := slices.Collect(slices.Chunk(numbers, 2))
21	fmt.Printf("\t%s\n", numbers1)
22
23	// slices.Sorted + slices.Values
24	fmt.Printf("\nslices.Sorted(slices.Values(numbers))\n")
25	sorted := slices.Sorted(slices.Values(numbers))
26	fmt.Printf("\t%v\n", sorted)
27
28	fmt.Printf("\nslices.Backward(numbers)\n")
29	for i, v := range slices.Backward(numbers) {
30		fmt.Printf("\ti: %d, v: %s\n", i, v)
31	}
  • L12-L16: slices.Chunk returns slices that have the size of the parameter, in this case it will return 3 slices with a length of 2.
  • L18-L21: slices.Collect will return a new slice with using the iterator parameter, so in this case it will return a slice of with a length of 3 that includes the other slices.
  • L23-L26:
    • slices.Values: returns the values of a slice as an iterator, and
    • slices.Sorted: sorts the received values in the iterator.
  • L28-L31: slices.Backward creates an iterator that iterates the values from last one to first one.

Running the program prints out the following:

numbers = [5 4 3 2 1]

slices.Chunk(numbers, 2)
	[5 4]
	[3 2]
	[1]

slices.Collect(slices.Chunk(numbers, 2))
	[[5 4] [3 2] [1]]

slices.Sorted(slices.Values(numbers))
	[1 2 3 4 5]

slices.Backward(numbers)
	i: 4, v: 1
	i: 3, v: 2
	i: 2, v: 3
	i: 1, v: 4
	i: 0, v: 5

5 new functions added to the maps package to support iterators

Code for this example is available on Github.

The maps package adds 5 functions that support iterators:

  • All returns an iterator over key-value pairs from a map.
  • Collect collects key-value pairs from an iterator into a new map and returns it.
  • Insert adds the key-value pairs from an iterator to an existing map.
  • Keys returns an iterator over keys in a map.
  • Values returns an iterator over values in a map.

Similar to the functions added to slices, these new functions take advantage of the new iter.Seq and iter.Seq2 types. For example:

10	numbers := map[string]int{
11		"one":   1,
12		"two":   2,
13		"three": 3,
14		"four":  4,
15		"five":  5,
16	}
17	fmt.Printf("numbers = %v\n", numbers)
18
19	// maps.Values
20	fmt.Printf("\nslices.Sorted(maps.Values(numbers))\n")
21	sortedValues := slices.Sorted(maps.Values(numbers))
22	fmt.Printf("\t%v\n", sortedValues)
23
24	// maps.Keys
25	fmt.Printf("\nslices.Sorted(maps.Keys(numbers))\n")
26	sortedKeys := slices.Sorted(maps.Keys(numbers))
27	fmt.Printf("\t%q\n", sortedKeys)
  • L19-L22: maps.Values will create an iterator that returns the values of the map, then we take advantage of slices.Sorted to sort those values.
  • L24-L27: maps.Keys will create an iterator that returns the keys of the map, then we take advantage of slices.Sorted to sort those keys.

The output of this program is:

numbers = map[five:5 four:4 one:1 three:3 two:2]

slices.Sorted(maps.Values(numbers))
	[1 2 3 4 5]

slices.Sorted(maps.Keys(numbers))
	["five" "four" "one" "three" "two"]

2 minor changes: slices.Repeat and net/http.Request.Pattern

Code for this example is available on Github.

Finally the last two additions are minor changes to the standard library, specifically two things:

For example:

13	mux := http.NewServeMux()
14	mux.HandleFunc("GET /hi", func(w http.ResponseWriter, req *http.Request) {
15		// New function: slices.Repeat + http.Request.Pattern
16		v, _ := json.Marshal(slices.Repeat([]string{req.Pattern}, 2))
17		w.Write(v)
18	})

Will write a JSON array with two values matching "GET /hi", so literally something like:

39	valStr := string(val)
40	if valStr != `["GET /hi","GET /hi"]` {
41		t.Fatalf("invalid value: %v", valStr)
42	}

Conclusion

I appreciate that the Go team keeps innovating and adding new features. However, I feel we are slowly starting to reach a point where we can consider the language itself to be “completed” (unless things such as Sum Types or real Enumerators ever come to fruition), not that I see the language moving into maintenance mode but adding new features while keeping the backwards compatibility promise makes adding new changes more challenging.

Every new release excites me because I want to learn about the latest features. However, it takes a while to properly evaluate them because, in some cases, this requires rewriting something that already exists and works to a new way of doing it (take Generics before and now Iterators). However, again, I’m not complaining; it’s nice to see Google is still investing in the language.

Great job, Go team; I’m looking forward to Go 1.24 next year!

If you’re looking to sink your teeth into more Go-related topics I recommend the following books:


Back to posts