navigation Navigation


Inhaltsverzeichnis

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:

    1. Der Compiler reserviert einen bestimmten Bereich im Speicher (abhängig vom Typ der Variable)
    2. Dieser Speicherbereich bekommt eine eindeutige Adresse
    3. Der Wert der Variable wird in diesem Speicherbereich abgelegt
    4. 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:

    1. 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.
    2. Performance-Probleme: Das Kopieren von Megabytes oder Gigabytes an Daten dauert Zeit. Bei jedem Funktionsaufruf würde das Programm spürbar langsamer.
    3. Ä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:

    1. Effizienz: Nur 8 Bytes werden kopiert (die Adresse), nicht die gesamten Daten
    2. Direkte Manipulation: Änderungen erfolgen am Original, nicht an einer Kopie
    3. Gemeinsame Daten: Mehrere Teile des Programms können auf dieselben Daten zugreifen
    4. Optionale Werte: Pointer können nil sein, 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.

    Beispiel
    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: 100

    Was 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.

    Beispiel
    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: 255

    Was ist hier anders?

    1. Die Funktion applyFilter nimmt jetzt einen Pointer auf Image: img *Image
    2. Beim Aufruf übergeben wir die Adresse mit &myImage
    3. Es werden nur 8 Bytes (die Adresse) kopiert, nicht 36 MB
    4. Die Funktion arbeitet direkt mit dem Original über die Adresse
    5. Ä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:

    1. Der Compiler bestimmt, wie viele Bytes die Variable benötigt (basierend auf ihrem Typ)
    2. Das Betriebssystem reserviert einen entsprechenden, zusammenhängenden Bereich im Speicher
    3. Dieser Bereich bekommt eine Startadresse
    4. Der Wert wird in diesen Bereich geschrieben
    5. 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.

    Schema
    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:

    • x ist eine normale Variable an Adresse 1000, die den Wert 42 enthält
    • p ist ein Pointer an Adresse 2000, der den Wert 1000 enthält (die Adresse von x)
    • Hier können wir sage: p zeigt auf x

    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-Struct

    Die 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:

    • *int ist ein Typ: “Pointer auf int”
    • *string ist ein Typ: “Pointer auf string”
    • *Person ist 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:

    1. Der Compiler weiß, wie viele Bytes er lesen/schreiben muss
    2. Die Bytes korrekt interpretiert werden
    3. 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 x ist 42 (hexadezimal 0x2A), gespeichert in den 8 Bytes
    • p (Typ: *int64) liegt an Adresse 0x2000 und nimmt ebenfalls 8 Bytes ein
    • Der Wert von p ist 0x1000 (die Adresse von x)

    Wenn wir nun *p schreiben (Dereferenzierung), sagt das dem Compiler:

    1. Lies den Wert aus p (ergibt 0x1000)
    2. Gehe zu Adress 0x1000
    3. Lies 8 Bytes ab dieser Adresse (weil p ein *int64 ist)
    4. Interpretiere diese 8 Bytes als int64
    5. 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:

    1. Wir haben eine große Datenstruktur D an Adresse 1000
    2. Zehn verschiedene Teiles unseres Programms brauchen Zugriff auf D
    3. Mit Werten: Jeder Teil hätte seine Kopie von D (10x Speicherverbrauch)
    4. 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

    &variable

    Was passiert intern

    Wenn wir &x schreiben, passiert Folgendes:

    1. Der Compiler findet die Variable x in seiner Symboltabelle
    2. Er schaut nach, an welcher Speicheradresse x liegt
    3. Er gibt diese Adresse zurück (als Pointer vom Typ T, wobei T der Typ von x ist)

    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 *int

    2. Als Dereferenzierungs-Operator: “Wert an der Adresse”

    Hier bedeutet es: “gib mir den Wert, auf den p zeigt”

    value := *p

    Die 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

    1. Der Compiler liest den Wert von p (das ist eine Speicheradresse)
    2. Er geht zu dieser Speicheradresse
    3. Er liest die Bytes ab dieser Adresse (Anzahl von Bytes abhängig vom Typ)
    4. Er interpretiert diese Bytes entsprechend dem Typ
    5. 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.

    Beispiel
    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 42

    Schritt-für-Schritt Beispiel

    Lasst uns jeden Schritt eines Pointer-Vorgangs detailliert durchgehen.

    Beispiel
    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 Adresse 0x1400000e0b0
    • Er schreibt die Zahl 42 in diese 8 Bytes
    • Er erstellt einen Eintrag in seiner Symboltabelle “x => Adresse 0x1400000e0b0, Typ int

    (2) p := &x

    • Der Compiler findet x in seiner Symboltabelle und sieht: “Adresse 0x1400000e0b0
    • Er erstellt eine neue Variable p vom Typ *int (Pointer auf int)
    • Er alloziert 8 Bytes für p (sagen wir an Adresse 0x14000092020)
    • Er schreibt die Adresse 0x1400000e0b0 in diese 8 Bytes (das ist der Wert von p)
    • In der Symboltabelle: “p => Adresse 0x1400000e0b0, Typ *int, Wert 0x1400000e0b0

    (3) *p

    • Der Compiler liest den Wert von p: 0x1400000e0b0
    • Er geht zu Adresse 0x1400000e0b0
    • Er liest 8 Bytes (weil p ein *int ist und int 8 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: x hat jetzt den Wert 100 (weil p auf x zeigt)

    **Schauen wir uns es nochmals schematisch an.

    Schritt 1
    x := 42
    
    +----------+----------------+------+
    | Variable | Adresse        | Wert |
    +----------+----------------+------+
    | x        | 0x1400000e0b0  | 42   |
    +----------+----------------+------+
    Schritt 2
    p := &x
    
    +----------+----------------+-----------------------------+
    | Variable | Adresse        | Wert                        |
    +----------+----------------+-----------------------------+
    | x        | 0x1400000e0b0  | 42                          |
    | p        | 0x14000092020  | 0x1400000e0b0 (zeigt auf x) |
    +----------+----------------+-----------------------------+
    Schritt 3
    *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.

    Beispiel
    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
    20

    Was 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.

    Beispiel
    package main
    
    import "fmt"
    
    func main() {
        x := 42
        p := &x
        pp := &p
    
        fmt.Println(**pp)
    }
    42

    **pp bedeutet:

    1. *pp → Folge dem ersten Pointer (pp), ergibt p
    2. *p → Folge dem zweiten Pointer (p), ergibt x

    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.

    Beispiel
    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 10

    Warum 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)
    }
    42

    Wenn wir modify(a) aufrufen, passiert Folgendes:

    1. Der Wert von a (42) wird gelesen
    2. Eine neue Variable x wird erstellt (auf dem Stack der Funktion modify)
    3. Der Wert 42 wird in x KOPIERT
    4. modify() arbeitet mit dieser Kopie
    5. 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?

    1. &myData gibt die Adresse von myData zurück (z.B. 0x1400000e0c8)
    2. Diese Adresse (8 Bytes) wird KOPIERT (Go bleibt “Pass by value”)
    3. Die Funktion erhält einen Pointer mit dem Wert 0x1400000e0c8
    4. Über diesen Pointer kann sie direkt auf myData zugreifen
    5. Ä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

    Beispiel
    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

    1. Extrem schnelle Allokation: Stack-Speicher wird einfach durch Erhöhen eines Pointers (O(1), nur eine CPU-Instruktion)
    2. Automatische Freigabe: Wenn eine Funktion endet, wird ihr gesamter Stack Frame automatisch freigegeben.
    3. Bessere Cache-Locality: Stack-Daten liegen sequentiell im Speicher, was CPU-Caches effizient nutzt
    4. Keine Fragmentierung: Der Stack wächst und schrumpft streng linear

    Stack-Nachteile

    1. Begrenzte Größe: Typischerweise 1-8 MB pro Goroutine (konfigurierbar)
    2. Funktions-gebundene Lebensdauer: Daten überleben den Funktionsaufruf nicht
    3. Sequenzielle Nutzung: Nur am “Ende” können Daten hinzugefügt werden

    Heap-Vorteile

    1. Große Größe: Begrenzt nur durch verfügbaren RAM (Gigabytes)
    2. Flexible Lebensdauer: Daten können beliebig lange leben
    3. 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.

    Beispiel
    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.

    Schema
    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 Pointer

    Das 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.

    Beispiel
    func create() *BigData {
        data := &BigData{}      // (1) Compiler erkennt - braucht Heap
        return &data            // (2) Adresse wird zurückgegeben
    }

    Was passiert hier genau

    1. Compile-Zeit: Der Compiler führt “Escape Analysis” durch und erkennt, dass data die Funktion überlebt => Heap-Allocation nötig.
    2. 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
    3. 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:

    1. Automatische Optimierung: Der Programmiere muss sich nicht darum kümmern, wo Variablen alloziert werden
    2. Sicherheit: Go verhindert Dangling Pointers (Pointer auf freigegebenen Stack-Speicher)
    3. Performance: Stack-Allocations sind extrem schnell, also versucht Go, so viel wie möglich auf dem Stack zu allozieren
    4. 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.go

    Oder für detailliertere Informationen.

    go build -gcflags="-m -m" yourfile.go

    Schauen wir uns ein greifbares Beispiel an.

    Beispiel
    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: large

    Escape vermeiden (wenn gewünscht)

    Manchmal möchte man Escape vermeiden für bessere Performance.

    Strategie 1 - Werte statt Pointer zurückgeben

    Beispiel
    // ESCAPE
    func create() *Data {
        return &Data{Value: 42}
    }
    
    // KEIN ESCAPE
    func create() Data {
        return Data{Value: 42}
    }

    Strategie 2 - Caller-Allocated Pattern

    Beispiel
    // 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.

    Beispiel - Workaround
    // 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”.

    Beispiel
    var p *int  // p ist nil (noch nicht initialisiert)
    
    if p == nil {
        // Pointer wurde noch nicht gesetzt
    }

    3. Optionale Felder in Structs

    Beispiel
    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 => nil

    Bei Pointern ist das Zero Value immer nil.

    var p *int
    fmt.Println(p == nil)   // true
    
    var user *User
    fmt.Println(user == nil)    // true

    Nil 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     // PANIC

    Was der Compiler versucht

    1. Lese den Wert von p (ergibt 0 oder eine spezielle nil-Marke)
    2. Gehe zu Adresse von 0 (oder einer ungültigen Adresse)
    3. 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.

    Beispiel
    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
    false

    Das 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

    Beispiel
    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

    Beispiel
    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 map

    Lesen aus nil Maps ist sicher, Schreiben nicht.

    Channels

    var ch chan int     // int channel
    ch == nil           // true
    ch <- 42            // Blockiert EWIG
    <-ch                // Blockiert EWIG

    Best 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 nil

    5. 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 *User

    Was passiert hier?

    • User wird erstellt (könnte Heap oder Stack sein)
    • Adresse wird genommen
    • Wenn die Adresse zurückgegeben oder gespeichert wird, escapet user zum Heap.

    2. Direkt mit & und Literal

    p := &User{
        Name: "John",
        Email: "john@mail.com",
    }
    
    // p ist *User

    Was 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

    Schritt 1
    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.

    Schritt 1
    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 User wird 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.Name

    Was 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.

    Beispiel
    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.

    Ohne Pointer
    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.

    Beispiel
    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.

    Beispiel
    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