05 - Data Structures

1) Arrays

Basics

An array in Go is a fixed-size sequence of elements of the same type. Its length is part of its type: [4]int and [5]int are distinct types.

  • Arrays are value types: assignment or function call copies all elements.
  • Zero value is an array of zero-valued elements.
  • Useful for fixed-size data (e.g. [32]byte crypto keys).
  • Arrays can be compared if element type is comparable.
array-basic.go
package main

import "fmt"

func main() {
	var a [3]int                         // [0 0 0]
	b := [3]int{1, 2, 3}                 // literal
	c := [...]string{"x", "y", "z"}      // length inferred
	fmt.Println("a:", a)
	fmt.Println("b:", b)
	fmt.Println("c:", c, "len:", len(c))
}

Copy Semantics

Assigning arrays duplicates all contents. To avoid copying, pass a pointer to an array.

array-copy.go
package main

import "fmt"

func mutate(arr [3]int) { arr[0] = 99 }

func mutatePtr(arr *[3]int) { arr[0] = 99 }

func main() {
	a := [3]int{1, 2, 3}
	mutate(a)            // works on a copy
	fmt.Println("after mutate:", a) // [1 2 3], unchanged

	mutatePtr(&a)        // modify original
	fmt.Println("after mutatePtr:", a) // [99 2 3]
}

2) Slices

Slicing Syntax a[i:j:k]

Slicing makes a new view into the same array. There are two forms:

  • a[i:j] (2-index): works on arrays, slices, pointers to arrays, and strings.
  • a[i:j:k] (3-index, full slice): works on slices, pointers to arrays (*[N]T), and array variables. Not allowed on array literals.
  • The third index k controls the capacity of the result slice and stops it from growing past that limit.
Note

Key rule

Use 2-index slicing everywhere. Use 3-index slicing when you want to limit capacity. Remember: 3-index is not allowed on array literals, only on slices, array variables, and pointers to arrays.
  • a[i:j] => elements with indices i up to j-1 (length = j - i).
  • Defaults: omitted i => 0; omitted j => len(a); so a[:] is the whole slice.
  • Bounds for a[i:j]: 0 ≤ i ≤ j ≤ len(a).
  • a[i:j:k] (full slice) also sets capacity: result cap = k - i.
  • Bounds for full slice: 0 ≤ i ≤ j ≤ k ≤ cap(a).
  • Full slice requires a slice or a pointer to an array (*[N]T). It’s not allowed on plain strings and not on a non-addressable array value.
slice-syntax.go
package main

import "fmt"

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

	// Basic 2-index slicing:
	s1 := a[1:4]       // [1 2 3], len=3, cap from a's backing starting at index 1
	fmt.Println("s1:", s1, "len:", len(s1), "cap:", cap(s1))

	// Omitting bounds:
	s2 := a[:3]        // [0 1 2]
	s3 := a[3:]        // [3 4 5]
	s4 := a[:]         // whole slice
	fmt.Println("s2:", s2, "s3:", s3, "s4:", s4)

	// Full slice (3-index): control capacity of the result.
	// Here we cap the view to stop it from growing into the rest.
	s5 := a[1:4:4]     // [1 2 3], len=3, cap=3 (since k=4 and i=1 => 4-1=3)
	fmt.Println("s5:", s5, "len:", len(s5), "cap:", cap(s5))

	// With full slice, appending past len forces a new backing array (since cap==len):
	s5 = append(s5, 99)
	fmt.Println("s5 after append:", s5, "cap:", cap(s5))

	// Slicing arrays produces a slice:
	arr := [...]int{10, 11, 12, 13}
	s6 := arr[1:3]     // OK: 2-index on array yields slice [11 12]

	// Full slice on array pointer (not plain array):
	p := &arr
	s7 := p[1:3:3]     // OK: full slice on *[N]T => [11 12], cap=2
	fmt.Println("s6:", s6, "s7:", s7, "cap(s7):", cap(s7))
}
// s1: [1 2 3] len: 3 cap: 5
// s2: [0 1 2] s3: [3 4 5] s4: [0 1 2 3 4 5]
// s5: [1 2 3] len: 3 cap: 3
// s5 after append: [1 2 3 99] cap: 6
// s6: [11 12] s7: [11 12] cap(s7): 2
Note

Indexing intuition

i is inclusive start; j is exclusive end. The third index k limits capacity, which is useful to avoid accidental growth into adjacent elements of the same backing array.

Slice Header

A slice is a descriptor of an underlying array: pointer + length + capacity.

slice-header.go
package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	a := [...]int{0, 1, 2, 3, 4, 5}
	s := a[1:4] // [1 2 3]
	fmt.Println("s:", s, "len:", len(s), "cap:", cap(s))

	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Printf("header: Data=%#x Len=%d Cap=%d\n", hdr.Data, hdr.Len, hdr.Cap)

	s[0] = 99
	fmt.Println("after s[0]=99, a:", a)
}
// s: [1 2 3] len: 3 cap: 5
// header: Data=0xc000104008 Len=3 Cap=5
// after s[0]=99, a: [0 99 2 3 4 5]
Note

Alias semantics

Slicing does not copy data. Multiple slices can share the same array; mutating one affects the others.

Make and Append

make([]T, len, cap) allocates a backing array. Appending grows the slice, possibly allocating a new array if capacity is exceeded.

slice-append.go
package main

import "fmt"

