漫行记Wandering
Journal 3,582 字 12 分钟 Golang

Golang Slice: A Deep Dive

What a slice Is

A slice is a descriptor for a segment of an underlying array. Unlike arrays(which have a fixed length), slices are * *dynamic**: they can grow, shrink, and share data with other slices. You can think of a slice as a ‘window’ onto an array:

  • It points to some contiguous elements of an array
  • It has a length
  • It has a capacity(how many elements it could potentially expand to, without reallocating)

Internal representation

A slice is not an array itself, it’s a small struct defined in Go’s runtime, you can refer to slice

type slice struct {
	array unsafe.Pointer // pointer to the underlying array
	len int
	cap int
}

So every slice value is just 3 words in memory(pointer, length, capacity)

  • Pointer: points to the first element accessible by the slice(not always the start of the array)
  • Length: number of elements currently usable
  • Capacity: maximum number of elements between the start of slice and the end of the underlying array

Growth Strategy

When capacity is exceeded:

  • For small slices, capacity roughly doubles
  • For large slices(>1024), growth is about 25% each time. it’s handled by runtime.growslice. you can refer to growslice

Zero values and Nil Slices

  • A slice’s zero value is nil
  • var s []int gives you a slice with array = nil, len = 0, cap = 0. And in this case, s == nil is true
  • Nil Slices behave like empty slices in most cases(e.g., len(s) == 0, can append to it)

Common Gotchas

  • Shared backing arrays: Two slices may point to the same array, so modifying one can affect the other
  • Capacity pitfalls: Appending can silently reallocate, so two slices that used to share data might stop sharing after an append.
  • Copy vs Reference: Use copy(dst,src) to explicitly duplicate slice contents, not just the descriptor

Passing Slices into Functions

First of all, in Go, everything is passed by value. That means when you pass a slice to a function, Go copies the * *slice header struct**(array pointer, len, cap) and it doesn’t copy the underlying array.

Only copy the slice header struct

From below example, you could see the address is different, it means that they are different slice header struct.

func main() {
	s := []int{1, 2, 3}
	fmt.Printf("The address in main: %p \n", &s)
	testSlice(s)
}
func testSlice(s []int) {
	fmt.Printf("The address in functions: %p", &s)
}
// output
The address in main: 0xc00000e018
The address in functions: 0xc00000e030

But please note, if you write the code like below, you will see the address is the same, it’s because, for a slice, p% dose not print the address of the slice header struct. Instead, it prints the pointer inside the slice header- the array filed which is the address of the first element of backing array. both the original slice and the function parameter slice header point to the same backing array.

func main() {
	s := []int{1, 2, 3}
	fmt.Printf("The address in main: %p \n", s)
	testSlice(s)
}
func testSlice(s []int) {
	fmt.Printf("The address in functions: %p", s)
}

Element Changes Are Visible

If the function modifies elements s[0] = 99, the caller can see it.

func main() {
	s := []int{1, 2, 3}
	fmt.Println("Before function: ", s)
	testSlice(s)
	fmt.Println("After function: ", s)
}

func testSlice(s []int) {
	s[0] = 99
}
// output
Before function:  [1 2 3]
After function:  [99 2 3]

Length and Capacity Changes Are Local

If the function changes length/capacity (like s = append(s, ...)), the caller won’t see the change unless you return the slice, because you’re only updating the local copy of the header.

func change(s []int) {
	s = append(s, 100)
	s[0] = 50
}

func main() {
	a := []int{1, 2, 3}
	fmt.Println("Before function: ", a)
	change(a)
	fmt.Println("After function: ", a)
}
//output
Before function:  [1 2 3]
After function:  [1 2 3]
  • a := []int{1, 2, 3} → length = 3, capacity = 3.
  • In change(s), we call append(s, 100).
    • Since capacity is full, Go calls growslice to allocate a new backing array.
    • s (inside the function) is updated to point to this new array, but the caller’s slice a still points to the old one.
  • Then s[0] = 50 changes the first element of the new backing array, not a’s array.
  • When change returns, the new slice is lost, and a is untouched.

Reslicing s[low:high]

when you write t := s[1:3]

  • Reslicing doesn’t copy underlying array.
  • It just makes a new slice header with a shifted pointer, new length, and new capacity.
  • Capacity always counts to the end of the original backing array, from the new starting index. Here is a simplified version
func slicer(p unsafe.Pointer, oldLen, oldCap, low, high int) slice {
    if low < 0 || high < low || high > oldCap {
        panicSliceBounds()
    }
    newLen := high - low
    newCap := oldCap - low
    return slice{
        array: unsafe.Add(p, uintptr(low)*elemSize), // move pointer forward
        len:   newLen,
        cap:   newCap,
    }
}

So reslicing is cheap(just a new header)

← 随笔