20. Sub-slices and shared memory

📖 Reading · 10 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Slicing a slice is free — you get a new header pointing into the same backing array. That's what makes it fast, and it's also the source of Go's most notorious bug class. This lesson is about seeing the sharing before it surprises you.

The slicing expression

package main

import "fmt"

func main() {
    nums := []int{0, 1, 2, 3, 4, 5, 6}

    fmt.Println(nums[2:5])
    fmt.Println(nums[:3])
    fmt.Println(nums[4:])
    fmt.Println(nums[:])
}

s[low:high] includes low and excludes high — the same half-open range Python uses. Omit either side for "from the start" / "to the end".

The result is a slice of length high-low. No elements were copied to produce it.

Sub-slices share the array

Here's the part that bites:

package main

import "fmt"

func main() {
    nums := []int{0, 1, 2, 3, 4, 5, 6}
    window := nums[2:5]

    fmt.Println("before:", nums, window)

    window[0] = 999

    fmt.Println("after: ", nums, window)
}

Writing through window changed nums. There is only one array; window is a different view of it, not a different copy of the data.

This is deliberate, and mostly it's what you want — passing data[100:200] to a function costs three words, not a hundred elements. But it means "I took a slice of it" is not the same as "I made a copy of it".

Capacity extends to the end of the array

A sub-slice's capacity runs from its start to the end of the backing array, not to the end of the slice:

package main

import "fmt"

func main() {
    nums := []int{0, 1, 2, 3, 4, 5, 6}
    window := nums[2:5]

    fmt.Println(window, "len:", len(window), "cap:", cap(window))

    window = append(window, 42)

    fmt.Println("window:", window)
    fmt.Println("nums:  ", nums)
}

window had spare capacity, so append didn't allocate a new array — it wrote 42 into the slot that nums[5] was already using. Appending to a sub-slice overwrote data in the parent.

That's the bug. It happens quietly, it only happens when there's spare capacity, and it can survive a lot of testing before it hurts you.

Three defences

1. Copy when you mean copy.

package main

import "fmt"

func main() {
    nums := []int{0, 1, 2, 3, 4, 5, 6}

    window := make([]int, 3)
    copy(window, nums[2:5])

    window[0] = 999
    fmt.Println("nums:  ", nums)
    fmt.Println("window:", window)
}

2. Use a three-index slice to cap the capacity.

package main

import "fmt"

func main() {
    nums := []int{0, 1, 2, 3, 4, 5, 6}

    window := nums[2:5:5]
    fmt.Println(window, "len:", len(window), "cap:", cap(window))

    window = append(window, 42)
    fmt.Println("window:", window)
    fmt.Println("nums:  ", nums)
}

s[low:high:max] sets the capacity to max-low. With cap == len, the very next append is forced to allocate a fresh array, so the parent can't be touched. This is what careful library code returns when it hands a caller a sub-slice.

3. Don't hold a small slice of a huge array.

func firstLine(hugeFile []byte) []byte {
    i := bytes.IndexByte(hugeFile, '\n')
    return hugeFile[:i] // keeps the WHOLE file alive
}

The returned 40-byte slice points into a 200 MB array, and the garbage collector can't free any of it while that slice exists. If you're keeping a small piece of something large, copy the piece out.

Slices of slices are shallow too

The same logic applies one level up:

package main

import "fmt"

func main() {
    grid := [][]int{
        {1, 2, 3},
        {4, 5, 6},
    }

    row := grid[0]
    row[0] = 100

    fmt.Println(grid)

    for _, r := range grid {
        for _, v := range r {
            fmt.Print(v, " ")
        }
        fmt.Println()
    }
}

grid[0] isn't a copy of the row — it's the row. Copying the outer slice would still leave every inner slice shared; a deep copy means copying each row.

Deleting an element

There's no remove built-in. The idiomatic delete uses append and a spread:

package main

import "fmt"

func main() {
    items := []string{"a", "b", "c", "d", "e"}

    i := 2
    items = append(items[:i], items[i+1:]...)

    fmt.Println(items)
}

Read it as "everything before index i, followed by everything after it". Note that this does modify the original backing array — after this call the old items you may have handed to somebody else is scrambled. That's consistent with everything above: one array, many views.

Your turn

Given data, produce an independent copy of the middle three elements (indexes 1, 2, 3), change its first element to 0, and print both slices so the original is untouched:

[10 20 30 40 50]
[0 30 40]
package main

import "fmt"

func main() {
    data := []int{10, 20, 30, 40, 50}
    // make an independent copy of data[1:4] called mid, then set mid[0] = 0
    fmt.Println(data)
    fmt.Println(mid)
}
package main

import "fmt"

func main() {
    data := []int{10, 20, 30, 40, 50}
    mid := make([]int, 3)
    copy(mid, data[1:4])
    mid[0] = 0
    fmt.Println(data)
    fmt.Println(mid)
}

Next: Go's other workhorse container — maps.