Slices in Go: len, cap und Fallstricke beim append

1. Übersicht

Slices sind die am häufigsten verwendeten Datentypen in Go, verursachen aber auch die schwer zu findenden Bugs. Der Grund ist, dass viele Slices ein gemeinsames zugrunde liegendes Array teilen können. In diesem Fall kann append Daten eines anderen Slices überschreiben, ohne einen Fehler zu melden.

Der Artikel erklärt len und cap, wie append entscheidet, ob ein neues Array allokiert oder in das alte geschrieben wird, und Techniken zur Fehlervermeidung: Full Slice Expression, copy, slices.Clone. Nach dem Lesen kannst du das richtige Ergebnis von Code-Abschnitten mit append vorhersagen und weißt, wie man Tests schreibt, um Aliasing-Fehler zu finden.

Die Beispiele verwenden Go 1.21 oder später, da das Paket slices ab dieser Version verfügbar ist.

2. Was ist ein Slice wirklich

Ein Slice ist ein kleines Struct mit drei Feldern:

  • pointer: zeigt auf das erste Element im zugrunde liegenden Array (backing array).
  • len: die Anzahl der Elemente, auf die du zugreifen kannst.
  • cap: die Anzahl der Elemente vom Pointer bis zum Ende des zugrunde liegenden Arrays.

Wenn du einen Slice in eine Funktion übergibst oder ihn einer anderen Variable zuweist, kopiert Go nur dieses Struct mit drei Feldern. Das zugrunde liegende Array wird nicht kopiert.

2.1. Beobachte len und cap

package main

import "fmt"

func main() {
	orders := []string{"A01", "A02", "A03", "A04", "A05"}
	recent := orders[1:3]

	fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4

orders[1:3] nimmt Elemente vom Index 1 bis vor Index 3, also len gleich 2. cap ist 4, weil vom Index orders[1] bis zum Ende des Arrays noch 4 Plätze frei sind: A02, A03, A04, A05.

2.2. Zwei Slices, ein Array

Da recent auf das gleiche Array wie orders zeigt, wird die Änderung eines Elements in diesem Slice sofort im anderen Slice sichtbar:

recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]

Dies ist beabsichtiges Verhalten. Es ermöglicht Slice-Operationen ohne Speicheraufwand. Aber es ist auch die Quelle aller Fallstricke in den folgenden Abschnitten.

3. Wie append funktioniert

append(s, x) macht eines von zwei Dingen:

  1. Wenn len(s) < cap(s): schreibt x in das Feld s[len(s)] im aktuellen Array, und gibt einen neuen Slice mit len + 1 zurück.
  2. Wenn len(s) == cap(s): allokiert ein größeres Array, kopiert alte Daten hinüber, schreibt x, und gibt einen Slice zurück, der auf das neue Array zeigt.

Weil Fall 2 auftreten kann, musst du immer das Ergebnis neu zuweisen: s = append(s, x).

3.1. Beobachte, wie cap graduell wächst

package main

import "fmt"

