Pointer
Pointer in Go erschließen direkten Zugriff auf Speicheradressen und machen Beziehungen zwischen Daten explizit. Sie ermöglichen effiziente Übergaben, vermeiden Kopierkosten und bilden die Basis für veränderliche Strukturen, ohne das Sicherheitsmodell der Sprache zu unterlaufen. Indem sie Nullwerte strikt behandeln und durch klare Dereferenzierung operieren, bleibt der Code nachvollziehbar. Im Zusammenspiel mit Funktionen, Structs und Interfaces zeigen Pointer, wie Go Kontrolle über Mutationen mit einfacher Syntax kombiniert und so Performance mit Transparenz vereint.
Inhaltsverzeichnis
Einführung
Warum Pointer?
Pointer stellen ein wichtiges Konzept in der Programmierung dar. Dennoch werden sie oft missverstanden oder als “kompliziert” abgetan. Um Pointer wirklich zu verstehen, muss man zunächst verstehen, warum sie überhaupt existieren und welches grundlegende Problem sie lösen.
In der Programmierung arbeiten wir mit Daten - Zahlen, Texte, Strukturen, Listen und so weiter. Diese Daten müssen irgendwo im Speicher des Computers abgelegt werden. Der Speicher eines Computers ist im Grunde genommen ein riesiges Array von Bytes, wobei jedes Byte eine eindeutige Adresse hat.
Wenn wir in Go eine Variable erstellen, passiert Folgendes:
- Der Compiler reserviert einen bestimmten Bereich im Speicher (abhängig vom Typ der Variable)
- Dieser Speicherbereich bekommt eine eindeutige Adresse
- Der Wert der Variable wird in diesem Speicherbereich abgelegt
- Der Variablenname ist nur ein menschenlesbarer Alias für diese Speicheradresse
Normalerweise arbeiten wir mit den Werten von Variablen. Wenn wir x := 42 schreiben, interessiert uns der Wert 42, nicht die Speicheradresse, wo diese 42 liegt. Aber manchmal müssen wir mit den Adressen selbst arbeiten, und genau hier kommen Pointer ins Spiel.
Das Problem - Kopieren von Daten
Go verwendet ein Konzept namens “Pass by value” (Wertübergabe). Das bedeutet: Wenn wir eine Variable an eine Funktion übergeben oder einer anderen Variable zuweisen, wird immer eine Kopie erstellt. Bei kleinen Werten wie einer einzelnen Zahl ist das kein Problem - das Kopieren von 8 Bytes (ein int64) ist extrem schnell und benötigt kaum Speicher.
Aber stellen wir uns vor, wir haben eine Datenstruktur mit Millionen von Datenpunkten - vielleicht ein hochauflösendes Bild, einen großen Datensatz aus einer Datenbank oder ein komplexes 3D-Modell. Diese Datenstruktur könnte mehrere Megabytes oder sogar Gigabytes groß sein.
Was passiert, wenn wir diese Datenstruktur an eine Funktion übergeben?
Ohne Pointer würde Go eine komplette Kopie der gesamten Datenstruktur erstellen. Das hätte mehrere gravierende Probleme:
- Speicherverschwendung: Sie hätten plötzlich zwei identische Kopien derselben Daten im Speicher. Bei großen Datenstrukturen könnte das den verfügbaren Speicher schnell erschöpfen.
- Performance-Probleme: Das Kopieren von Megabytes oder Gigabytes an Daten dauert Zeit. Bei jedem Funktionsaufruf würde das Programm spürbar langsamer.
- Änderungen gehen verloren: Wenn die Funktion die Daten ändern soll (z.B. ein Bild filtern, Daten sortieren), würde sie nur die Kopie ändern. Das Original bliebe unverändert, was meist nicht gewünscht ist.
Die Lösung - Pointer, Adressen statt Werte
Hier kommen Pointer ins Spiel. Anstatt die gesamten Daten zu kopieren, können wir der Funktion einfach die Adresse mitteilen, wo sich die Daten im Speicher befinden. Das ist vergleich damit, jemandem zu sagen “Die Dokumente liegen im Raum 304, Regal 5” anstatt alle Dokumente zu fotokopieren und zu übergeben.
Ein Pointer ist also nichts anderes als eine Variable, die eine Speicheradresse enthält. Auf einem 64-Bit-System ist ein Pointer immer genau 8 Bytes groß - egal ob er auf einen einzelnen Integer oder auf eine Gigabyte-große Datenstruktur zeigt. Diese konstante, kleine Größe macht Pointer extrem effizient.
Die Vorteile von Pointern sind:
- Effizienz: Nur 8 Bytes werden kopiert (die Adresse), nicht die gesamten Daten
- Direkte Manipulation: Änderungen erfolgen am Original, nicht an einer Kopie
- Gemeinsame Daten: Mehrere Teile des Programms können auf dieselben Daten zugreifen
- Optionale Werte: Pointer können
nilsein, was “kein Wert” bedeutet (nützlich für optionale Felder)
Beispiel des Problems
Betrachten wir ein konkretes Problem. Angenommen, wir haben eine Struktur, die ein Bild repräsentiert - sagen wir 4000 x 3000 Pixel, wobei jedes Pixel 3 Bytes (RGB) benötigt. Das sind 4000 x 3000 x 3 = 36.000.000 Bytes = ~36 MB.
package main
import "fmt"
type Image struct {
Width int
Height int
Pixels [4000][3000][3]byte
}
// Diese Funktion erhält eine KOPIE des Bildes
func applyFilter(img Image) {
img.Pixels[0][0][0] = 255 // Änderung nur in der Kopie
fmt.Println("Filter angewendet")
}
func main() {
myImg := Image{
Width: 4000,
Height: 3000,
}
myImg.Pixels[0][0][0] = 100
fmt.Println("Vorher:", myImg.Pixels[0][0][0])
applyFilter(myImg)
fmt.Println("Nachher:", myImg.Pixels[0][0][0])
}Vorher: 100
Filter angewendet
Nachher: 100Was passiert hier?
Wenn applyFilter(myImg) aufgerufen wird, erstellt Go eine komplette Kopie der gesamten Image-Struktur (36MB). Die Funktion arbeitet dann mit dieser Kopie. Änderungen betreffen nur die Kopie, nicht das Original. Wenn die Funktion endet, wird die Kopie verworfen - 36 MB wurden umsonst kopiert und die Änderung ist verloren.
Eine Lösung mit Pointer
Schauen wir uns an, wie Pointer dieses Problem lösen.
package main
import "fmt"
type Image struct {
Width int
Height int
Pixels [4000][3000][3]byte
}
// Diese Funktion erhält einen Pointer (nur 8 Bytes)
func applyFilter(img *Image) {
img.Pixels[0][0][0] = 255 // Ändert das Original
fmt.Println("Filter angewendet")
}
func main() {
myImg := Image{
Width: 4000,
Height: 3000,
}
myImg.Pixels[0][0][0] = 100
fmt.Println("Vorher:", myImg.Pixels[0][0][0])
applyFilter(&myImg)
fmt.Println("Nachher:", myImg.Pixels[0][0][0])
}Vorher: 100
Filter angewendet
Nachher: 255Was ist hier anders?
- Die Funktion
applyFilternimmt jetzt einen Pointer auf Image:img *Image - Beim Aufruf übergeben wir die Adresse mit
&myImage - Es werden nur 8 Bytes (die Adresse) kopiert, nicht 36 MB
- Die Funktion arbeitet direkt mit dem Original über die Adresse
- Änderungen sind persistent
Das ist der fundamentale Unterschied und die Hauptmotivation für Pointer: Effizienz und direkte Manipulation.
Was sind Pointer?
Die Natur des Computer-Speichers
Um Pointer noch weiter zu verstehen, müssen wir verstehen, wie Computer-Speicher funktioniert. Der Arbeitsspeicher (RAM) eines Computers ist konzeptionell ein gigantisches, lineares Array von Bytes. Jedes Byte hat eine eindeutige Nummer - seine Adresse. Auf einem modernen Computer mit 8 GB RAM gibt es etwas 8 Milliarden Bytes, nummeriert von 0 bis etwa 8.000.000.000.
Wenn wir eine Variable deklarieren, passiert Folgendes:
- Der Compiler bestimmt, wie viele Bytes die Variable benötigt (basierend auf ihrem Typ)
- Das Betriebssystem reserviert einen entsprechenden, zusammenhängenden Bereich im Speicher
- Dieser Bereich bekommt eine Startadresse
- Der Wert wird in diesen Bereich geschrieben
- Der Variablenname wird intern auf diese Adresse gemappt
Beispiel: Wenn wir var x int64 = 42 schreiben und ein int64 8 Bytes benötigt, könnte der Compiler die Bytes an den Adressen 1000 bis 1007 reservieren und dort die Zahl 42 speichern (in binärer Darstellung). Der Name x ist dann nur ein menschenlesbarer Alias für “die 8 Bytes ab Adresse 1000”.
Was ist ein Pointer?
Ein Pointer (deutsch: Zeiger) ist eine Variable, deren Wert eine Speicheradresse ist. Das ist der zentrale Punkt: Während normale Variablen Daten enthalten (Zahlen, Text, etc.) enthalten Pointer Adressen.
Schauen wir uns ein Schema an.
Normale Variable
+-----------------+-------------+----------+
| Speicheradresse | Inhalt | Variable |
+-----------------+-------------+----------+
| 1000 | 42 | x |
+-----------------+-------------+----------+
Pointer
+-----------------+-------------+----------+
| Speicheradresse | Inhalt | Variable |
+-----------------+-------------+----------+
| 1000 | 42 | x | <- Normale Variable
| 2000 | 1000 | p | <- Pointer (enthält Adresse von x)
+-----------------+-------------+----------+In diesem Beispiel ist folgendes dargestellt:
xist eine normale Variable an Adresse1000, die den Wert42enthältpist ein Pointer an Adresse2000, der den Wert1000enthält (die Adresse vonx)- Hier können wir sage:
pzeigt aufx
Die Hausnummer-Analogie
Eine hilfreiche Analogie zum Verständnis von Pointern ist das Konzept von Hausnummern.
Normale Werte (Values):
- Wir haben ein Haus mit all seinem Inhalt
- Wenn wir jemanden “das Haus geben” wollen, müssten wir eine exakte Kopie bauen
- Das ist teuer, zeitaufwendig und platzverschwendend
Pointer
- Wir haben ein Haus an einer bestimmten Adresse (z.B. “Hauptstraße 42”)
- Wenn wir jemanden “das Haus geben” wollen, geben sie einfach die Adresse weiter
- Das ist billig, schnell und effizient
- Wer die Adresse hat, kann das echte Haus besuchen und ändern
Die “Adresse” in dieser Analogie ist der Pointer. Er sagt nicht was im Haus ist, sondern wo das Haus steht. Mit dieser Information kann man das Haus finden und direkt damit arbeiten.
Pointer-Typen in Go
In Go wird ein Pointer-Typ durch ein Stern-Symbol (*) vor dem Typ deklariert, auf den gezeigt wird.
var p *int // p ist ein Pointer auf einen int
var q *string // q ist ein Pointer auf einen String
var r *Person // r ist ein Pointer auf einen Person-StructDie Syntax *T bedeutet: Ein Pointer auf einen Wert vom Typ T. Es ist wichtig zu verstehen, dass der Pointer selbst immer die gleiche Größe hat (8 Bytes auf 64-Bit-Systemen), unabhängig davon, worauf er zeit. Ein *int ist genauso groß wie ein *[100000]byte - beide sind 8 Bytes, weil beide nur eine Adresse speichern.
Der Unterschied zwischen Typ und Wert
Dies ist ein kritischer Punkt, der oft zu Verwirrung führt:
Der Typ eines Pointers:
*intist ein Typ: “Pointer auf int”*stringist ein Typ: “Pointer auf string”*Personist ein Typ: “Pointer auf Person”
Der Wert eines Pointers:
- Der Wert ist eine Speicheradresse (z.B. 0x00c000012090)
- Diese Adresse zeigt auf eine Variable des entsprechenden Typs
Wenn wir var p *int schreiben, sagen wir: “p ist eine Variable vom Typ ‘Pointer auf int’”. Der Typ von p ist *int. Der Wert von p (noch nicht zugewiesen) ist nil (die Null-Adresse), aber sobald wir ihm eine Adresse zuweisen (z.B. p = &x), wird sein Wert diese konkrete Speicheradresse sein.
Warum Pointer typisiert sind
Wir könnten uns fragen: Wenn ein Pointer nur eine Adresse ist (eine Zahl), warum ist er dann typisiert? Warum kann ein *int nicht auf einen string zeigen?
Der Grund ist Typ-Sicherheit. Der Compiler muss wissen, wie er den Speicher an der Adresse interpretieren soll. Ein int nimmt 8 Bytes ein und wird als Ganzzahl interpretiert. Ein string hat eine komplexere Struktur (Länge + Pointer auf Bytes) und wird anders interpretiert. Wenn wir einen *int haben und damit auf string-Daten zeigen würden, würde der Compiler die Bytes falsch interpretieren - mit katastrophalen Folgen.
Die Typisierung stellt sicher, dass:
- Der Compiler weiß, wie viele Bytes er lesen/schreiben muss
- Die Bytes korrekt interpretiert werden
- Typ-Fehler zur Compile-Zeit erkannt werden, nicht zur Laufzeit
Visualisierung des Speicher-Layouts
Lasst uns konkret visualisieren, wie Pointer im Speicher aussehen.
Speicher-Layout (vereinfacht)
Adresse Inhalt Erklärung
──────────────────────────────────────────────────────────
...
0x1000 0x00 0x00 0x00 0x00 ┐
0x1004 0x00 0x00 0x00 0x2A ┘ int64 x = 42 (8 Bytes)
...
0x2000 0x00 0x00 0x00 0x00 ┐
0x2004 0x00 0x00 0x10 0x00 ┘ *int64 p = &x (8 Bytes, Wert: 0x1000)
...Was ist in diesem Beispiel gegeben?
x(Typ:int64) liegt an Adresse 0x1000 und nimmt 8 Bytes ein- Der Wert von
xist 42 (hexadezimal 0x2A), gespeichert in den 8 Bytes p(Typ:*int64) liegt an Adresse 0x2000 und nimmt ebenfalls 8 Bytes ein- Der Wert von
pist 0x1000 (die Adresse vonx)
Wenn wir nun *p schreiben (Dereferenzierung), sagt das dem Compiler:
- Lies den Wert aus
p(ergibt 0x1000) - Gehe zu Adress 0x1000
- Lies 8 Bytes ab dieser Adresse (weil
pein*int64ist) - Interpretiere diese 8 Bytes als
int64 - Ergebnis: 42
Die Macht der Indirektion
Pointer ermöglichen eine mächtige Eigenschaft namens Indirektion (Indirection). Indirektion bedeutet, dass wir nicht direkt auf Daten zugreifen, sondern über einen Umweg - die Adresse.
Warum ist das mächtig? Betrachten wir folgendes Szenario:
- Wir haben eine große Datenstruktur D an Adresse 1000
- Zehn verschiedene Teiles unseres Programms brauchen Zugriff auf D
- Mit Werten: Jeder Teil hätte seine Kopie von D (10x Speicherverbrauch)
- Mit Pointern: Jeder Teil hat einen Pointer auf Adresse 1000 (10 x 8 Bytes = 80 Bytes)
Alle zehn Teile arbeiten mit denselben Daten. Wenn ein Teil die Daten ändert, sehen alle anderen die Änderungen sofort. Das ist die Macht der Indirektion: Gemeinsamer Zugriff ohne Duplizierung.
Pointer-Operatoren: & und *
Go verwendet zwei zentrale Operatoren für die Arbeit mit Pointern: & (Address-of) und * (Dereferenzierung). Diese beiden Operatoren sind komplementär - sie sind quasi Gegensätze. Das Verständnis dieser beiden Operatoren ist fundamental für die Arbeit mit Pointern.
Der & Operator (Adress-of)
Der & Operator nimmt eine Variable und gibt ihre Speicheradresse zurück. Man kann ihn lesen als “Adresse von” oder “address of”.
Syntax
&variableWas passiert intern
Wenn wir &x schreiben, passiert Folgendes:
- Der Compiler findet die Variable
xin seiner Symboltabelle - Er schaut nach, an welcher Speicheradresse
xliegt - Er gibt diese Adresse zurück (als Pointer vom Typ
T, wobeiTder Typ vonxist)
Das Resultat ist immer ein Pointer. Wenn x vom Typ int ist, ist &x vom Typ *int. Wenn x vom Typ string ist, ist &x vom Typ *string.
Wichtig: Der & Operator kann nur auf “addressable” (adressierbare) Werte angewendet werden. Das sind folgende:
- Variablen
- Struct-Felder
- Array/Slice Elemente
- Pointer-Dereferenzierungen
Dieser funktioniert nicht auf:
- Literale (z.B.
&42) - Funktionsrückgabewerte (außer es sind Variablen)
- Map-Elemente (aus technischen Gründen)
Der * Operator (Dereferenzierung)
Der * Operator hat in Go zwei verschiedene Bedeutungen, abhängig vom Kontext.
1. Als Teil einer Typ-Deklaration: “Pointer auf”
Hier bedeutet es: “p ist ein Pointer auf int”
var p *int2. Als Dereferenzierungs-Operator: “Wert an der Adresse”
Hier bedeutet es: “gib mir den Wert, auf den p zeigt”
value := *pDie Dereferenzierung ist der Vorgang, bei dem wir einem Pointer “folgen”, um den eigentlichen Wert zu bekommen. Man kann es lesen als “Wert von” oder “value at”.
Was passiert intern bei Dereferenzierung:
Wenn wir *p schreiben (und p ist ein Pointer), passiert Folgendes
- Der Compiler liest den Wert von
p(das ist eine Speicheradresse) - Er geht zu dieser Speicheradresse
- Er liest die Bytes ab dieser Adresse (Anzahl von Bytes abhängig vom Typ)
- Er interpretiert diese Bytes entsprechend dem Typ
- Er gibt den resultierenden Wert zurück
Wichtig: Dereferenzierung setzt voraus, dass der Pointer gültig ist (nicht nil). Wenn wir einen nil-Pointer dereferenzieren, führt das zu einer Panic (Runtime-Fehler).
Die Symmetrie zwischen & und *
& und * sind komplementäre Operationen. Sie heben sich gegenseitig auf.
x := 42
p := &x // p hat die Adresse von x
y := *p // y hat den Wert von x über die Adresse p
fmt.Println("x", x)
fmt.Println("p", p)
fmt.Println("y", y)x 42
p 0x1400000e0c8
y 42Schritt-für-Schritt Beispiel
Lasst uns jeden Schritt eines Pointer-Vorgangs detailliert durchgehen.
package main
import "fmt"
func main() {
x := 42 // (1)
fmt.Println("x =", x) // x = 42
p := &X // (2)
fmt.Println("p =", p) // p = 0x1400000e0b0
fmt.Println("*p =", *p) // (3) *p = 42
*p = 100 // (4)
fmt.Println("x =", x) // x = 100
}Was passiert in jedem Schritt
(1) x := 42
- Der Compiler alloziert 8 Bytes (für einen
int) im Speicher, sagen wir an Adresse0x1400000e0b0 - Er schreibt die Zahl 42 in diese 8 Bytes
- Er erstellt einen Eintrag in seiner Symboltabelle “
x=> Adresse 0x1400000e0b0, Typint”
(2) p := &x
- Der Compiler findet
xin seiner Symboltabelle und sieht: “Adresse0x1400000e0b0” - Er erstellt eine neue Variable
pvom Typ*int(Pointer aufint) - Er alloziert 8 Bytes für
p(sagen wir an Adresse0x14000092020) - Er schreibt die Adresse
0x1400000e0b0in diese 8 Bytes (das ist der Wert vonp) - In der Symboltabelle: “
p=> Adresse0x1400000e0b0, Typ*int, Wert0x1400000e0b0”
(3) *p
- Der Compiler liest den Wert von
p:0x1400000e0b0 - Er geht zu Adresse
0x1400000e0b0 - Er liest 8 Bytes (weil
pein*intist undint8 Bytes groß ist) - Er interpretiert diese Bytes als
int - Ergebnis: 42
(4) *p = 100
- Der Compiler liest den Wert von
p:0x1400000e0b0 - Er geht zu Adresse
0x1400000e0b0 - Er schreibt die Zahl 100 in die 8 Bytes ab dieser Adresse
- Resultat:
xhat jetzt den Wert 100 (weilpaufxzeigt)
**Schauen wir uns es nochmals schematisch an.
x := 42
+----------+----------------+------+
| Variable | Adresse | Wert |
+----------+----------------+------+
| x | 0x1400000e0b0 | 42 |
+----------+----------------+------+p := &x
+----------+----------------+-----------------------------+
| Variable | Adresse | Wert |
+----------+----------------+-----------------------------+
| x | 0x1400000e0b0 | 42 |
| p | 0x14000092020 | 0x1400000e0b0 (zeigt auf x) |
+----------+----------------+-----------------------------+*p = 100
+----------+----------------+-----------------------------+
| Variable | Adresse | Wert |
+----------+----------------+-----------------------------+
| x | 0x1400000e0b0 | 100 (geändert!) |
| p | 0x14000092020 | 0x1400000e0b0 (zeigt auf x) |
+----------+----------------+-----------------------------+Pointer umleiten (Reassignment)
Ein wichtiges Konzept: Ein Pointer ist selbst eine Variable, deren Wert geändert werden kann. Wir können einen Pointer “umleiten”, damit er auf etwas anderes zeigt.
package main
import "fmt"
func main() {
x := 10
y := 20
p := &x // p zeigt auf x
fmt.Println(*p)
p = &y
fmt.Println(*p)
}10
20Was passiert hier?
Initial zeigt p auf x.
p → x (10)
y (20)Nach p = &y zeigt p auf y.
x (10)
p → y (20)Wichtig: x existiert weiterhin und behält seinen Wert. Nur p zeigt jetzt woanders hin. Dies ist ein fundamentaler Unterschied zu Referenzen in anderen Sprachen - in Go kann ein Pointer jederzeit umgeleitet werden.
Mehrfache Indirektion
Wir können * mehrfach anwenden, wenn wir mit Pointern zu Pointern arbeiten.
package main
import "fmt"
func main() {
x := 42
p := &x
pp := &p
fmt.Println(**pp)
}42**pp bedeutet:
*pp→ Folge dem ersten Pointer (pp), ergibtp*p→ Folge dem zweiten Pointer (p), ergibtx
Das wird selten gebraucht, aber es ist wichtig zu verstehen, dass es möglich ist.
Praktisches Beispiel - Tausch-Funktion
Ein klassisches Beispiel, das die Notwendigkeit von Pointern zeigt, ist eine Funktion zum Tauschen zweier Werte.
package main
import "fmt"
func swapWrong(a, b int) {
temp := a
a = b
b = temp
}
func swapCorrect(a, b *int) {
temp := *a
*a = *b
*b = temp
}
func main() {
x, y := 10, 20
swapWrong(x, y)
fmt.Println(x, y)
swapCorrect(x, y)
fmt.Println(&x, &y)
}10 20
20 10Warum baucht die Funktion swapCorrect Pointer?
Ohne Pointer erhält die Funktion Kopien von x und y. Sie tauscht die Kopien, aber die Originale bleiben unverändert. Mit Pointern erhält die Funktion die Adressen von x und y. Sie kann dann über diese Adressen direkt die Original-Werte ändern.
Pointer vs Werte - die konzeptionellen Unterschiede
Das Paradigma: Pass by value
Go ist konsequent eine “Pass by value” Sprache. Das bedeutet: Immer, wenn wir eine Variable an eine Funktion übergeben, einer anderen Variable zuweisen oder als Return-Wert zurückgeben, wird eine Kopie erstellt. Das ist ein grundlegendes Designprinzip von Go und unterscheidet sich von Sprachen wie Java (die Referenzen für Objekte verwendet) oder C++ (die sowohl Werte als auch Referenzen unterstützt).
Was bedeutet “Pass by value” konkret?
package main
import "fmt"
func modify(x int) {
x = 100
}
func main() {
a := 42
modify(a)
fmt.Println(a)
}42Wenn wir modify(a) aufrufen, passiert Folgendes:
- Der Wert von
a(42) wird gelesen - Eine neue Variable
xwird erstellt (auf dem Stack der Funktionmodify) - Der Wert 42 wird in
xKOPIERT modify()arbeitet mit dieser Kopie- Wenn
modify()endet, wird die Kopie verworfen
Das Original a ist nie direkt zugänglich für modify(). Jede Änderung betrifft nur die lokale Kopie.
Wann ist “Pass by value” problematisch?
“Pass by value” ist hervorragend für kleine Datentypen. Zahlen (1 bis 8 Bytes), Booleans (1 Byte), kleine Structs (wenige Bytes). Bei diesen Typen ist das Kopieren extrem schnell und der Speicherverbrauch vernachlässigbar. Aber betrachten wir größeren Datenstrukturen.
type LargeData struct {
Matrix [1000][1000]float64 // 8 MB
Buffer [10000000]byte // 10 MB
}
func process(data LargeData) { // Kopiert 18 MB
// Verarbeitung ...
}
func main() {
myData := LargeData{}
process(myData) // 18 MB werden KOMPLETT Kopiert
}Wie Pointer das Problem lösen können
Pointer ermöglichen es, die Adresse statt des Wertes zu übergeben. Da eine Adresse immer gleich groß ist (8 Bytes auf 64-Bit-Systemen), spielt die Größe der eigentlichen Daten keine Rolle mehr.
func process(data *LargeData) { // Übergabe eines Pointers (8 Bytes)
// Zugriff auf das Original über den Pointer
}
func main() {
myData := LargeData()
process(&myData) // Nur die Adresse (8 Bytes) wird kopiert/übergeben
}Was passiert hier im Detail?
&myDatagibt die Adresse vonmyDatazurück (z.B.0x1400000e0c8)- Diese Adresse (8 Bytes) wird KOPIERT (Go bleibt “Pass by value”)
- Die Funktion erhält einen Pointer mit dem Wert
0x1400000e0c8 - Über diesen Pointer kann sie direkt auf
myDatazugreifen - Änderungen betreffen das Original
Value-Semantik vs Pointer-Semantik
Es gibt zwei grundlegend verschiedene Arten, mit Daten zu arbeiten.
Value-Semantik (Wert-Semantik)
- Jede Variable ist unabhängig
- Kopien sind separate Entitäten
- Änderungen betreffen nur die jeweilige Kopie
- Keine überraschenden Seiteneffekte
- Einfacher zu verstehen und zu debuggen
Pointer-Semantik (Zeiger-Semantik)
- Mehrere Pointer können auf dieselben Daten zeigen
- Änderungen über einen Pointer sind überall sichtbar
- Teilen von Daten ohne Kopieren
- Mögliche Seiteneffekte
- Mehr Vorsicht erforderlich
Praktisches Vergleichsbeispiel
package main
import "fmt"
type Point struct {
X, Y int
}
// Value receiver - Empfängt Kopie
func (p Point) MoveValue(dx, dy int) Point {
p.X += dx
p.Y += dy
return p
}
// Pointer receiver - Empfängt Adresse
func (p *Point) MovePointer(dx, dy int) {
p.X += dx
p.Y += dy
}
func main() {
// Value Semantik
p1 := Point{X: 0, Y: 0}
p1 = p1.MoveValue(10, 20)
fmt.Println(p1)
// Pointer Semantik
p2 := Point{X: 0, Y: 0}
p2.MovePointer(10, 20)
fmt.Println(p2)
}{10 20}
{10 20}Beide Ansätze funktionieren, haben aber unterschiedliche Charakteristiken.
- Value: Sicherer (keine Seiteneffekte), etwas mehr Schreibarbeit
- Pointer: Direkter, effizienter bei großen Typen, mögliche Seiteneffekte
Stack vs Heap - Die zwei Speicherbereiche
Die Architektur des Speichers
Um Pointer vollständig zu verstehen, müssen wir verstehen, wo Variablen eigentlich im Speicher landen. Go (wie die meisten Programmiersprachen) unterteilt den Speicher in zwei Hauptbereiche: Stack und Heap. Diese haben grundlegend unterschiedliche Eigenschaften nud Verwendungszwecke.
Der Stack
Der Stack (Stapel) ist ein spezieller Speicherbereich, der nach dem LIFO-Prinzip (Last In, First Out) funktioniert - wie ein Stapel Teller. Wenn eine Funktion aufgerufen wird, wird ein “Stack Frame” (Stapelrahmen) für diese Funktion auf den Stack gelegt. Dieser Frame enthält:
- Lokale Variablen der Funktion
- Funktionsparameter
- Return-Adresse (wo soll das Programm nach der Funktion weitermachen)
- Weitere Verwaltungsdaten
Wenn die Funktion endet, wird ihr kompletter Frame vom Stack entfernt (pop). Das ist extrem schnell - es muss nur der Stack Pointer verringer werden.
Der Heap
Der Heap (Halde) ist ein großer, unstrukturierter Speicherbereich. Im Gegensatz zum Stack gibt es hier keine feste Ordnung. Speicher kann in beliebiger Reihenfolge alloziert und freigegeben werden. Der Heap ist für Daten gedacht, die:
- Länger leben als einzelne Funktionsaufrufe
- Zu groß für den Stack sind
- Zur Laufzeit in variabler Größe erstellt werden
Warum zwei verschiedene Bereiche?
Man könnte fragen: Warum nicht alles auf dem Heap? Der Grund sind Performance und Verwaltung.
Stack-Vorteile
- Extrem schnelle Allokation: Stack-Speicher wird einfach durch Erhöhen eines Pointers (O(1), nur eine CPU-Instruktion)
- Automatische Freigabe: Wenn eine Funktion endet, wird ihr gesamter Stack Frame automatisch freigegeben.
- Bessere Cache-Locality: Stack-Daten liegen sequentiell im Speicher, was CPU-Caches effizient nutzt
- Keine Fragmentierung: Der Stack wächst und schrumpft streng linear
Stack-Nachteile
- Begrenzte Größe: Typischerweise 1-8 MB pro Goroutine (konfigurierbar)
- Funktions-gebundene Lebensdauer: Daten überleben den Funktionsaufruf nicht
- Sequenzielle Nutzung: Nur am “Ende” können Daten hinzugefügt werden
Heap-Vorteile
- Große Größe: Begrenzt nur durch verfügbaren RAM (Gigabytes)
- Flexible Lebensdauer: Daten können beliebig lange leben
- Beliebige Zuteilung: Speicher kann in beliebiger Größe und Reihenfolge alloziert werden
Heap-Nachteile
- Langsame Allozierung: Komplexer Algorithmus um freie Blöcke zu finden
- Garbage Collection nötig: Go muss regelmäßig ungenutzten Speicher finden und freigeben
- Fragmentierung: Freie Blöcke können über den Speicher verstreut sein
- Schlechtere Cache-Locality: Heap-Daten können weit auseinander liegen
Visualisierung der Speicher Architektur
+---------------------------------------------+
| Prozess-Speicher |
+---------------------------------------------+
| |
| +------------------------------------+ |
| | STACK (Goroutine 1) | |
| | +--------------------------+ | |
| | | main() | | |
| | | - lokale Variablen | | |
| | | - Parameter | | |
| | +--------------------------+ | |
| | | calculate() | | |
| | | - lokale Variablen | | |
| | | - Parameter | | |
| | +--------------------------+ | |
| | | helper() | | |
| | | - lokale Variablen | | |
| | +--------------------------+ | |
| | ^ Waechst nach unten | |
| +------------------------------------+ |
| |
| +------------------------------------+ |
| | HEAP (geteilt) | |
| | | |
| | [BigData Objekt] [String] | |
| | ^ | |
| | | | |
| | [User Struct] [Array] | |
| | | |
| | ... dynamisch allokiert ... | |
| | | |
| +------------------------------------+ |
| |
+---------------------------------------------+Was landet auf dem Stack?
Typischerweise landen auf dem Stack folgende Sachen.
1. Lokale Variablen (wenn sie klein genug sind)
func example() {
x := 42 // Stack
y := "Hello" // Stack (String-Header, nicht die Daten selbst)
z := [5]int{} // Stack (Array)
}2. Funktionsparameter
func process(a int, b string) { // a und b auf Stack
// ...
}3. Return-Werte
func calculate() int {
return 42 // Oft im Register, sonst Stack
}Die Regel ist: Wenn eine Variable eine lokale Lebensdauer hat (sie wird nur innerhalb der Funktion benutzt und ihre Adresse wird nicht zurückgegeben), versucht der Compiler, sie auf dem Stack zu allozieren.
Was landet auf dem Heap?
Variablen landen auf dem Heap, wenn:
1. Ihre Adresse die Funktion überlebt
func createValue() *int {
x := 42
return &x // x muss auf Heap sein
}Warum ist das hier so? Wenn die Funktion endet, wird ihr Stack Frame entfernt. Wenn x auf dem Stack wäre, würde die zurückgegebene Adresse auf ungültigen Speicher zeigen (Dangling Pointer). Der Compiler erkennt das und alloziert x auf dem Heap.
2. Sie zu groß für den Stack sind
func huge() {
big := [10000000]int{} // 80 MB - zu groß für den Stack
// big landet auf dem Heap
}3. Sie in einem Closure verwendet werden
func makeCounter() func() int {
count := 0 // count muss auf dem Heap sein
return func() int {
count ++
return count
}
}Die innere Funktion überlebt makeCounter() und braucht Zugriff auf count.
4. Sie in einem Interface gespeichert werden
func toInterface(x int) interface{} {
return x // x wird auf dem Heap alloziert
}Stack-Allocation im Detail
Schauen wir uns etwas genauer an, wie Stack-Allocation funktioniert.
func a() {
x := 10 // (1) Stack-Pointer wird um 8 Bytes erhöht
b() // (2) Neuer Stack-Frame wird oben drauf gelegt
// (5) Stack-Pointer wird um 8 Bytes verringert
}
func b() {
y := 20 // (3) Stack-Pointer wird um 8 Bytes erhöht
// (4) Stack-Pointer wird um Bytes verringert
}So würde der Stack-Zustand während der Ausführung aussehen.
Anfang von a()
+------------+
| a() |
| x = 10 | <- Stack Pointer
+------------+
Während b()
+------------+
| b() |
| y = 20 | <- Stack Pointer
+------------+
| a() |
| x = 10 |
+------------+
Ende von b()
+------------+
| a() |
| x = 10 | <- Stack Pointer
+------------+
(leer) <- Stack PointerDas Allozieren und Freigeben ist extrem schnell - nur ein Pointer wird verschoben.
Heap-Allocation im Detail
Heap-Allocation ist komplexer. Wenn wir auf dem Heap allozieren möchten, haben wir eine andere Vorgehensweise.
func create() *BigData {
data := &BigData{} // (1) Compiler erkennt - braucht Heap
return &data // (2) Adresse wird zurückgegeben
}Was passiert hier genau
- Compile-Zeit: Der Compiler führt “Escape Analysis” durch und erkennt, dass
datadie Funktion überlebt => Heap-Allocation nötig. - Runtime:
- Go’s Memory Allocation (mallocgc) wird aufgerufen
- Er sucht einen freien Block auf dem Heap (komplexer Algorithmus)
- Er reservier den Block
- Er markiert den Block als “in Benutzung”
- Er gibt die Adresse zurück
- Später:
- Der Garbage Collector läuft periodisch
- Er findet heraus, welche Heap-Objekte nicht mehr erreichbar sind
- Er gibt sie frei
Dieses Vorgehen ist viel aufwändiger als Stack-Allocation.
Escape Analysis - Wie Go entscheided
Was ist Escape Analysis?
Escape Analysis ist ein cleverer Compiler-Optimierungsprozess, der automatisch entscheidet, ob eine Variable auf dem Stack oder Heap alloziert werden soll. Dieser Prozess läuft zur Compile-Zeit und analysiert, wie und wo Variablen verwendet werden.
Der Begriff “Escape” (entkommen) bedeutet: Eine Variable “escapet” (entkommt), wenn sie den Scope (Gültigkeitsbereich) ihrer Funktion überlebt oder auf eine Weise verwendet wird, die eine Stack-Allocation unmöglich macht.
Warum ist Escape Analysis wichtig?
Escape Analysis ist aus mehreren Gründen entscheidend:
- Automatische Optimierung: Der Programmiere muss sich nicht darum kümmern, wo Variablen alloziert werden
- Sicherheit: Go verhindert Dangling Pointers (Pointer auf freigegebenen Stack-Speicher)
- Performance: Stack-Allocations sind extrem schnell, also versucht Go, so viel wie möglich auf dem Stack zu allozieren
- Garbage Collection: Weniger Heap-Allocations bedeuten weniger GC-Arbeit
Die Regeln der Escape Analysis
Der Compiler betrachtet mehrere Faktoren, um zu entscheiden, ob eine Variable escapet.
1. Wird die Adresse zurückgegeben
func escape() *int {
x := 42
return &x // ESCAPE: Adresse verlässt die Funktion
}
func noEscape() int {
x := 42
return x // KEIN ESCAPE: Nur der Wert wird zurückgegeben
}2. Wird die Variable in einem Closure verwendet?
func escape() func() {
x := 42
return func() {
fmt.Println(x) // ESCAPE: x muss überleben
}
}3. Wird die Variable in ein Interface konvertiert?
func escape(x int) interface{} {
return x // ESCAPE: Interface speichert Wert auf Heap
}4. Ist die Variable groß
func huge() {
big := [10000000]int{} // ESCAPE: Zu groß für Stack
}5. Wird die Variable in einen Channel geschickt?
func escape() {
x := 42
ch := make(chan int)
ch <- x // Möglicherweise ESCAPE (abhängig vom Channel-Lebenszyklus)
}6. Wird die Variable in einem Slice/Map gespeichert, der escapet?
func escape() []int {
x := 42
return []int{x} // ESCAPE: Slice escapet, also escapet x auch
}Escape Analysis in Aktion sehen
Go bietet ein Tool, um zu sehen, was der Compiler entscheidet. Mit dem -gcflags="-m" Flag kann man die Escape Analysis Entscheidungen sehen.
go build -gcflags="-m" yourfile.goOder für detailliertere Informationen.
go build -gcflags="-m -m" yourfile.goSchauen wir uns ein greifbares Beispiel an.
package main
type Data struct {
Value int
}
// Fall 1 - KEIN ESCAPE
func stackAllocation() {
d := Data{Value: 42}
_ = d.Value // d wird nur lokal verwendet
}
// Fall 2 - ESCAPE
func heapAllocation() *Data {
d := Data{Value: 42}
return &d // Adresse wird zurückgegeben
}
// Fall 3 - ESCAPE (zu groß)
func largeAllocation() {
large := [10_000_000]int{}
_ = large[0]
}
// Fall 4 - Parameter escapet nicht
func parameterNoEscape(d Data) {
_ = d.Value
}
// Fall 5 - Parameter escapet
func parameterEscape(d Data) *Data {
return &d // Adresse des Parameters
}
func main() {
stackAllocation()
heapAllocation()
largeAllocation()
parameterNoEscape()
parameterEscape(Data{})
}# command-line-arguments
./main.go:8:6: can inline stackAllocation
./main.go:14:6: can inline heapAllocation
./main.go:20:6: can inline largeAllocation
./main.go:26:6: can inline parameterNoEscape
./main.go:31:6: can inline parameterEscape
./main.go:35:6: can inline main
./main.go:36:17: inlining call to stackAllocation
./main.go:37:16: inlining call to heapAllocation
./main.go:38:17: inlining call to largeAllocation
./main.go:39:19: inlining call to parameterNoEscape
./main.go:40:17: inlining call to parameterEscape
./main.go:15:2: moved to heap: d
./main.go:21:2: moved to heap: large
./main.go:31:22: moved to heap: d
./main.go:38:17: moved to heap: largeEscape vermeiden (wenn gewünscht)
Manchmal möchte man Escape vermeiden für bessere Performance.
Strategie 1 - Werte statt Pointer zurückgeben
// ESCAPE
func create() *Data {
return &Data{Value: 42}
}
// KEIN ESCAPE
func create() Data {
return Data{Value: 42}
}Strategie 2 - Caller-Allocated Pattern
// Caller alloziert (kontrollierte Allocation)
func fill(d *Data) {
d.Value = 42
}
func main() {
var d Data // Kann auf Stack bleiben
fill(&d)
}Strategie 3 - Sync.Pool für wiederverwendbare Objekte
var pool = sync.Pool{
New: func() interface{} {
return &Data{}
}
}
func useData() {
d := pool.Get().(*Data)
defer pool.Put(d)
}Nil Pointers - Der Null-Zustand
Was ist nil?
nil ist in Go der Zero Value (Null-Wert) für Pointer, aber auch für andere Reference-Typen wie Slices, Maps, Channels, Interfaces und Funktionstypen. Für Pointer bedeutet nil: “dieser Pointer zeigt auf nichts” oder präziser: “dieser Pointer enthält keine gültige Speicheradresse”.
Warum brauchen wir nil?
Nill ist extrem nützlich für mehrere Szenarien:
1. Optionale Werte (Optional Values)
In vielen Situationen ist “kein Wert” ein gültiger Zustand. Zum Beispiel:
- Eine Datenbank-Abfrage findet keinen Datensatz
- Ein optionales Feld in einer Konfiguration ist nicht gesetzt
- Ein Cache-Miss (Wert nicht im Cache)
Ohne nil müsste man komplizierte Workarounds verwenden.
// Ohne Pointer (umständlich)
type User struct {
Name string
Email string
}
type UserResult struct {
User User
Found bool // Extra-Feld nötig
}
func FindUser(id int) UserResult {
// ...
return UserResult{Found: false}
}
// Mit Pointer (elegant)
func FindUser(id int) *User {
// ...
return nil // User nicht gefunden
}2. Uninitialisierter Zustand
Pointer haben standardmäßig den Wert nil, was klar signalisiert: “noch nicht initilaisiert”.
var p *int // p ist nil (noch nicht initialisiert)
if p == nil {
// Pointer wurde noch nicht gesetzt
}3. Optionale Felder in Structs
type Config struct {
Host string
Port int
Timeout *int // Optional: nil = nicht gesetzt
}Im letzten Beispiel ist Timeout optional. Wenn es nil ist, wurde kein Timeout gesetzt. Wenn es nicht nil ist, zeigt es auf den tatsächlichen Timeout-Wert.
Das Zero Value von Pointern
In Go hat jeder Typ einen Zero Value - den Standardwert, den eine Variable hat, wenn sie deklariert aber nicht initialisiert wird.
var i int // Zero Value => 0
var s string // Zero Value => ""
var b bool // Zero Value => false
var p *int // Zero Value => nilBei Pointern ist das Zero Value immer nil.
var p *int
fmt.Println(p == nil) // true
var user *User
fmt.Println(user == nil) // trueNil Pointer Dereferenzierung - Die häufigste Panic
Der mit Abstand häufigste Fehler mit Pointern ist die Dereferenzierung eines nil-Pointers. Das führt zu einer Runtime-Panic mit der Meldung: “invalid memory address or nil pointer dereference”.
Warum ist das ein Problem? Schauen wir uns an, was bei einer Dereferenzierung passiert.
var p *int // p ist nil
x := *p // PANICWas der Compiler versucht
- Lese den Wert von
p(ergibt 0 oder eine speziellenil-Marke) - Gehe zu Adresse von 0 (oder einer ungültigen Adresse)
- Lese die Bytes an dieser Adresse
Schritt 3 schlägt fehl, weil die Adresse 0 (bzw. nil) absichtlich ungültig ist. Das Betriebssystem schützt diese Adresse, sodass kein Programm darauf zugreifen kann. Das führt zur Panic.
Warum ist das gut?
Alternativ könnte der Compiler einfach zufällige Bytes von Adresse 0 lesen. Das wäre katastrophal.
- Undefiniertes Verhalten
- Schwer zu debuggen
- Potenzielle Sicherheitslücken
Die Panic ist also eine Schutzmaßnahme: Liefer sofort abstürzen mit klarer Fehlermeldung als mit korrupten Daten weiter ausgeführt zu werden.
Nil-Checks - Die Lösung
Die Lösung ist einfach: Immer auf nil prüfen, bevor diese dereferenziert wird.
var p *int
// FALSCH - kein Check
x := *p // PANIC
// RICHTIG - Mit Check
if p != nil {
x := *p // Sicher
}Das kommt so oft vor, dass es zum idiomatischen Go-Stil gehört.
func FindUser(id int) *User {
// ... Suche ...
return nil // Nicht gefunden
}
// Idiomatischer Gebrauch
if user := FindUser(123); user != nil {
fmt.Println(user.Name)
} else {
fmt.Println("User nicht gefunden")
}Nil als gültiger receiver
Eine interessante und mächtige Eigenschaft von Go: Wir können Methoden als nil-Pointern aufrufen!
type List struct {
Value int
Next *List
}
func (l *List) Len() int {
if l == nil {
return 0 // Nil-safe Implementierung
}
return 1 + l.Next.Len()
}
func main() {
var list *List // nil
fmt.Println(list.Len()) // 0 (kein Panic)
}Wie funktioniert das?
Wenn wir eine Methode auf einem Pointer aufrufen, wird der Pointer als Receiver übergeben. Auch wenn dieser Pointer nil ist, wird die Methode aufgerufen - der Receiver ist einfach nil. Innerhalb der Methode müssen wir dann auf nil prüfen.
Das ist anders als in vielen anderen Sprachen (z.B. Java, C#), wo null.method() sofort eine Exception wirft.
Warum ist das nützlich?
Es ermöglicht elegante, nil sichere APIs.
type Tree struct {
Value int
Left *Tree
Right *Tree
}
func (t *Tree) Find(value int) *Tree {
if t == nil {
return nil
}
if t.Value == value {
return t
}
if value < t.Value {
return t.Left.Find(value)
}
return t.Right.Find(value)
}Nil vs leere Strukturen
Es ist wichtig, zwischen nil und leeren Strukturen zu unterscheiden.
package main
import "fmt"
type User struct {
Name string
Email string
}
func main() {
var p1 *User
fmt.Println(p1 == nil)
// Pointer auf leeren Struct
p2 := &User{}
fmt.Println(p2 == nil)
}true
falseDas ist ein subtiler aber wichtiger Unterschied.
nil: Kein User-Objekt&User{}Ein User-Objekt existiert, aber alle Felder sind leer
Semantisch können diese unterschiedlich sein
nil: Benutzer wurde nicht geladen / existiert nicht&User{}: Benutzer existiert, hat aber noch keinen Namen
Nil bei Slices, Maps, Channels
Nil funktioniert etwas anders bei anderen Referenz-Typen.
Slices
package main
import "fmt"
func main() {
var s []int
fmt.Println(s == nil)
fmt.Println(len(s))
s = append(s, 1)
fmt.Println(s)
}true
0
[1]Ein nil Slice verhält sich wie ein leerer Slice für die meisten Operationen.
Maps
package main
import "fmt"
func main() {
var m map[string]int
fmt.Printf("m == nil => %t\n", m == nil)
fmt.Println(len(m))
v := m["key"]
fmt.Printf("v => %v\n", v)
m["key"] = 42
}m == nil => true
0
v => 0
panic: assignment to entry in nil mapLesen aus nil Maps ist sicher, Schreiben nicht.
Channels
var ch chan int // int channel
ch == nil // true
ch <- 42 // Blockiert EWIG
<-ch // Blockiert EWIGBest Practices mit nil
1. Immer prüfen, außer man ist sicher
// Gut
if user =! nil {
fmt.Println(user.Name)
}
// Auch gut, wenn man weiß, dass "user" nicht nil ist
user := &User{Name: "John"}
fmt.Println(user.Name)2. Nil dokumentieren
// FindUser sucht einen User nach ID
// Gibt nil zurück, wenn der User nicht existiert
func FindUser(id int) *User {
// ...
}3. Nil-safe Methoden implementieren, wo sinnvoll
func (l *List) IsEmpty() bool {
return l == nil
}4. Zero Value nutzen
var user *User // Automatisch nil5. Nil für optionale Parameter
func NewServer(config *Config) *Server {
if config == nil {
config = &Config{/* Standardwerte */}
}
// ...
}
// Aufruf mit Standardwerten
server := NewServer(nil)Das nil Interface Problem
Ein subtiler und häufiger Fehler gibt es mit nil und Interfaces. Schauen wir uns es an.
func returnError() error {
var err *MyError = nil
return err // NICHT nil
}
func main() {
if err := returnError(); err != nil {
fmt.Println("Fehler!") // Wird ausgegeben
}
}Warum?
Ein Interface in Go besteht aus zwei Teilen: Typ & Wert. Ein Interface ist nur nil, wenn beide nil sind. Hier ist der Typ *MyError (nicht nil), auch wenn der Wert nil ist.
Mögliche Lösung
func returnError() error {
var err *MyError = nil
if err != nil {
return err
}
return nil // Explizit nil zurückgeben
}Pointer auf Structs
Warum Pointer bei Structs besonders wichtig sind
Structs sind die primären Datencontainer in Go - sie gruppieren zusammengehörige Daten. Anders als Primitive (int, bool, etc.) können Structs beliebig groß werden, von wenigen Bytes bis zu Megabytes. Aus diesem Grund sind Pointer bei Structs besonders wichtig und häufig.
Betrachten wir einen einfachen Struct vs. einen komplexen Struct.
// Klein - 16 Bytes
type Point struct {
X, Y int
}
// Groß - Hunderte bis Tausende Bytes
type User struct {
ID int
Name string
Email string
Password [64]byte
Avatar [10000]byte
Settings map[string]string
Friends []int
// ... und weitere
}Bei Point könnte man argumentieren: “16 Bytes kopieren ist schnell, kein Pointer nötig”. Bei User wäre das Kopieren verschwenderisch und langsam - hier sind Pointer fast immer die richtige Wahl.
Drei Wege, Pointer auf Structs zu erstellen
Go bietet drei idiomatische Wege, Pointer auf Structs zu erstellen.
1. Variable erstellen, dann Adresse nehmen
user := User{
Name: "John",
Email: "john@mail.com",
}
p := &User // p ist *UserWas passiert hier?
Userwird erstellt (könnte Heap oder Stack sein)- Adresse wird genommen
- Wenn die Adresse zurückgegeben oder gespeichert wird, escapet
userzum Heap.
2. Direkt mit & und Literal
p := &User{
Name: "John",
Email: "john@mail.com",
}
// p ist *UserWas passiert hier?
- Struct wird erstellt (meist auf Heap, da Adresse sofort genommen wird)
- Adresse wird direkt zurückgegeben
Das ist die idiomatische und häufigste Methode in Go.
3. Mit new() Zero Values
package main
import "fmt"
type User struct {
Name string
Email string
Active false
Balance int
}
func main() {
user := new(User)
fmt.Println(*user)
user.Name = "John"
user.Balance = 1400
fmt.Println(*user)
}{ false 0}Wie wir hier sehen können, ist direkt nach der Erstellung user ein User Objekt mit Zero Feldern. Die ersten beiden Positionen in der Ausgabe sind Name und Email mit leeren Strings als Zero Value. Die anderen beiden haben dann false und 0.
Wenn wir jetzt das Beispiel ausbauen und eine Änderung implementieren, werden wir sehen, dass man das Objekt füllen kann und die Zero Values durch eigene ersetzen kann.
package main
import "fmt"
type User struct {
Name string
Email string
Active false
Balance int
}
func main() {
user := new(User)
user.Name = "John"
user.Balance = 1400
fmt.Println(*user)
}{John false 1400}Was passiert hier?
- Ein
Userwird auf dem Heap erstellt (new()alloziert immer auf Heap) - Alle Felder haben ihre Zero Values
- Adresse wird zurückgegeben
Syntaktischer Zucker - Implizite Dereferenzierung
Go hat ein sehr praktisches Feature bei Struct-Pointern: implizite Dereferenzierung beim Feld-Zugriff.
user := &User{Name: "John"}
// Explizite Dereferenzierung (wie in C)
name := (*user).Name
// Implizite Dereferenzierung (idiomatisch in Go)
name := user.NameWas bedeutet das?
Wenn wir user.Name schreiben und user ein Pointer ist, erkennt Go automatisch: “user ist ein Pointer, ich muss erst dereferenzieren, um an die Felder zu kommen”. Intern wird es zu (*user).Name, aber wir müssen es nicht schreiben.
Warum ist das praktisch?
Es macht Code lesbarer und wir müssen uns beim Schreiben nicht um Pointer vs. Value kümmern.
package main
import "fmt"
type User struct {
Name string
Email string
}
func UpdateEmailImplicit(user *User, email string) {
user.Email = email
}
func UpdateEmailExplicit(user *User, email string) {
(*user).Email = email
}
func main() {
user := &User{
Name: "John",
Email: "john@mail.com",
}
UpdateEmailImplicit(user)
fmt.Println(*user)
UpdateEmailExplicit(user)
fmt.Println(*user)
}{John first@mail.com}
{John second@mail.com}Wichtig: Die implizite Dereferenzierung funktioniert nur bei Feldern, nicht bei Methoden (dort gibt es automatische Konvertierung).
Struct-Pointer in Funktionen
Ein Hauptanwendungsfall für Struct-Pointer: Funktionen, die einen Struct modifizieren sollen.
package main
import "fmt"
type Counter struct {
Count int
}
func IncrementWithoutPointer(c Counter) {
c.Count++ // Ändert nur die Kopie
}
func IncrementWithPointer(c *Counter) {
c.Count++
}
func main() {
counter := Counter{Count: 0}
Increment(counter)
fmt.Println(counter.Count)
counter2 := Counter{Count :0}
IncrementWithPointer(&counter)
fmt.Println(counter2)
}{0}
{1}Im ersten Fall (IncrementWithoutPointer) wurde lediglich eine Kopie übergeben. Hier wurde die ursprüngliche Variable (bzw. ihr Wert) gar nicht aktualisiert. Im zweiten Fall (IncrementWithPointer) wird ein Pointer erwartet und auch übergeben. Das führt dazu, dass sich der Wert direkt an der Quell-Variable aktualisiert.
Konstruktor Pattern mit Pointern
In Go gibt es keine built-in Konstruktoren (wie in Java/C++), aber die Konvention ist, eine Funktion New oder NewTypeName zu schreiben, die einen initialisierten Pointer zurückgibt.
package main
import (
"fmt"
"time"
)
type User struct {
ID int
Name string
CreatedAt time.Time
active bool // private Eigenschaft
}
// Konstruktor
func NewUser(id int, name string) *User {
return &User{
ID: id,
Name: name,
CreatedAt: time.Now(),
active: true,
}
}
func main() {
user := NewUser(1, "John Doe")
fmt.Println(*user)
}{1 John Doe 2025-12-13 14:21:08.542338 +0100 CET m=+0.000179210 true}Warum sollte man Pointer zurückgeben?
- Konsistenz: Die meisten Go-Typen in der Standard-Library geben Pointer zurück
- Effizienz: Kein Kopieren großer Structs
- Erwartung: Nutzer erwarten, dass sie das Origianl haben, nicht eine Kopie
- Erweiterbarkeit: Später kann man einfach Methoden mit Pointer-Receiver hinzufügen
Struct-Pointer und nil
Ein wichtiger Aspekt: Struct-Pointer können nil sein, was bei Funktionen geprüft werden sollte.
package main
import "fmt"
type User struct {
Name string
Email string
}
func GetName(u *User) string {
if u == nil {
return "Unbekannt"
}
return u.Name
}
func main() {
// Ohne Initialisierung versuchen
var user *User
fmt.Println(GetName(user))
// Mit Initialisierung
user = &User{Name: "John"}
fmt.Println(GetName(user))
}Unbekannt
John