Deep Dive into Go Slices: Creation, Internals, and Append Mechanics
The article explains Go slices as three‑field descriptors pointing to an underlying array, details creation methods, shows the low‑level runtime representation and assembly for make, describes how append triggers growslice with capacity‑doubling or ~25 % growth and alignment, and clarifies sharing, nil slices, and passing semantics.
Go's slice type is a powerful but sometimes confusing data structure. This article examines slices from a low‑level perspective, showing how they are represented in the runtime, how they are created, and what really happens when append is used.
What a slice looks like in the source code
type slice struct {
array unsafe.Pointer // pointer to the underlying array
len int // number of elements visible to the slice
cap int // capacity of the underlying array
}The three fields explain the three most important properties of a slice: a pointer to the backing array, its current length, and its total capacity.
Ways to create a slice
Direct declaration: var s []int (produces a nil slice)
Using new : s := *new([]int)
Literal: s := []int{1,2,3}
make : s := make([]int, 5, 10)
Reslicing (cutting): s2 := s[2:5:7
Both a nil slice and an empty slice have length and capacity zero, but they differ in their internal pointer. The empty slice points to a shared zero‑length array (e.g., 0xc42003bda0 ), while a nil slice has a nil pointer. The Go standard library recommends using nil slices when possible.
Assembly view of make
// runtime/slice.go (simplified)
type slice struct {
array unsafe.Pointer
len int
cap int
}When make([]int, 5, 10) is compiled, the generated assembly (excerpt) looks like:
0x0000 MOVQ (TLS), CX
0x0009 CMPQ SP, 16(CX)
0x000d JLS 228
0x0013 SUBQ $96, SP
0x0017 MOVQ BP, 88(SP)
0x001c LEAQ 88(SP), BP
... (calls runtime.makeslice, runtime.convT2Eslice, fmt.Println)
0x00d8 ADDQ $96, SP
0x00dd RETThe call to runtime.makeslice allocates the backing array, and runtime.convT2Eslice converts the slice to an empty interface so that fmt.Println can print it.
How append works
The signature of append is:
func append(slice []Type, elems ...Type) []TypeIf the slice has enough capacity, the new elements are written directly into the existing backing array. If not, the runtime calls growslice to allocate a larger array, copy the old elements, and then append the new ones.
The growth algorithm (Go 1.9.5) is roughly:
newcap := old.cap
doublecap := newcap + newcap
if requiredCap > doublecap {
newcap = requiredCap
} else {
if old.len < 1024 {
newcap = doublecap // double the capacity
} else {
for newcap < requiredCap {
newcap += newcap / 4 // grow by ~25 %
}
}
}
// finally round up to the nearest size class for memory alignment
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)This explains why the capacity growth is not a simple “×2 or ×1.25” rule: after the logical growth the runtime aligns the size to a memory class, which may increase the capacity a bit more.
Reslicing and sharing the backing array
Creating a new slice from an existing one (e.g., s2 := s[2:5] ) shares the same underlying array. Modifying the shared array through either slice is visible to the other. However, once append triggers a reallocation, the new slice gets its own backing array and no longer affects the original.
Passing slices to functions
Slices are passed by value (the three‑field struct is copied). The copy still points to the same backing array, so element modifications are reflected in the caller. The slice header itself (length, capacity, pointer) cannot be changed in the caller unless you either return the modified slice or pass a pointer to the slice:
func myAppend(s []int) []int { return append(s, 100) }
func myAppendPtr(p *[]int) { *p = append(*p, 100) }Both patterns are demonstrated in the article.
Summary of key points
A slice is a descriptor of a segment of an underlying array (pointer, length, capacity).
Multiple slices can share the same array; changes to the shared array affect all slices.
append grows the slice by calling growslice , which first doubles the capacity for small slices and then grows by ~25 % for larger ones, followed by memory‑size alignment.
Nil slices can be appended to; the runtime allocates a new backing array on the first append .
When a slice is passed as a function argument, the elements can be mutated, but the slice header itself is immutable unless a pointer is used or the function returns the new slice.
Overall, the article provides a thorough, low‑level view of Go slices, useful for developers who need to understand performance characteristics and memory behavior.
Didi Tech
Official Didi technology account
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.