12 Slice Tricks to Enhance Your Go Toolbox
There are many more techniques out there for manipulating slices in Go, beyond what's provided in slices package.
Previously, we looked at six different ways to use the switch statement in Go. Now, let’s dive into slices.
The Go Team has really put slices at the forefront in Go, even introducing a dedicated ‘slices’ package in the standard library with Go 1.21. This package offers handy functions for working with slices more easily.
But that’s not the whole story.
There are many more techniques out there for manipulating slices in Go, beyond what’s provided in slices
package. Some of the methods we’ll discuss might be similar to what you’ll find in the ‘slices’ package, but understanding these different approaches is key.
For the sake of clarity, we’ll focus on slices of integers in this discussion, but keep in mind that these concepts can be applied more broadly using Go’s generics
Size manipulation
1. Removing an element
The first idea might be to loop through the slice and construct a new one, omitting the element you want to remove.
Yet, there’s a more elegant way using the append function. It’s variadic, meaning it can take multiple arguments. This feature allows us to use the spread operator to efficiently handle slice elements:
func remove(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}
Here’s how it works in a practical scenario. Say we have a slice of integers and want to remove the element at index 2:
a := []int{1, 2, 3, 4, 5}
a = remove(a, 2) // Result: [1 2 4 5]
A common question is, what happens if we try to remove an element at the end of the slice? Will a[5:]… cause a panic?
The answer is nuanced.
Directly accessing a[5] would cause a panic, but a[5:] returns an empty slice. However, a[6:] is out of bounds and will cause a panic.
a := []int{1, 2, 3, 4, 5}
a = append(a[:4], a[5:]...) // Result: [1 2 3 4]
2. Remove an element without preserving order
If performance is a priority and element order isn’t important, there’s a quicker method.
By swapping the target element with the last one and then slicing off the end, you can remove an element efficiently:
func removeWithoutOrder(s []int, i int) []int {
s[i] = s[len(s)-1]
return s[:len(s)-1]
}
Applying this to our slice a
:
a := []int{1, 2, 3, 4, 5}
a = removeWithoutOrder(a, 2) // [1 2 5 4]
This method is handy for situations where the order of elements isn’t crucial. It offers a performance benefit by avoiding the need to shift elements.
3. Remove subset of elements
Now, let’s navigate a bit of a trickier scenario: removing a specific range of elements from a slice. This requires a bit more finesse compared to removing a single element.
func removeRange(s []int, i, j int) []int {
return append(s[:i], s[j:]...)
}
This function looks straightforward, but as with many operations in Go, attention to detail is crucial. One potential issue is index out-of-range errors.
To handle these cases more gracefully, we can enhance our function:
func removeRangeSafe(s []int, i, j int) []int {
if i > len(s) {
i = len(s)
}
if j > len(s) {
j = len(s)
}
return append(s[:i], s[j:]...)
}
With this safer version, we’re checking the indices i and j to ensure they’re within the bounds of the slice. This precaution helps avoid runtime errors.
Choosing between the straightforward removeRange
and the more cautious removeRangeSafe
depends on your context and requirements.
Sometimes, triggering a panic with an out-of-range index can be a deliberate move to quickly expose flawed logic in your code. A bug that causes a panic is often more noticeable and easier to debug than one that fails silently.
4. Insert
Insertion into a slice is where things get a bit more complex compared to removal. Consider this seemingly complex one-liner:
func insert(s []int, index int, values ...int) []int {
return append(s[:index], append(values, s[index:]...)...)
}
Breaking it down into its core components:
The inner
append(values, s[index:]…)
combines the new elements we want to insert (values) with the latter part of our original slice, starting from index.Next, the outer
append(s[:index], …)
merges above temporary slice with the first part of s, up to index.
Swapping
5. Shuffle
Shuffling a slice in Go is straightforward, thanks to the math/rand package, here’s how we can do it:
a := []int{1, 2, 3, 4, 5}
rand.Shuffle(len(a), func(i, j int) {
a[i], a[j] = a[j], a[i]
})
You might’ve noticed something neat here.
Unlike some languages that require a temporary variable or complex techniques, Go lets you swap elements directly using the intuitive syntax (a, b = b, a).
“Hold on a minute, why do I get the same shuffled result every time I run this?”
That’s a good observation, it’s not a mistake in your code.
In Go versions up to 1.19, the rand
package relies on a seed value to generate random sequences. By default, this seed is set to the same value (1) every time a program runs, leading to repeatable results.
To get a different shuffle each time, we can set the seed using the current time:
a := []int{1, 2, 3, 4, 5}
rand.Seed(time.Now().UnixNano()) // <------
rand.Shuffle(len(a), func(i, j int) {
a[i], a[j] = a[j], a[i]
})
But starting with Go 1.20, there’s a slight change.
If you don’t explicitly seed the generator using rand.Seed()
, Go will automatically seed it randomly at the start of the program. This change simplifies the process, often allowing you to skip the rand.Seed(time.Now().UnixNano())
.
6. Reverse
Reversing can be done by iteratively swapping the elements from the ends towards the center and this process utilizes Go’s multiple assignment capability for a clean and efficient solution:
a := []int{1, 2, 3, 4, 5}
for i := 0; i < len(a)/2; i++ {
a[i], a[len(a)-1-i] = a[len(a)-1-i], a[i]
}
a // [5 4 3 2 1]
This method takes the first and last elements of the slice and swaps them, then moves inwards, repeating the process until it reaches the middle of the slice.
Stack & Queue
Diving further, slices in Go are versatile enough to simulate more complex data structures like stacks and queues. We’re already familiar with the append()
function for adding elements, so let’s skip the basics of push
and enqueue
operations.
Also, adding elements to the start of a slice is slightly different:
func pushStart(s []int, v int) []int {
return append([]int{v}, s...)
}
7. Pop & Peek
The pop
function in Go slices serves to extract and remove the last element:
func pop(s []int) (int, []int) {
if len(s) == 0 {
return 0, s
}
return s[len(s)-1], s[:len(s)-1]
}
We first check if the slice is empty to avoid panics. If it’s not empty, we return the last element and the slice minus this last element.
The peek
function, on the other hand, simply returns the last element without removing it:
func peak(s []int) int {
if len(s) == 0 {
return 0
}
return s[len(s)-1]
}
Just like with pop, we check for an empty slice to ensure safe operation. Both pop and peek are common operations in stack-like data structures.
Note: you might want to return a boolean to indicate whether the stack is empty or not.
8. Dequeue
dequeue
is another slice operation, distinct from pop, it removes the first element from the slice:
func dequeue(s []int) (int, []int) {
if len(s) == 0 {
return 0, s
}
return s[0], s[1:]
}
Filtering & Transforming
9. Filter
Filtering involves removing elements from a slice that don’t meet certain criteria defined by a predicate function:
func filter(s []int, fn func(int) bool) []int {
var r []int
for _, v := range s {
if fn(v) {
r = append(r, v)
}
}
return r
}
For example, to keep only even numbers in a slice:
a := []int{1, 2, 3, 4, 5}
a = filter(a, func(v int) bool {
return v%2 == 0
})
// [2 4]
10. Map
When it comes to transforming elements in a slice, you can choose between two methods: in-place or out-of-place transformation.
In-place transformation modifies the original slice, here’s how we can implement it:
func mapInPlace(s []int, fn func(int) int) {
for i, v := range s {
s[i] = fn(v)
}
}
Since this alters the original slice, we don’t return anything but the out-of-place version returns a new slice:
func mapOutOfPlace(s []int, fn func(int) int) []int {
var r = make([]int, 0, len(s))
for _, v := range s {
r = append(r, fn(v))
}
return r
}
Mapping can also involve changing the type of the slice elements, like mapping int
to string
and this introduces some complexity with generics:
func mapOutOfPlace[T, K any](s []T, fn func(T) K) []K {
var r = make([]K, 0, len(s))
for _, v := range s {
r = append(r, fn(v))
}
return r
}
Others
In my personal projects, I’ve found a couple of techniques particularly useful. They might not be mainstream, but they’ve certainly been handy in specific scenarios.
11. Batch processing
Let’s talk about batch processing slices.
When you’re up against large slices, processing them all in one go can be a strain on memory and CPU. So I break them down into manageable chunks:
func batchProcess(s []int, batchSize int, processFn func([]int)) {
for start := 0; start < len(s); start += batchSize {
end := start + batchSize
if end > len(s) {
end = len(s)
}
processFn(s[start:end])
}
}
It’s a straightforward yet effective way to handle large data sets without overwhelming your resources.
12. Deduplicate
Go doesn’t have a built-in set
data structure, but we can mimic it using a map. Suppose you have a slice with duplicate elements and you want to clean it up:
func deduplicate(s []int) []int {
var r []int
seen := make(map[int]bool)
for _, v := range s {
if !seen[v] {
r = append(r, v)
seen[v] = true
}
}
return r
}
But when performance is critical, here's a small trick: use a map with empty struct{} values for more efficient memory usage:
func deduplicate(s []int) []int {
var r []int
seen := make(map[int]struct{})
for _, v := range s {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
r = append(r, v)
}
}
return r
}