func main() {
	var ids []int
	for i := 0; i < 10; i++ {
		ids = append(ids, i)
		fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
	}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16

Bei kleinen Slices verdoppelt sich cap normalerweise jedes Mal, wenn es vollläuft. Bei großen Slices wächst es langsamer, und das Ergebnis wird nach der Size Class des Speicherzuteilers gerundet.

Hinweis: Die Formel für das Wachstum von cap ist ein Implementierungsdetail der Runtime und hat sich über Go-Versionen hinweg geändert. Schreibe keinen Code, der von einem bestimmten cap-Wert nach append abhängt.

4. Fallstrick 1: append überschreibt Daten des ursprünglichen Slices

Dies ist der häufigste Fehler:

package main

import "fmt"

func main() {
	prices := []int{10, 20, 30, 40, 50}
	firstTwo := prices[:2]

	firstTwo = append(firstTwo, 99)

	fmt.Println("firstTwo:", firstTwo)
	fmt.Println("prices:  ", prices)
}
firstTwo: [10 20 99]
prices:   [10 20 99 40 50]

firstTwo hat len = 2, aber cap = 5. Das Array hat noch Platz, also allokiert append kein neues Array, sondern schreibt 99 in das dritte Feld des gemeinsamen Arrays. Das Ergebnis ist, dass prices[2] von 30 zu 99 wird.

Dieser Bug ist sehr schwer zu finden. Der Code wirft keine Panik aus, und der überschriebene Wert kann erst viel später in einem anderen Modul sichtbar werden.

5. Fallstrick 2: zwei append vom gleichen Slice

package main

import "fmt"

func main() {
	base := make([]string, 3, 4)
	base[0], base[1], base[2] = "pen", "book", "bag"

	cartA := append(base, "laptop")
	cartB := append(base, "phone")

	fmt.Println("cartA:", cartA)
	fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]

base hat genau einen freien Platz. Das erste append schreibt "laptop" dorthin. Das zweite schreibt "phone" in den gleichen Platz. Sowohl cartA als auch cartB zeigen auf das gleiche Array, also ist "laptop" verschwunden.

Wenn base cap = 3 hätte, würden beide append separate Arrays allokieren, und das Ergebnis wäre wie erwartet. Der gleiche Code, aber unterschiedliches Verhalten je nach cap. Das ist, warum dieser Bug nur gelegentlich auftaucht.

6. Fallstrick 3: append in einer Funktion ändert nicht den Slice des Callers

package main

import "fmt"

func addItem(items []string, item string) {
	items = append(items, item)
}

func main() {
	cart := make([]string, 0, 10)
	addItem(cart, "pen")

	fmt.Println(len(cart), cart)
	fmt.Println(cart[:1])
}
0 []
[pen]

Die Funktion addItem erhält eine Kopie des Slice-Structs. append hat "pen" in das gemeinsame Array geschrieben, weil noch Platz vorhanden war. Aber das neue len wurde nur der lokalen Variable items zugewiesen. Die Variable cart in main hat immer noch len = 0. Die Daten sind im Array vorhanden, aber du siehst sie nur, wenn du absichtlich cart[:1] schneidest.

Die Lösung ist, einen neuen Slice zurückzugeben:

func addItem(items []string, item string) []string {
	return append(items, item)
}

// Verwendung
cart = addItem(cart, "pen")

Das ist auch der Grund, warum die Standard-Bibliothek append als Funktion mit Rückgabewert designt hat, nicht als In-place-Modifikation.

7. Fallstrick 4: make mit len und make mit cap verwechseln

package main

import "fmt"

func main() {
	names := []string{"An", "Binh", "Chi"}

	upper := make([]string, len(names)) // falsche Absicht
	for _, n := range names {
		upper = append(upper, n+"!")
	}

	fmt.Println(len(upper), upper)
}
6 [   An! Binh! Chi!]

make([]string, 3) erzeugt einen Slice mit 3 leeren Elementen, nicht einen leeren Slice mit Kapazität 3. append fügt nach den 3 leeren Strings hinzu.

Du hast zwei richtige Wege, das zu beheben. Wähle einen aus, vermische sie nicht:

// Weg 1: len = 0, cap = n, dann append
upper := make([]string, 0, len(names))
for _, n := range names {
	upper = append(upper, n+"!")
}

// Weg 2: len = n, über Index zuweisen
upper := make([]string, len(names))
for i, n := range names {
	upper[i] = n + "!"
}

Beide allokieren genau einmal, schneller als append mehrmals zu lassen, um cap zu vergrößern.

8. Abwehrmaßnahmen

8.1. Full Slice Expression: cap begrenzen

Die Syntax s[low:high:max] setzt cap = max - low. Wenn cap gleich len ist, muss das nächste append ein neues Array allokieren:

prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2

firstTwo = append(firstTwo, 99)

fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices:  ", prices)
firstTwo: [10 20 99]
prices:   [10 20 30 40 50]

prices wird nicht beeinflusst. Diese Technik ist nützlich, wenn du einen Teil eines internen Slices nach außen zurückgibst und möchtest, dass der Caller sicher append kann, ohne deine Daten zu beschädigen.

8.2. slices.Clone und copy: Array vollständig trennen

Wenn du eine völlig unabhängige Kopie brauchst, klone:

import "slices"

orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)

snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X

Mit älterem Go vor 1.21 verwende copy:

snapshot := make([]string, len(orders))
copy(snapshot, orders)

copy gibt die Anzahl der kopierten Elemente zurück, gleich dem kleineren Wert zwischen len(dst) und len(src). Deshalb muss dst ein ausreichend großes len haben, nicht nur cap. Ein häufiger Fehler ist copy(make([]string, 0, n), src) zu schreiben und dann einen leeren Slice zurück zu bekommen.