func main() {
	s := make([]int, 0, 2)
	fmt.Println("start:", s, "len:", len(s), "cap:", cap(s))

	s = append(s, 1, 2)
	fmt.Println("after append 1,2:", s, "len:", len(s), "cap:", cap(s))

	// Next append exceeds capacity => runtime allocates a new backing array
	s = append(s, 3)
	fmt.Println("after append 3:", s, "len:", len(s), "cap:", cap(s))

	// Show that appending may detach from old backing array
	t := s[:2]
	s = append(s, 4, 5, 6, 7)
	fmt.Println("t (aliased prefix):", t)
	fmt.Println("s (after growth):", s, "cap:", cap(s))
}
// start: [] len: 0 cap: 2
// after append 1,2: [1 2] len: 2 cap: 2
// after append 3: [1 2 3] len: 3 cap: 4
// t (aliased prefix): [1 2]
// s (after growth): [1 2 3 4 5 6 7] cap: 8
Note

Growth strategy

Go doubles capacity while cap < 256. Beyond that, capacity grows by ~1.25× (smoothed) until it meets the need; if the required size exceeds 2×cap, Go jumps directly to the required capacity. (Runtime: nextslicecap)

Pitfalls

  • Holding a small slice of a large array keeps the entire array in memory.
  • append may reallocate: old references won’t see new elements.
  • Use the 3-index slice a[i:j:k] to limit capacity and prevent leaks.
slice-trim.go
package main

import "fmt"

func main() {
	big := make([]byte, 0, 1_000_000) // huge backing array capacity
	// Pretend we only need first 100 bytes of it:
	small := big[:100]
	fmt.Println("before trim len/cap:", len(small), cap(small))

	// Trim capacity to length so the rest of the big array isn't retained by small.
	small = small[:len(small):len(small)]
	fmt.Println("after trim len/cap:", len(small), cap(small))

	// Demonstrate append creates a new backing array (since cap == len).
	small = append(small, 1)
	fmt.Println("after append len/cap:", len(small), cap(small))
}
// before trim len/cap: 100 1000000
// after trim len/cap: 100 100
// after append len/cap: 101 208

3) Maps

Basics

A map is a hash table: map[K]V. Keys must be comparable (no slices, maps, functions).

  • Zero value is nil. Lookups on nil map are safe; writes panic.
  • Iteration order is deliberately randomized.
  • len(m) returns number of entries.
map-basic.go
package main

import "fmt"

func main() {
	// Safe lookup on nil map:
	var nilMap map[string]int
	fmt.Println("lookup on nil map:", nilMap["x"]) // 0

	// But writes to nil map panic; use make:
	m := make(map[string]int)
	m["a"] = 1
	m["b"] = 2

	if v, ok := m["a"]; ok {
		fmt.Println("m[\"a\"] =", v)
	}

	fmt.Println("len:", len(m))
	for k, v := range m {
		fmt.Printf("%s=%d ", k, v)
	}
	fmt.Println()
}
// lookup on nil map: 0
// m["a"] = 1
// len: 2
// a=1 b=2

Implementation

Maps use an array of buckets. Each bucket holds up to 8 key/value pairs. Overflow buckets are chained as needed.

Note

Load factor

When average load per bucket ≥ ~6.5, the map grows (typically doubling buckets) and rehashes incrementally.

Best Practices

  • Preallocate with make(map[K]V, n) to avoid rehashing.
  • Use the ok idiom for safe lookups.
  • Do not rely on iteration order.
  • Not safe for concurrent writes – use sync.Map or locks.

4) Sets via Maps

Idiomatic Set

Go has no built-in set type. The idiom: map[T]struct{}.

set.go
package main

import "fmt"

type IntSet map[int]struct{}

func (s IntSet) Add(x int)    { s[x] = struct{}{} }
func (s IntSet) Remove(x int) { delete(s, x) }
func (s IntSet) Has(x int) bool {
	_, ok := s[x]
	return ok
}

func main() {
	s := make(IntSet)
	s.Add(2)
	s.Add(5)
	fmt.Println("Has 2?", s.Has(2))
	fmt.Println("Has 3?", s.Has(3))
	s.Remove(2)
	fmt.Println("Has 2 now?", s.Has(2))
}
Tip

Why struct{}?

struct{} takes 0 bytes. Using map[T]bool wastes space.

5) Performance & Best Practices

Preallocation

Use make with capacity hints to reduce allocations.

prealloc.go
package main

import "fmt"

func main() {
	s := make([]int, 0, 1000) // ready for ~1000 appends
	for i := 0; i < 5; i++ {
		s = append(s, i)
		fmt.Println("len:", len(s), "cap:", cap(s))
	}

	m := make(map[string]int, 4) // initial space for ~4 entries
	m["a"], m["b"], m["c"], m["d"] = 1, 2, 3, 4
	fmt.Println("map len:", len(m))
}

Copy vs Loop

Prefer the built-in copy function over manual loops. It’s optimized in assembly.

copy.go
package main

import "fmt"

func main() {
	src := []int{1, 2, 3, 4, 5}
	dst := make([]int, len(src))
	n := copy(dst, src)
	fmt.Println("copied:", n, "dst:", dst)
}

Memory Safety

  • Trim slice capacity to allow GC of unused backing arrays.
  • Avoid keeping references to huge arrays through small slices.
  • For maps: use nil only if read-only (writes panic). For slices: nil vs []T{} are both valid; choose based on semantics (e.g., JSON null vs []).