The new maps and slices packages in Go 1.23: tour and examples

GOLANG
5 min read

We're using Go to write Dolt, the world's first and only version-controlled SQL database.

dolt loves go

Like any Go program that deals with data, we often find ourselves in need of high-level functions that transform data from one form into another. Go has historically been very light on collections packages akin to Apache Commons, in large part due to lack of support for generic types.

But now generics have launched, and official support for collection convenience functions in the standard library followed closely behind in 1.23, in the maps and slices packages. In this blog, we'll take a look at a few of the more interesting functions and learn how to use them.

Iterator methods

The slices and maps package export several convenience methods for returning iterator functions over collections. Briefly:

All these methods return range iterators that you can use with the range keyword, as we demonstrated in our blog earlier this year. If you haven't really wrapped your head around range iterators yet, we recommend reading it to clear up the concept. They're not really hard to understand, but the method signatures make them seem like they are.

You can use range iterators with the range keyword like this:

	is := []int{0, 10, 20, 30, 40}
    for i, v := range slices.All(is) {
        fmt.Printf("%d: %v\n", i, v)
    }

But just using them for this simple purpose doesn't really add much, since maps and slices already work with the range keyword without these new library functions. The real power of these methods is in combination with the other functions in the library, many of which accept these iterators.

Creating and populating slices and maps

The slices and maps packages both have methods called Collect, which let you create a new collection from an iterator. Here's what it looks like in action:

	is := []int{0, 10, 20, 30, 40}
	m := maps.Collect(slices.All(is))

The equivalent code to do this with a loop looks like this.

	m := make(map[int]int)
	for i, v := range is {
		m[i] = v
	}

So we go from 4 lines of code to a one-liner to create and populate our map, which is very convenient when you happen to have a range iterator around (and want to map a slice's indexes to its values).

Sorting map elements

Go randomizes the iteration order of map elements as a deliberate policy, to prevent you from relying on it. But sometimes, you really want a consistent ordering of a map's elements. Now there's an easy way to get one.

	sortedKeys := slices.Sorted(maps.Keys(m))
	sortedVals := slices.Sorted(maps.Values(m))

Without using the slices and maps packages, you would have to write this code:

	keys := make([]int, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Ints(keys)

	values := make([]int, 0, len(m))
	for _, v := range m {
		values = append(values, v)
	}
	sort.Ints(values)

This turns a 5-line block of code into a one-liner. Pretty handy!

Copying maps and slices

Prior to Go 1.23, if you wanted to make a copy of a slice or a map, you had to use make and write a loop (or use the copy built-in for a slice). Now we get Clone() for both slices and lists:

	cloned := slices.Clone(is)
	clonedMap := maps.Clone(m)

These one-liners replace these blocks:

	cloned := make([]int, len(is))
	copy(cloned, is)

	clonedMap := make(map[int]int)
	for k, v := range m {
		clonedMap[k] = v
	}

Similarly, maps.Copy() now provides a one-line way to insert all the elements from one map into another, similar to the built-in copy for slices.

	maps.Copy(clonedMap, m)
	
	// without the maps package:
	// for k, v := range m {
	// 	clonedMap[k] = v
	// }

Finally, the slices.Concatenate() method is a great way to create a new slice from the contents of other ones.

	concatenated := slices.Concat(is, cloned, slices.Collect(maps.Keys(m)))	
	concatenated = slices.Concat(is, is, is)	

Think of the slices.Concatenate() as a better version of append for those times when you know you want to create a new slice, instead of sometimes re-using an existing one. It replaces this much more verbose code:

    // concatenated := slices.Concat(is, cloned, slices.Collect(maps.Keys(m)))	
	concatenated := make([]int, 0, len(is) + len(cloned) + len(m))
	concatenated = append(concatenated, is...)
	concatenated = append(concatenated, cloned...)
	for k := range m {
		concatenated = append(concatenated, k)
	}

    // 	concatenated = slices.Concat(is, is, is)
    concatenated = append([]int{}, appen(is, append(is, is...)...)...)

The missing methods: slices.Map() and slices.Filter()

In our first example, we showed how to create a new map from the indexes of a slice to its values:

	is := []int{0, 10, 20, 30, 40}

	m := maps.Collect(slices.All(is))

This is cute, but it's rarely what we want. What we actually want is a way to create a map from the values in a slice by applying some transform function. This would be slices.Map(), but it doesn't exist.

We can write our own:

func sliceToMap2[K comparable, V any, S any](s []S, mapFn func(S) (K, V)) map[K]V {
	m := make(map[K]V)
	for _, k := range s {
		key, val := mapFn(k)
		m[key] = val
	}
	return m
}

stringMap2 := sliceToMap2(is, func(v int) (string, string) {
	return fmt.Sprintf("key %d", v), fmt.Sprintf("value %d", v  * 10)
})

Because our slice2Map2 function is generic, we can use it on any kind of slice to produce any kind of map. The code above yields the following map contents:

string map 2: map[key 0:value 0 key 10:value 100 key 20:value 200 key 30:value 300 key 40:value 400]

But because of the anonymous func, the space savings here aren't compelling unless you pass it a func defined statically somewhere else. Depending on your code base, that might be a good idea if you find yourself doing this a lot.

Similarly, the slices package contains no Filter() method, and it was explicitly rejected for being too complex. Rob Pike himself chimed in to strike down the suggestion.

You can just use a for loop, which is more flexible in general. I am not being facetious; the filter operation tends to obscure allocation and overhead, and also tends to be overused. Although I may be in the minority, I do not believe this would be a wise addition to the standard library.

So we don't get a slices.Filter() in the standard library, but writing your own is easy:

func filtered[S any](s []S, fn func(S) bool) iter.Seq[S] {
	return func(yield func(s S) bool) {
		for _, v := range s {
			if fn(v) {
				if !yield(v) {
					return
				}
			}
		}
	}
}

filteredSlice := slices.Collect(filtered(is, func(v int) bool {
	return v > 20
}))

This has the same problem as our slice2map2 example: the anonymous func takes up a lot of space. If you want this to be a true one-liner like is possible in other languages, you'll need to define your predicate function elsewhere so you can pass it on the same line.

Conclusion

Want to talk about Go library functions? Or maybe you're curious about Dolt, the world's first version-controlled SQL database? Come by our Discord to talk to our engineering team and meet other Dolt users.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.