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 []intgives you a slice witharray = nil, len = 0, cap = 0. And in this case,s == nilis 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 callappend(s, 100).- Since capacity is full, Go calls
growsliceto allocate a new backing array. s(inside the function) is updated to point to this new array, but the caller’s sliceastill points to the old one.
- Since capacity is full, Go calls
- Then
s[0] = 50changes the first element of the new backing array, nota’s array. - When
changereturns, the new slice is lost, andais 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)