PidokuInfra

Arrays and Slices

Basic Beginner 55 min Difficulty 3/5 Topic 01 of 06

Prerequisites I.02

The idea in one minute#

An array is a fixed-size block of elements, laid out one after another; its length is part of its type and assigning it copies every element. A slice is a three-word header — pointer, length, capacity — describing a window onto an array that lives somewhere else.

Slicing (s[2:5]) makes a new header over the same array. append writes into spare capacity if there is any, and otherwise allocates a bigger array and copies. Whether two slices share memory after an append therefore depends on capacity — the source of most slice bugs.

An analogy#

An array is a row of numbered lockers bolted to the floor. A slice is an index card that says “start at locker 12, you may use 3, and there are 8 before the row ends”. Hand a copy of the card to a friend and you both use the same lockers. If you need more than 8, the school builds a new, longer row, moves your things, and gives you a new card — your friend’s old card still points at the old lockers.

A picture#

flowchart TB
  subgraph HDR["Slice headers, 24 bytes each"]
    S["s<br/>ptr, len 5, cap 8"]
    T["t := s[1:3]<br/>ptr+1, len 2, cap 7"]
  end
  subgraph ARR["One backing array on the heap, cap 8"]
    direction LR
    E0["0: a"] --- E1["1: b"] --- E2["2: c"] --- E3["3: d"] --- E4["4: e"] --- E5["5: spare"] --- E6["6: spare"] --- E7["7: spare"]
  end
  S --> E0
  T --> E1
  T -.->|"append(t, X) writes here:<br/>index 3, inside s"| E3
  class S,T queue
  class E0,E1,E2,E3,E4 memory
  class E5,E6,E7 neutral

How it really works#

Arrays#

[4]int and [5]int are different types. An array is a value: assigning or passing it copies all of it, and its size is known at compile time, so it can live on the stack or directly inside a struct with no pointer involved. Arrays are the building block; you will mostly use them as fixed-size keys, buffers and matrix rows.

The slice header#

Go
// what the runtime sees (reflect.SliceHeader, conceptually)
type slice struct {
    ptr *T     // first element this slice can see
    len int    // elements in use: valid indexes are 0..len-1
    cap int    // elements from ptr to the end of the backing array
}
ExpressionResult
make([]T, n)New array of n zeroed elements; len = cap = n
make([]T, 0, n)New array of n; len 0, cap n — the “I know how many” form
s[i:j]Same array; ptr+i, len j-i, cap cap(s)-i
s[i:j:k]Same, but cap limited to k-i — the full slice expression
var s []Tnil slice: ptr nil, len 0, cap 0. Safe to len, range and append
len(s), cap(s)Read the header; constant time
copy(dst, src)Copies min(len) elements; the way to get independent data
slices.Clone(s)A new slice with its own array

append#

Go
s = append(s, x)     // always assign the result
  1. If len < cap: write x into the existing array at index len, return a header with len+1. No allocation; anyone sharing the array sees the write.
  2. Otherwise: allocate a larger array, copy the elements, write x, return a header pointing at the new array. The old array is untouched and, if nothing else refers to it, garbage.

Growth: roughly doubling while the slice is small and about 1.25× once it is large (the changeover is gradual, around 256 elements), then rounded up to one of the allocator’s size classes (III.03). Doubling makes append amortized constant time; the price is one reallocation-and-copy per growth step and up to 2× over-allocation.

If you know the final size, say so: make([]T, 0, n). One allocation instead of a dozen.

The aliasing traps#

TrapWhat happensFix
t := s[:2]; t = append(t, x)Overwrites s[2]s[:2:2] to cap the capacity, or slices.Clone
Returning buf[:n] from a reused bufferThe caller’s data changes on the next readCopy before returning or storing
Keeping big[:10] aliveThe whole big array stays in memoryslices.Clone(big[:10])
Passing a slice to a function that appendsThe caller’s header is unchanged: it does not see the new lengthReturn the slice
for _, v := range s { v.x = 1 }v is a copy of the elementIndex: s[i].x = 1

Slices of slices#

[][]float32 is a slice of headers, each pointing at its own row. Rows can be anywhere in memory, so walking a matrix this way hops between allocations. Numerical code uses one flat slice and index arithmetic, data[i*cols+j] — contiguous, cache-friendly, one allocation. Module VI’s tensor is built that way.

Zero-length and nil#

A nil slice and an empty non-nil slice ([]int{}) behave the same for len, range and append. They differ only when compared with nil and in some encoders (JSON encodes nil as null, empty as []). Prefer len(s) == 0 to s == nil.

Code#

Go
// slices.go — headers, sharing, growth and the aliasing trap, measured.
package main

import (
	"fmt"
	"testing"
	"unsafe"
)

func header(name string, s []int) {
	fmt.Printf("%-10s ptr=%p len=%d cap=%d %v\n", name, unsafe.SliceData(s), len(s), cap(s), s)
}

func main() {
	s := make([]int, 5, 8)
	for i := range s {
		s[i] = i
	}
	t := s[1:3]
	header("s", s)
	header("t=s[1:3]", t)
	fmt.Printf("t starts %d bytes after s: same array\n\n",
		uintptr(unsafe.Pointer(unsafe.SliceData(t)))-uintptr(unsafe.Pointer(unsafe.SliceData(s))))

	// The trap: t has spare capacity that overlaps s.
	t = append(t, 99)
	header("s after", s)
	fmt.Println("append(t, 99) overwrote s[3]")

	// The fix: limit capacity so append must allocate.
	u := s[1:3:3]
	u = append(u, -1)
	header("u", u)
	header("s still", s)
	fmt.Println()

	// Growth: watch capacity change as we append.
	var g []int
	last := -1
	fmt.Print("capacities while appending 2,000 ints: ")
	for i := 0; i < 2000; i++ {
		g = append(g, i)
		if cap(g) != last {
			fmt.Print(cap(g), " ")
			last = cap(g)
		}
	}
	fmt.Println()

	// What preallocation saves.
	const n = 100000
	grow := testing.AllocsPerRun(10, func() {
		var x []int
		for i := 0; i < n; i++ {
			x = append(x, i)
		}
	})
	pre := testing.AllocsPerRun(10, func() {
		x := make([]int, 0, n)
		for i := 0; i < n; i++ {
			x = append(x, i)
		}
	})
	fmt.Printf("\nappending %d ints: %.0f allocations growing, %.0f preallocated\n", n, grow, pre)

	// A function gets a copy of the header.
	add := func(x []int) { x = append(x, 7); _ = x }
	h := make([]int, 0, 4)
	add(h)
	fmt.Println("after add(h): len(h) =", len(h), "but the array holds", h[:1])
}

Remember this#

  • A slice is (pointer, length, capacity) — 24 bytes — over an array that lives elsewhere.
  • Slicing shares; copy and slices.Clone separate.
  • append reuses spare capacity or reallocates; always assign its result.
  • Preallocate with make([]T, 0, n) when you know n. Use a flat slice for matrices.

Try it#

  1. Run slices.go. From the printed capacities, where does growth change from doubling to something slower?
  2. Write a Filter(s []int, keep func(int) bool) []int that reuses the input’s backing array (out := s[:0]). What does the caller’s original slice look like afterwards?
  3. Build a 1,000 × 1,000 matrix as [][]float64 and as one flat []float64. Count the allocations for each with testing.AllocsPerRun.

Check yourself#

  1. What are the three fields of a slice header?
  2. When does append allocate, and what happens to slices sharing the old array?
  3. What does the third index in s[i:j:k] do?

↑↓ navigate↵ openesc close