The slice aliasing gotcha
Slicing is cheap because the result shares storage with the original. That cheapness costs something: modifying the shared region through either slice changes what both see. The previous lesson mentioned this in passing. This lesson makes it concrete with three scenarios almost every Go programmer trips over at least once.
Scenario 1: writing through a sub-slice
Try to predict what this program prints before you read on:
nums := []int{1, 2, 3, 4, 5}
middle := nums[1:4]
middle[0] = 99
middle[1] = 88
middle[2] = 77
fmt.Println(nums)
fmt.Println(middle)
The output:
[1 99 88 77 5]
[99 88 77]
middle looks like a brand-new slice, but its backing array IS nums's backing array. Every write through middle lands in the same memory that nums is watching. nums[0] and nums[4] are untouched only because middle does not cover those positions.
This is usually what you want when you intend to modify a window of a larger dataset (scaling pixels, zeroing a region, transforming a sub-range). It is a bug when you expected middle to be an independent copy, which it would be in many other languages.
Scenario 2: append into a sub-slice
Here is the trickier case. Predict first:
base := []int{1, 2, 3, 4, 5}
prefix := base[:3]
prefix = append(prefix, 99)
fmt.Println(base)
fmt.Println(prefix)
Answer:
[1 2 3 99 5]
[1 2 3 99]
prefix had length 3 and capacity 5 (it started at position 0 of a five-element array). append(prefix, 99) had room to fit without reallocating, so it wrote 99 into position 3 of the shared array. base was watching that position and just saw its fourth element change from 4 to 99.
You never wrote to base directly; an append through prefix did it for you. If the two slices felt like separate datasets in your head, this is a hard bug to spot.
Scenario 3: append triggers a reallocation
Same setup, but push append past the capacity:
base := []int{1, 2, 3, 4, 5}
prefix := base[:3]
prefix = append(prefix, 10, 20, 30, 40) // exceeds cap(prefix) = 5
prefix[0] = 999
fmt.Println(base)
fmt.Println(prefix)
Output:
[1 2 3 4 5]
[999 2 3 10 20 30 40]
This time append needed more room than the underlying array could give, so it allocated a new, bigger array, copied the first three elements across, and added the new values. prefix now points at the new array. base still points at the original.
The extra prefix[0] = 999 line is the proof. If prefix were still sharing storage with base, then base[0] would have changed too. It did not. Once the reallocation happened, later mutations through prefix stayed inside prefix.
The sharing is conditional: you get aliasing while the two slices share a backing array, and you stop getting aliasing the moment one of them grows past the original capacity and switches to a fresh allocation. That "sometimes shared, sometimes not" behaviour is exactly what makes the bug slippery. Code can pass your tests on small inputs (reallocation happens, slices diverge) and fail on large inputs (no reallocation, slices alias). Or vice versa.
How to avoid the trap
Two rules will handle almost every case:
- If you need an independent copy, say so explicitly. Do not assume slicing gives you one. The next lesson shows how to copy a slice on purpose.
- Assume any sub-slice shares memory with its parent. Mutate it only when you intend to mutate the parent. Treat it as read-only otherwise.
When you see a bug where a slice "magically changes" from somewhere else in the program, the first suspect is always an alias you forgot you had.
The starter contains a small program that combines Scenarios 1 and 2. Before you run it, predict what the two Printlns will output. Write your prediction down somewhere (even just in your head).
Then run it and see. Explain to yourself which mutations land in data, which stay in view, and why.
data := []int{10, 20, 30, 40, 50}
view := data[1:3]
view[0] = 200
view = append(view, 999)
fmt.Println(data)
fmt.Println(view)
[10 200 30 999 50] [200 30 999]