8.3. Fallstrick: Speicher festhalten

Das Schneiden eines kleinen Slices aus einem großen Array hält das gesamte große Array im Speicher:

func firstLine(data []byte) []byte {
	for i, b := range data {
		if b == '\n' {
			return slices.Clone(data[:i]) // hält nicht ganz data
		}
	}
	return slices.Clone(data)
}

Wenn data eine 100-MB-Datei ist und du data[:i] ohne Klonen zurückgibst, kann der Garbage Collector diese 100 MB nicht freigeben, solange der resultierende Slice noch referenziert wird.

9. Tests schreiben, um Aliasing-Fehler zu fangen

Angenommen, du hast einen Typ Inventory, der eine Produktliste zurückgibt. Du möchtest sichergehen, dass wenn der Caller append aufruft, das interne Verhalten nicht beschädigt wird:

// inventory.go
package inventory

type Inventory struct {
	items []string
}

func New(items ...string) *Inventory {
	return &Inventory{items: items}
}

func (inv *Inventory) Items() []string {
	return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory

import (
	"slices"
	"testing"
)

func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
	inv := New("pen", "book")
	inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // extra cap absichtlich

	got := inv.Items()
	got = append(got, "laptop")

	want := []string{"pen", "book"}
	if !slices.Equal(inv.Items(), want) {
		t.Fatalf("internal items = %v, want %v", inv.Items(), want)
	}
	if inv.items[:3][2] != "bag" {
		t.Fatalf("backing array überschrieben: %v", inv.items[:3])
	}
	if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
		t.Fatalf("got = %v", got)
	}
}

Der Test erzeugt absichtlich einen internen Slice mit cap größer als len, weil das die Bedingung ist, unter der der Bug auftritt. Wenn du Items() in return inv.items änderst, wird append(got, "laptop") das "bag" überschreiben und der Test schlägt bei der zweiten Assertion fehl.

go test ./...
ok  	example.com/inventory	0.002s

Hinweis: Aliasing-Tests müssen immer die Situation len < cap aufbauen. Wenn der Slice im Test mit einem Literal wie []string{"pen", "book"} erzeugt wird, dann len == cap, und append allokiert immer ein neues Array, sodass der Test erfolgreich ist, obwohl der Code einen Bug hat.

10. Wann du dir KEINE Sorgen machst

Nicht jeder Slice braucht geklont zu werden. Das Kopieren verbraucht Speicher und CPU. Du kannst das Array sicher teilen, wenn:

  • Der Slice wird nur gelesen, niemand führt append aus oder ändert Elemente.
  • Der Slice ist eine lokale Variable, die nicht außerhalb der Funktion geteilt wird.
  • Du möchtest absichtlich an Ort und Stelle ändern, z.B. Elemente mit s[:0] filtern, um das Array wiederzuverwenden:
active := orders[:0]
for _, o := range orders {
	if o != "CANCELLED" {
		active = append(active, o)
	}
}

Diese Technik ist sicher, weil active niemals über die Position hinausschreibt, bei der wir orders lesen. Nach der Schleife solltest du nur active verwenden und orders vergessen.

11. Fazit

Ein Slice ist ein Struct aus Pointer, len und cap, das auf ein Array zeigt, das geteilt werden kann. append allokiert ein neues Array nur, wenn len == cap. In allen anderen Fällen schreibt es direkt in das gemeinsame Array. Das ist der Ursprung der vier Fallstricke, denen wir begegnet sind: den ursprünglichen Slice überschreiben, zwei append überschreiben sich gegenseitig, append in einer Funktion ändert nicht den Caller, und len mit cap in make verwechseln.

Um sicher zu sein, merke dir diese wenigen Regeln:

  • Schreibe immer s = append(s, ...).
  • Funktionen, die Elemente hinzufügen, geben einen neuen Slice zurück.
  • Verwende s[low:high:high], wenn du einen internen Slice nach außen zurückgibst.
  • Verwende slices.Clone, wenn du eine unabhängige Kopie brauchst oder ein großes Array freigeben möchtest.

Wenn du Tests schreibst, denke daran, die Bedingung len < cap aufzubauen, damit Bugs eine Chance haben, sich zu offenbaren.

golangsliceappendmemory