Arrays, Slices and Maps: The Three Essential Containers
Module 1: Go Language Intro & Dev Environment | Lesson: m01-l07 | Level: Beginner After this lesson you will be able to: master the core use of arrays, slices and maps, understand slice growth and underlying-array sharing, and avoid common pitfalls such as concurrent map reads and writes.
Learning Objectives
- Distinguish the memory models of arrays and slices, and understand the structure of a slice header.
- Use
append,copyand slice expressions correctly, and explain slice growth behavior. - Perform map inserts, deletes, updates and lookups skillfully, and understand iteration randomness and its effect on deletions.
- Recognize and avoid the shared-underlying-array trap when passing slices, and the fatal error caused by concurrent map access.
Core Concepts
7.1 Arrays: Fixed-Size Value Types
An array is the most basic data structure in Go: a contiguous block of memory made up of elements of the same type.
- What: the length is part of the array type.
[5]intand[10]intare two completely different types. An array must have its length specified at declaration, and that length cannot change. - Why: Go chooses fixed-length arrays so that the memory size is known at compile time, yielding very high access efficiency and avoiding the overhead of dynamic allocation.
- How:
go var a [5]int // declared and initialized to the zero value [0 0 0 0 0] b := [3]int{1, 2, 3} c := [...]int{1, 2, 3} // the compiler infers the length as 3Note: an array is a value type. When an array is passed as a function argument, its entire contents are copied. On a 64-bit platform, passing an array of length N costs $N \times \text{sizeof(Element)}$ memory.
7.2 Slices: Dynamic Arrays with Reference Semantics
A slice is an abstraction over an array underneath, providing a dynamically sized view.
-
What: a slice is itself a data structure (a Slice Header) containing three fields:
Pointer: a pointer to the slice's starting element in the underlying array.Len: the number of elements in the slice.Cap: the capacity of the underlying array, i.e. the number of elements from the start position to the end of the array.
A slice is not a reference type; it is a value type (the header — 24 bytes: pointer 8 + len 8 + cap 8 — is copied when passed), but it shares the same underlying array with its sibling slices.
-
Why: to combine the performance of static arrays with the flexibility of dynamic arrays. By sharing the underlying array, slice operations (such as reslicing) complete in $O(1)$ time without copying data.
-
How:
- Creation:
go s := make([]int, 3, 5) // len=3, cap=5, underlying array length 5 s2 := []int{1, 2, 3} // len=3, cap=3 - Slice expression:
s[low:high:max] s[low:high]: takes elements from indexlowtohigh-1.s[low:high:max]: besides taking elements, it caps the new slice's maximum capacity atmax - low. This is commonly used to prevent data leaks or to reuse memory.- Iteration: use
for range.
- Creation:
7.3 append and the Growth Mechanism
append is the most common slice operation, used to add elements to the end of a slice.
-
Growth threshold and strategy (Go 1.18+): When the slice runs out of capacity,
appendallocates a new underlying array and copies the data. The growth strategy is:- When
oldcap < 256, the new capacity is roughly 2x the old capacity (about doubling). - When
oldcap >= 256, the new capacity grows by the formulanewcap += (newcap + 3*256)/4(roughly 1.25x), then rounds up to a memory size class. - Engineering practice: do not rely on the exact growth factor. If you know the approximate size of a slice, preallocate capacity with
make([]T, 0, n)to avoid the allocation and copying cost of repeated growth.
- When
-
The shared-underlying-array trap: If the slice has enough capacity,
appenddoes not trigger growth and writes directly into the underlying array. If multiple slices share the same underlying array, a modification through one slice affects the others.
7.4 map: A Hash Table
A map is Go's built-in hash table implementation, used for key-value storage.
- What:
map[K]V, where K must be a type supporting==and!=(such as int, string, struct, etc.; slices, maps and functions are not allowed). - Iteration is unordered: when
for rangeiterates over a map, the order of key-value pairs is random. It may differ on every run. - Deletion:
delete(map, key): removes the given key.- Spec semantics: deleting elements during a
for rangeloop is legal. The spec states: if an entry not yet reached is deleted during iteration, it will not be produced. - New entries: whether an entry inserted during iteration is produced is undefined (it may or may not show up), so do not add entries during iteration.
- Concurrency safety: a native map is not concurrency-safe. Concurrent reads and writes on the same map trigger
fatal error: concurrent map reads and map writesorconcurrent map writes— an unrecoverable fatal error.
Example Code
Example 1: Slice Growth and the Shared-Underlying-Array Trap
This example shows the underlying array being detached when append triggers growth, and the side effects of sharing the underlying array when capacity is sufficient.
package main
import "fmt"
func main() {
// Scenario 1: capacity runs out, growth triggers, and the underlying arrays separate
fmt.Println("=== Scenario 1: growth detaches the underlying array ===")
s1 := []int{1, 2, 3}
fmt.Printf("s1: %v, cap: %d\n", s1, cap(s1))
// At this point s1 has cap=3, so append needs to grow
s2 := append(s1, 4)
fmt.Printf("s1: %v, cap: %d\n", s1, cap(s1))
fmt.Printf("s2: %v, cap: %d\n", s2, cap(s2))
fmt.Println("s1 and s2 now have separate underlying arrays and do not affect each other.")
fmt.Println()
// Scenario 2: capacity is sufficient, writing directly into the shared array affects the original slice
fmt.Println("=== Scenario 2: enough capacity, shared underlying array ===")
// Create a slice with spare capacity
s3 := make([]int, 2, 5) // len=2, cap=5
s3[0] = 10
s3[1] = 20
// Create s4 based on s3, pointing at s3's second element
s4 := s3[1:2] // len=1, cap=4, points at s3[1]
fmt.Printf("s3: %v\n", s3)
fmt.Printf("s4: %v\n", s4)
// Append to s4. Because s4 has enough cap (4 > 1), no growth happens,
// and it writes directly into s3's underlying array at s3[2].
s4 = append(s4, 99)
fmt.Printf("s3: %v\n", s3) // s3 is modified!
fmt.Printf("s4: %v\n", s4)
fmt.Println("Note: s3[2] was modified by s4's append, because they share the underlying array.")
}
Expected output:
=== Scenario 1: growth detaches the underlying array ===
s1: [1 2 3], cap: 3
s1: [1 2 3], cap: 3
s2: [1 2 3 4], cap: 6
s1 and s2 now have separate underlying arrays and do not affect each other.
=== Scenario 2: enough capacity, shared underlying array ===
s3: [10 20 0]
s4: [20]
s3: [10 20 99]
s4: [20 99]
Note: s3[2] was modified by s4's append, because they share the underlying array.
Example 2: Map Iteration and Deletion
This example shows map insert/update/lookup/delete, and the semantics of safely deleting elements during iteration.
package main
import "fmt"
func main() {
// 1. Create and initialize
m := map[string]int{
"a": 1,
"b": 2,
"c": 3,
}
// 2. Insert, update, lookup
m["d"] = 4 // insert
m["a"] = 10 // update
val, ok := m["b"] // lookup; ok is true
fmt.Printf("m['b']: %v, ok: %v\n", val, ok)
// 3. Check whether a key exists
if _, exists := m["e"]; !exists {
fmt.Println("Key 'e' does not exist")
}
// 4. Iteration and deletion
fmt.Println("Before delete:")
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}
// Deleting during iteration is legal.
// Spec: entries not yet reached that are deleted are no longer produced.
fmt.Println("\nDeleting 'a' during iteration:")
for k, v := range m {
if k == "a" {
delete(m, k)
fmt.Printf("Deleted %s\n", k)
}
}
fmt.Println("\nAfter delete:")
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}
}
Expected output:
m['b']: 2, ok: true
Key 'e' does not exist
Before delete:
a: 10
b: 2
c: 3
d: 4
Deleting 'a' during iteration:
Deleted a
After delete:
b: 2
c: 3
d: 4
(Note: map iteration order is random; the output above is just one possibility. It only needs to contain b, c, d and no a.)
Common Mistakes & Pitfalls
-
Wrong assumption: a slice is a reference type and passing it does not copy
- Why: the slice header (pointer, length, capacity) is a value type, so passing a slice copies the pointer, len and cap (24 bytes total). Although the header is copied, the pointer points at the same underlying array, so modifying elements affects the original slice.
- Correct approach: understand that a slice is "a value type that shares its underlying array". If you need an independent copy, use
copyorappend([]T(nil), s...).
-
Wrong assumption: deleting while iterating a map panics
- Why: the Go spec explicitly allows deleting unreached keys during
for range. What actually causes a fatal error is concurrent access (goroutine A reads while goroutine B writes). - Correct approach: deleting during iteration is legal. If you want to avoid deleting unreached entries, collect the keys to delete first and bulk-delete after the loop.
- Why: the Go spec explicitly allows deleting unreached keys during
-
Wrong assumption:
append(s, x)[:len(s)]preserves the original slice- Why: if the original slice has spare capacity,
appenddoes not allocate a new array but modifies the underlying array directly. Thensandtshare the underlying array, and modifyingtaffectss. - Correct approach: if you need a fully independent slice, use a three-index slice
append(s[:len(s):len(s)], x)orcopy.
- Why: if the original slice has spare capacity,
Best Practices
- Preallocate capacity: when creating a slice or map with
make, provide a capacity argument whenever possible (e.g.make([]int, 0, 100)) to reduce the number of growths triggered byappend. - Avoid the shared-underlying-array trap: when passing a slice to a function that might modify it, and you do not want the original data affected, copy the slice first (
copyor a newappendslice). - Map concurrency safety: when reading and writing a map concurrently, you must use
sync.Mutexorsync.Map. Do not try to recover from a concurrent-map-write panic; it is a fatal error. - Cap capacity when reslicing: use
s[low:high:high]ors[low:high:max]to bound the new slice's cap, preventing an oversized underlying array from causing a memory leak.
Exercises
-
Concept question: Explain why
var a [5]intandvar b [10]intare different types, while[]intand[]intare the same type. -
Coding exercise: Write a function
RemoveDuplicatesthat takes a[]intslice and returns a deduplicated[]intslice. Requirements:- Each element appears exactly once in the result.
- Preserve the order of each element's first appearance in the original slice.
- Do not use any external libraries.
-
Advanced/debugging exercise: The code below is meant to find duplicate elements in a slice, but has a logic error. Find the problem and fix it, so that each duplicated element appears exactly once in the result, in the order of its first repeat.
```go package main
import "fmt"
func FindDuplicates(nums []int) []int { seen := make(map[int]bool) var duplicates []int for _, num := range nums { if seen[num] { duplicates = append(duplicates, num) } else { seen[num] = true } } return duplicates }
func main() { nums := []int{1, 2, 3, 2, 4, 1, 5} fmt.Println(FindDuplicates(nums)) }
`` *(Note: the expected output is[2 1]`, because 2 and 1 are duplicated, and 2 is encountered a second time before 1 is.)*
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
The array length is part of the type signature; the compiler fixes the array size at compile time, so [5]int and [10]int have different memory layouts and are treated as different types. A slice, on the other hand, is a dynamic view whose type only cares about the element type int, not the length, so []int and []int are the same type.
Question 2
package main
import "fmt"
func RemoveDuplicates(nums []int) []int {
seen := make(map[int]bool)
var result []int
for _, num := range nums {
if !seen[num] {
seen[num] = true
result = append(result, num)
}
}
return result
}
func main() {
nums := []int{1, 2, 3, 2, 4, 1, 5}
fmt.Println(RemoveDuplicates(nums)) // prints: [1 2 3 4 5]
}
Question 3
Problem analysis: the original code appends a duplicated element directly to the duplicates slice every time it sees one. If the same element appears multiple times (e.g. [1, 1, 1]), it gets appended to the result multiple times. The task requires "each duplicated element appears exactly once".
Fixed code:
package main
import "fmt"
func FindDuplicates(nums []int) []int {
seen := make(map[int]bool)
duplicates := make(map[int]bool)
var result []int
for _, num := range nums {
if seen[num] {
// If already marked as duplicate, skip it
if !duplicates[num] {
duplicates[num] = true
result = append(result, num)
}
} else {
seen[num] = true
}
}
return result
}
func main() {
nums := []int{1, 2, 3, 2, 4, 1, 5}
fmt.Println(FindDuplicates(nums)) // prints: [2 1]
}
Lesson Summary
- Arrays are fixed-length value types; slices are value types that share an underlying array (the header holds a pointer, len and cap).
appendtriggers growth when capacity runs out; the Go 1.18+ strategy is roughly 2x foroldcap < 256and ~1.25x rounded up for>= 256.- Passing a slice copies the header but shares the underlying array, so watch out for
appendmodifying the original data. - Map iteration is unordered; deleting unreached keys during
for rangeis legal, but concurrent map access causes a fatal error. - In practice, preallocate slice and map capacity to avoid frequent growth.
Next lesson preview: strings and Unicode — rune, byte and the strings package.