navigation Navigation


Inhaltsverzeichnis

Funktionen


Funktionen sind in Go das zentrale Werkzeug, um Logik klar zu kapseln und Programme strukturiert wachsen zu lassen. Sie definieren präzise Schnittstellen, trennen Verantwortlichkeiten und ermöglichen Wiederverwendung ohne unnötige Kopien. Durch Wert- und Zeigertypen, Mehrfachrückgaben sowie explizite Fehlerbehandlung bleiben Datenflüsse transparent und nachvollziehbar. Zusammen mit First-Class-Funktionen, Closures und Methoden auf Structs entsteht ein flexibles Baukastensystem, das sich von kleinen Hilfsroutinen bis zu robusten Architekturen skalieren lässt.

Inhaltsverzeichnis

    Einführung

    Funktionen sind so ziemlich die wichtigsten Bausteine nahezu jeder Programmiersprache. Go ist hier keine Ausnahme. Sie sind der primäre Mechanismus zur Strukturierung, Wiederverwendung und Abstraktion von Code. In Go haben Funktionen einige besondere Eigenschaften, die sie von Funktionen in anderen Sprachen unterscheiden.

    Go behanldelt Funktionen als First-Class Citiziens. Das bedeutet Folgendes:

    • Funktionen sind Werte: Sie können Variablen zugewiesen werden
    • Funktionen können Parameter sein: Sie können an andere Funktionen übergeben werden
    • Funktionen können zurückgegeben werden: Eine Funktion kann eine andere Funktion als Rückgabewert haben
    • Funktionen können Closures bilden: Sie können auf Variablen aus ihrem umgebenden Scope zugreifen

    Diese Eigenschaften machen Go zu einer Sprache, die sowohl prozedurale als auch funktionale Programmierparadigmen unterstützt.

    Die Rolle von Funktionen in Go

    Im Gegensatz zu objektorientierten Sprachen wie Java oder C++, wo Klassen und Objekte im Mittelpunkt stehen, organisiert Go Code primär durch:

    1. Packages: Logische Gruppierung von Code
    2. Funktionen: Ausführbare Einheiten
    3. Methoden: Funktionen mit Receivern (auf Typen gebunden)

    Es gibt keine Klassen in Go. Stattdessen kombiniert man:

    • Structs für Datenstrukturen
    • Funktionen für Verhalten
    • Methoden (Funktionen mit Receivern) für typgebundenes Verhalten
    • Interfaces für Polymorphismus

    Funktionen vs. Methoden

    Funktionen und Methoden unterscheiden sich in Go durch die Bindung an einen Typ.

    Funktion

    Funktion
    func CalculateArea(width, height float64) float64 {
        return width * height
    }

    Methode (Funktion mit Receivern)

    Methode
    type Rectangle struct {
        Width, Height float64
    }
    
    func (r Rectangle) Area() float64 {
        return r.Width * r.Height
    }
    
    func main() {
        r := Rectangle{
            Width: 10,
            Height: 20,
        }
    
        area := r.Area()
    }

    Syntax und Aufbau

    Anatomie einer Funktion

    Eine Funktion in Go hat folgende Struktur.

    func <Name>(<Parameter>) <Rückgabetyp> {
        <Funktionskörper>
    }

    Einfachste Funktion

    Beispiel
    func sayHello() {
        fmt.Println("Hello world")
    }

    Was haben wir hier?

    • func: Keyword für Funktionsdefinition
    • sayHello: Funktionsname (CameCase-Konvention)
    • (): Leere Parameterliste (keine Parameter)
    • Kein Rückgabetyp angegeben (implizit “void”)
    • {}: Funktionskörper

    Funktion mit Parameter

    Beispiel
    func greet(name string) {
        fmt.Println("Hello", name)
    }

    Was ist hier neu?

    • name string: Ein Parameter vom Typ string
    • Die Syntax: <Name> <Typ>
    • Der Parameter ist innerhalb der Funktion als lokale Variable verfügbar

    Funktion mit Rückgabewert

    Beispiel
    func add(a int, b int) int {
        return a + b
    }

    Neu hier

    • int nach der Parameterliste: Rückgabetyp
    • return: Keyword zum Zurückgeben eines Wertes
    • Der Rückgabewert muss vom angegebenen Typ sein

    Es besteht auch die Möglichkeit für gleichtypisierte Parameter eine Kurzschreibweise zu verwenden.

    Beispiel
    func add(a, b int) int {
        return a + b
    }

    Sichtbarkeit (Exported vs. Unexported)

    Wie bei allen Identifiern in Go bestimmt die Groß- und Kleinschreibung des ersten Buchstabens die Sichtbarkeit.

    Beispiel
    // Exported
    func PublicFunction() {
        fmt.Println("I am public")
    }
    
    // Unexportet
    func privateFunction() {
        fmt.Println("I am private")
    }

    Konvention

    • Großbuchstabe: Exportiert (öffentlich)
    • Kleinbuchstabe: Nicht exportiert (privat)

    Dies gilt für: Variablen, Typen, Struct-Felder, Methoden


    Die main Funktion

    Jedes ausführbare Go-Programm benötigt eine main Funktion im main Package. Die Funktion muss main heißen und es muss sich im main Package befinden.

    Diese Funktion nimmt keine Parameter an und gibt nichts zurück.

    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("Programm starts here")
    }

    Die init Funktion

    In Go gibt es noch eine besondere Funktion init(). Diese Funktion wird automatisch beim Package-Import aufgerufen. Ausgeführt wird diese Funktion von main() Funktion.

    Man kann diese Funktion mehrfach im selben Package definieren. Auch diese Funktion nimmt keine Parameter entgegen und gibt auch nichts zurück.

    Wichtig: Die Reihenfolge der Ausführung ist folgende:

    1. Alle importierten Packages werden initalisiert (rekursiv)
    2. Alle init() Funktionen werden ausgeführt
    3. Die main() Funktion wird ausgeführt

    Schauen wir uns das ganze an einem Beispiel an. Da die init() Funktion in einem Package beim Import ausgeführt wird, brauchen wir ein Test-Package.

    Bevor wir mit dem Erstellen der Go-Dateien beginnen, sollten wir ein Go-Modul initialisieren.

    go mod init mibeon.com/init-func
    lib/lib.go
    package lib
    
    import "fmt"
    
    func init() {
        SayHello()
    }
    
    func SayHello() {
        fmt.Println("Greetings from lib package")
    }

    Nun werden wir dieses Package in unserer Haupt-Datei importieren.

    main.go
    package main
    
    import (
        "fmt"
    
        _ "mibeon.com/init-func/lib"
    )
    
    func main() {
        fmt.Println("Running main")
    }

    Wenn wir nun diesen Code mit go run main.go ausführen, erhalten wir Folgendes in der Ausgabe.

    Greetings from lib package
    Running main

    Parameter und Argumente

    Terminologie

    Es empfiehlt sich zwischen Parametern und Argumenten zu unterscheiden.

    • Parameter: Die Variablen in der Funktionsdefinition
    • Argumente: Die tatsächlichen Werte beim Funktionsaufruf
    func add(a, b int) int { // a und b sind Parameter
        return a + b
    }
    
    res := add(3, 4) // 3 und 4 sind Argumente

    Parameter-Typen

    In Go müssen alle Parameter typisiert sein. Es gibt keine implizite Typen oder Type Inference bei Parametern.

    Korrekt
    func process(x int, y float64, name string) {
        // ...
    }
    Fehler
    func process(x, y name) {
        // ...
    }

    Mehrere Parameter gleichen Typs

    Wie bereits erwähnt, können aufeinanderfolgende Parameter desselben Typs zusammengefasst werden.

    Beispiel
    // Langform
    func calc(width float64, height float64, depth float64) float64 {
        return width * height * depth
    }
    
    // Kurzform
    func calc(width, height, depth float64) float64 {
        return width * height * depth
    }

    Gemischte Typen, jeder Parameter mit seinem eigenen Typ, würden wie folgt aussehen.

    Beispiel
    func createUser(id int, name, email string, age int, active bool) User {
        // ...
    }

    Pass by Value

    In Go werden Argumente immer by Value übergeben. Das bedeutet, die Funktion erhält eine Kopie der Argumente.

    Schauen wir uns ein Beispiel an.

    Beispiel
    package main
    
    import "fmt"
    
    func modify(x int) {
        x = 100
        fmt.Println("Innerhalb Funktion:", x)
    }
    
    func main() {
        num := 42
        modify(num)
        fmt.Println("Außerhalb Funktion:", num)
    }
    Innerhalb Funktion: 100
    Außerhalb Funktion: 42

    Was passiert in diesem Beispiel?

    1. num hat den Wert 42
    2. Bei modify(num) wird eine Kopie von 42 erstellt
    3. Diese Kopie wird dem Parameter x zugewiesen
    4. Änderungen an x betreffen nur die Kopie
    5. Das Original num bleibt unverändert

    Um dieses Problem zu lösen, verwendet man Pointer.


    Pointer-Parameter für Modifikationen

    Wenn man das Original ändern möchte, übergibt man einen Pointer. Dabei ist wichtig, dass die Funktion einen korrekten Pointer-Typ als Parameter annimmt.

    Beispiel
    package main
    
    import "fmt"
    
    func modify(x *int) {
        *x = 100
        fmt.Println("Innerhalb Funktion:", *x)
    }
    
    func main() {
        num := 42
        modify(&num)
        fmt.Println("Außerhalb Funktion:", num)
    }
    Innerhalb Funktion: 100
    Außerhalb Funktion: 100

    Was passiert nun in diesem Beispiel? Was ist hier anders?

    1. num hat den Wert 42
    2. &num gibt die Adresse von num zurück
    3. Diese Adresse wird by Value kopiert, allerdings in diesem Fall wird der Pointer kopiert
    4. *x = 100 ändert den Wert an der Adresse
    5. Das Original num wird geändert

    Slices, Maps und Channels

    Für Slices, Maps und Channels gilt eine Besonderheit: Sie enthalten intern bereits Pointer/Referenzen.

    Wenn wir das folgende Beispiel anschauen, werden das Verhalten beobachten können.

    Beispiel
    package main
    
    import "fmt"
    
    func modifySlice(s []int) {
        s[0] = 999
    }
    
    func main() {
        nums := []int{1, 2, 3, 4}
        modifySlice(nums)
        fmt.Println(nums)
    }
    [999 2 3 4]

    Warum funktioniert das?

    In diesem Beispiel haben wir unsere Slice-Variable nicht als einen Pointer, sondern direkt (klassisch) übergeben. Warum könnten wir dennoch einen Wert an der ersten Position im Slice ändern?

    Das hängt damit zusammen, dass Slice intern folgende Struktur hat.

    type slice struct {
        ptr *Element  // Pointer auf das zugrundeliegende Array
        len int
        cap int
    }

    Beim Funktionsaufruf wird diese Struktur kopiert, aber der ptr zeigt weiterhin auf dasselbe zugrundeliegende Array. Daher sind Änderungen an den Elementen sichtbar.

    Erweiterung mit append - Besonderheiten

    Mit append kann man das Original-Slice in einer Funktion nicht direkt erweitern, obwohl ein Slice intern ein Pointer beinhaltet.

    Beispiel
    package main
    
    import "fmt"
    
    func modifySlice(s []int) {
        s = append(s, 999)
        fmt.Println("Innerhalb Funktion:", s)
    }
    
    func main() {
        mySlice := []int{1, 2, 3, 4}
        modifySlice(mySlice)
        fmt.Println("Außerhalb Funktion:", mySlice)
    }
    Innerhalb Funktion: [1 2 3 4 999]
    Außerhalb Funktion: [1 2 3 4]

    Wie wir sehen können, wurde nur das Slice innerhalb der Funktion aktualisiert. Die Lösung für die Verwendung von append wäre, ein neues Slice zurückzugeben.

    Beispiel
    package main
    
    import "fmt"
    
    func modifySlice(s []int) []int {
        return append(s, 999)
    }
    
    func main() {
        mySlice := []int{1, 2, 3, 4}
        mySlice = modifySlice(mySlice)
        fmt.Println("Nach Änderung:", mySlice)
    }
    Nach Änderung: [1 2 3 4 999]

    Oder, als Alternative, einfach einen Pointer verwenden.

    Beispiel
    package main
    
    import "fmt"
    
    func modifySlice(s *[]int) {
        *s = append(*s, 999)
    }
    
    func main() {
        mySlice := []int{1, 2, 3, 4}
        modifySlice(&mySlice)
        fmt.Println("Nach Änderung:", mySlice)
    }
    Nach Änderung: [1 2 3 4 999]

    Zusammenfassung: Wann braucht man einen Pointer?

    Hier eine Übersicht, wann ein Pointer benötigt wird, wenn man ein Argument an eine Funktion übergibt.

    TypVerhaltenPointer nötig?
    Primitive Werte (int, float, bool)KopieJa, für Modifikation
    StructsKopieJa, für Modifikation oder Performance
    ArraysKopie (gesamtes Array)Ja, für Modifikation
    SlicesKopie der Slice-Struktur, aber gemeinsames zugrundeliegende ArrayNur für Append/Neuzuweiseung
    MapsKopie der Map-PointersNein
    ChannelsKopie der Channel-ReferenzNein

    Rückagebwerte

    Go hat ein besonderes Feature: Multiple Return Values. Das bedeutet, dass wir in Go nicht nur einen, sonderen auch mehrere Rückgabewerte bei einer Funktion haben können.

    Single return value

    Hierbei wird ein Wert zurückgegeben. Klassisches Schema, wie auch in einigen anderen Programmiersprachen.

    Beispiel
    func double(x int) int {
        return x * 2
    }
    
    res := double(21)

    Multiple return value

    Wie bereits einleitend zu diesem Abschnitt erwähnt, erlaubt Go mehrere Rückgabewerte.

    Mehrere Rückgabewerte
    package main
    
    import "fmt"
    
    func divMod(a, b int) (int, int) {
        quotient := a / b
        remainder := a % b
        return quotient, remainder
    }
    
    func main() {
        q, r := divMod(17, 5)
        fmt.Println(q, r)
    }
    3 2

    Sobald eine Funktion mehr als einen Rückgabewert hat, sollen diese in runde Klammern () gesetzt werden. Wenn eine Funktion mehrere Werte zurückgibt, sollen ebenfalls auf der anderen Seite mehrere Werte mittels Mehrfachzuweisung empfangen werden.


    Das Error-Handling Pattern

    Der häufigste Anwendungsfall für Multiple Returns ist Error-Handling. Damit hat man die Möglichkeit bei einer korrekten Funktionsweise einer Funktion den berechneten Wert und im Falle eines Fehlers einen Fehler zurückzugeben.

    Beispiel
    package main
    
    import (
        "fmt"
        "errors"
    )
    
    func divide(a, b float64) (float64, error) {
        if b == 0 {
            return 0, errors.New("division by zero error")
        }
    
        return a / b, nil
    }
    
    func main() {
        res, err := divide(10, 2)
        if err != nil {
            fmt.Println("Fehler:", err)
            return
        }
    
        fmt.Println("Resultat:", res)
    }
    Resultat: 5

    Konvention:

    • Ein Fehler wird immer als letzter Rückgabewert zurückgegeben
    • Bei Erfolg: return <result>, nil
    • Bei Fehler: return <zero-value>, <error>

    Blank Identifier für ungenutzte Rückgabewerte

    Manchmal benötigt man für die weitere Verarbeitung nicht alle Rückgabewerte einer Funktion, wenn diese mehrere Werte zurückgibt. In diesem Fall wird der sogenannte Blank Identifier, geschrieben als _ verwendet.

    Wichtig: Wenn man einen Rückgabewert normal (als Variable) definiert aber nicht verwendet, gibt es einen Fehler während der Kompilierung.

    Beispiel
    package main
    
    import (
        "fmt"
        "errors"
    )   
    
    func divide(a, b float64) (float64, error) {
        if b == 0 {
            return 0, errors.New("division by zero error")
        }
    
        return a / b, nil
    }
    
    func main() {
        // Nur auf Fehler prüfen, kein Resultat speichern
        _, err := divide(10, 0)
        if err != nil {
            fmt.Println("Fehler:", err)
        }
    }
    Fehler: division by zero error

    Benannte Rückgabewerte (named return values)

    Go erlaubt es, den Rückgabewerten Namen zu geben. In diesem Fall kann man einfach das Schlüsselwort return angeben, ohne spezifisch die Variablennamen dahinter zu schreiben. Diese werden automatisch zurückgegeben.

    Beispiel
    func split(sum int) (x, y int) {
        x = sum * 4 / 9
        y = sum - x
        return
    }

    Was genau passiert hier?

    • x und y sind benannte Rückgabewerte
    • Sie werden automatisch als Variablen im Funktionsscope definiert
    • Sie haben den Zero Value ihres Typs (hier: 0)
    • return ohne Argumente (Naked Return) gibt sie zurück

    Im Grunde ist das obere Beispiel ein Äquivalent zu folgendem Code.

    func split(sum int) (int, int) {
        x := sum * 4 / 9
        y := sum - x
        return x, y
    }

    Named Return und defer

    Named Returns können durchaus mit Verwendung von defer nützlich sein, da defer den Return Value (Rückgabewert) modifizieren kann.

    Beispiel
    package main
    
    import "fmt"
    
    func increment() (result int) {
        defer func() {
            result++
        }()
    
        return 5
    }
    
    func main() {
        fmt.Println(increment())
    }

    Variadic Functions - Variable Argumentenanzahl

    Variadic Functions sind Funktionen, die eine variable Anzahl von Argumenten akzeptieren. Das bekannteste Beispiel ist fmt.Println.

    Die ... Syntax

    Variadic Parameter muss an der letzten Position in der Parameterliste stehen und wird mit ... vor dem Typ angegeben. Mit Hilfe von ... werden mehrere Werte, welche beim Aufruf der Funktion übergeben werden, in einer Variable innerhalb der Funktion gebündelt.

    Beispiel
    package main
    
    import "fmt"
    
    func sum(nums ...int) int {
        total := 0
        for _, n := range nums {
            total += n
        }
        return total
    }
    
    func main() {
        fmt.Println(sum())
        fmt.Println(sum(1))
        fmt.Println(sum(1, 2, 3))
        fmt.Println(sum(1, 2, 3, 4, 5, 6, 7))
    }

    Wie funktioniert es intern?

    Der Compiler konvertiert die Argumente in ein Slice.

    sum(1, 2, 3)
    
    // Wird intern zu
    sum([]int{1, 2, 3})

    Daher ist nums im oberen Beispiel innerhalb der Funktion ein normales Slice.


    Regeln für Variadic Parameters

    1. Nur ein Variadic Parameter

    Es kann nur ein Variadic Parameter pro Funktion geben.

    2. Letzte Position

    Der Variadic Parameter muss der letzte in der Parameterliste sein.

    Beispiel
    // ✅ Korrekt
    func log(level string, messages ...string) {}
    
    // ❌ Fehler - Position
    func log(messages ...string, level string) {}
    
    // ❌ Fehler - Anzahl
    func log(messages ...string, errors ...error) {}

    Slice entpacken mit ...

    Wenn wir ein existierendes Slice an eine Variadic Funktion übergeben, müssen wir dafür sorgen, dass wir einzlene Werte übergeben bzw. korrekte Werte.

    Schauen wir uns ein Beispiel an.

    Beispiel
    package main
    
    import "fmt"
    
    func sum(nums ...int) int {
        total := 0
        for _, n := range nums {
            total += n
        }
        return total
    }
    
    func main() {
        numbers := []int{1, 2, 3, 4}
    
        // Mit ... entpacken
        result := sum(numbers...)
    
        // Ohne ... => ❌ Fehler
        // result := sum(numbers)
    }

    Was passiert beim entpacken?

    sum(numbers...)
    
    // Ist äquivalent zu
    sum(numbers[0], numbers[1], numbers[2], numbers[3])

    Funktionen als First-Class Citizens

    In Go sind Funktionen First-Class Citizens. Das bedeutet, sie können wie jeder andere Wert behandelt werden.

    Funktionen können:

    • Einer Variable zugewiesen werden
    • Als Argument übergeben werden
    • Als Rückgabewert verwendet werden
    • In Datenstrukturen gespeichert werden

    Diese Eigenschaft ermöglicht funktionale Programmierpatterns in Go.

    Funktionen als Variablen

    Eine Funktion kann einer Variable zugewiesen werden. Wichtig ist dabei, dass die Funktionssignatur mit dem Typ der Variable übereinstimmen.

    Beispiel
    package main
    
    import "fmt"
    
    func add(a, b int) int {
        return a + b
    }
    
    func main() {
        // Funktion einer Variable zuweisen
        var operation func(int, int) int
        operation = add
    
        res := operation(5, 3)
        fmt.Println(res)
    }
    8

    Was ist hier wichtig?

    • operation ist eine Variable vom Typ func(int, int) int
    • operation = add weist die Funktion add der Variable zu
    • operation(5, 3) ruft die Funktion über die Variable auf

    Function Types

    Der Typ einer Funktion wird in Go durch ihre Signatur bestimmt. Wenn wir das Beispiel von oben nochmals verwenden, so würde die Typ-Anatomie wie folgt aussehen:

    func(int, int) int
    |    |     |    |
    |    |     |    +-- Rueckgabetyp
    |    |     +------- Zweiter Parameter
    |    +------------- Erster Parameter
    +------------------ Function Keyword

    Schauen wir uns ein Beispiel an.

    Beispiel
    package main
    
    import "fmt"
    
    // Diese Funktionen haben alle denselben Typ
    func add(a, b int) int { return a + b }
    func multiply(a, b int) int { return a * b }
    func subtract(a, b int) int { return a - b }
    
    // Typ-Alias für Lesbarkeit
    type BinaryOperation func(int, int) int
    
    func main() {
        var op BinaryOperation
    
        op = add
        fmt.Println(op(5, 3))
    
        op = multiply
        fmt.Println(op(5, 3))
    
        op = subtract
        fmt.Println(op(5, 3))
    }
    8
    15
    2

    Wie wir in diesem Beispiel sehen, können wir unserer op Variable jeweils eine andere Funktion zuweisen, bevor wir sie ausführen. Das ist dank der identischen Signatur der Funktionen möglich.


    Funktionen als Parameter (Higher-Order Functions)

    Funktionen können als Argumente an andere Funktionen übergeben werden.

    Beispiel
    package main
    
    import "fmt"
    
    func apply(nums []int, operation func(int) int) []int {
        res := make([]int, len(nums))
        for i, n := range nums {
            res[i] = operation(n)
        }
    
        return res
    }
    
    func double(x int) int {
        return x * 2
    }
    
    func square(x int) int {
        return x * x
    }
    
    func main() {
    
        nums := []int{1, 2, 3, 4, 5}
    
        doubled := apply(nums, double)
        fmt.Println(doubled)
    
        squared := apply(nums, square)
        fmt.Println(squared)
    
    }
    [2 4 6 8 10]
    [1 4 9 16 25]

    Funktionen als Rückgabewerte

    In Go ist es ebenfalls möglich eine Funktion als Rückgabewert einer anderen Funktion zu haben. Hierbei sei das Stichwort Closure erwähnt.

    Beispiel
    package main
    
    import "fmt"
    
    func makeMultiplier(factor int) func(int) int {
        return func(x int) int {
            return x * factor
        }
    }
    
    func main() {
        double := makeMultiplier(2)
        triple := makeMultiplier(3)
    
        fmt.Println(double(5))
        fmt.Println(triple(5))
    }
    10
    15

    Was passiert hier?

    • Die Funktion makeMultiplier gibt eine neue Funktion zurück
    • Diese neue Funktion “erinnert” sich an factor
    • Die Funktionen double und triple sind unterschiedliche Funktionen mit unterschiedlichen factor Werten

    Funktionen in Datenstrukturen

    Funktionen können in Slices, Maps und Structs gespeichert werden.

    In einem Slice

    Beispiel - Slice
    package main
    
    import "fmt"
    
    // Operation ist vom Typ: func(int, int) int
    type Operation func(int, int) int
    
    func main() {
        operations := []Operation{
            func(a, b int) int { return a + b },
            func(a, b int) int { return a - b },
            func(a, b int) int { return a * b },
            func(a, b int) int { return a / b },
        }
    
        for i, op := range operations {
            fmt.Printf("Operation %d: %d\n", i, op(10, 5))
        }
    }
    Operation 0: 15
    Operation 1: 5
    Operation 2: 50
    Operation 3: 2

    In einer Map

    Beispiel - Map
    package main
    
    import "fmt"
    
    func main() {
        operations := map[string]func(int, int) int {
            "add": func(a, b int) int { return a + b },
            "subtract": func(a, b int) int { return a - b },
            "multiply": func(a, b int) int { return a * b },
            "divide": func(a, b int) int { return a / b },
        }
    
        result := operations["multiply"](6, 7)
        fmt.Println(result)
    }
    42

    In einem Struct

    Beispiel - Struct
    package main
    
    import "fmt"
    
    type Calculator struct {
        Add func(int, int) int
        Subtract func(int, int) int
    }
    
    func main() {
        calc := Calculator{
            Add: func(a, b int) int { return a + b },
            Subtract: func(a, b int) int { return a - b },
        }
    
        fmt.Println(calc.Add(10, 5))
        fmt.Println(calc.Subtract(10, 5))
    }
    15
    5

    Strategy Pattern

    Ein klassisches Design-Pattern mit Fist-Class Functions.

    Beispiel - Struct
    package main
    
    import "fmt"
    
    type PaymentStrategy func(amount float64) error
    
    func creditCardPayment(amount float64) error {
        fmt.Printf("Paying %.2f with credit card\n", amount)
        return nil
    }
    
    func paypalPayment(amount float64) error {
        fmt.Printf("Paying %.2f with PayPal\n", amount)
        return nil
    }
    
    func processPayment(amount float64, strategy PaymentStrategy) error {
        return strategy(amount)
    }
    
    func main() {
        processPayment(97.50, creditCardPayment)
        processPayment(75.25, paypalPayment)
    }
    Paying 97.50 with credit card
    Paying 75.25 with PayPal

    Was ist hier der Flow?

    1. Wir definieren einen Typ PaymentStrategy. Jede Funktion in unserem Code, welche die Signatur func(amount float64) error hat, entspricht automatisch diesem Typ.
    2. Wir erstellen zwei Funktionen, welche genau dieser Signatur entsprechen.
      • creditCardPayment(amount float64) error
      • paypalPayment(amount float64) error
    3. Wir erstellen eine Funktion, welche beliebige Payment-Arten verarbeiten kann, die der Signatur PaymentStrategy entsprechen. Diese Funktion nimmt den tatsächlichen amount Wert an und die verwendete Funktion, mit einer passenden Signatur.
    4. In unserer main Funktion rufen wir zwei mal die verarbeitende Funktion processPayment auf und übergeben die jeweilige Konfiguration.

    Callback Pattern

    Callbacks sind Funktionen, die an andere Funktionen übergeben werden, um später aufgerufen zu werden.

    Beispiel - Callback
    package main
    
    import (
        "fmt"
        "time"
    )
    
    type Callback func(result int)
    
    func asyncCompute(a, b int, callback Callback) {
        go func() {
            result := a * b
            callback(result)
        }()
    }
    
    func main() {
        asyncCompute(6, 7, func(result int) {
            fmt.Println("Result:", result)
        })
    
        // Kurz warten, damit die Goroutine fertig wird
        time.Sleep(100 * time.Millisecond)
    }
    Result: 42

    Method Values

    Ein Method Value ist eine Methode, die bereits an eine bestimmte Instanz gebunden ist. Bei diesen Methoden haben wir einen so genannten Receiver (Empfänger). Man kann eine grobe Analogie zu Klassen-Methoden hier aufbauen.

    Beispiel
    package main
    
    import "fmt"
    
    type Calculator struct {
        value float64
    }
    
    func (c Calculator) Add(x float64) float64 {
        return c.value + x
    }
    
    func main() {
        calc := Calculator{value: 30}
    
        // Method value: Bereits an calc gebunden
        addFunc := calc.Add
    
        // Aufruf ohne erneute Angabe des Empfängers
        res := addFunc(5)
        fmt.Println(res)
    }

    Schauen wir uns noch ein Beispiel an.

    Beispiel
    package main
    
    import "fmt"
    
    type Counter struct {
        count int
    }
    
    func (c *Counter) Increment() {
        c.count++
    }
    
    func (c *Counter) Decrement() {
        c.count--
    }
    
    func main() {
        counter := &Counter{}
    
        // Method value - Funktion mit gebundenem Receiver
        increment := counter.Increment
        increment()
        increment()
    
        fmt.Println(counter.count)
    }
    2

    Method Expressions

    Ein Method Expression ist eine Methode, die nocht nicht an eine Instanz gebunden ist. Es erfordert beim Aufruf die explizite Angabe des Empfängers (Receivers) als ersten Parameter.

    Beispiel
    package main
    
    import "fmt"
    
    type Calculator struct {
        value float64
    }
    
    func (c *Calculator) Add(x float64) float64 {
        return c.value + x
    }
    
    func main() {
    
        // Method Expression (Signatur)
        var addFunc func(*Calculator, float64) float64
    
        // Zuweisung der Method Expression
        addFunc = Calculator.Add
    
        calc1 := &Calculator{value: 10}
        calc2 := &Calculator{value: 100}
    
        res1 := addFunc(calc1, 5)
        res2 := addFunc(calc2, 5)
    
        fmt.Println(res1, res2)
    }
    15 105

    Was genau passiert hier?

    • Es gibt einen Typ Calculator, welcher eine Eigenschaft value hat.
    • Wir erzeugen eine Receiver-Funktion func(c *Calculator) Add(x float64) float64 {}
    • Diese Funktion erwartet einen Pointer auf einen Wert vom Typ Calculator.
    • Weiter unten erstellen wir eine Variable addFunc. Diese Variable hat folgenden Typ: func(*Calculator, float64) float64. Zunächst ist dies einfach eine lose Variable (ungebunden).
    • Es erfolgt eine Zuweisung der addFunc zu (*Calculator).Add Funktion. Wichtig ist hier, dass es ein Pointer ist ((*Calculator))

    Nil Funktionen

    Funktionsvariablen können nil sein. Dies ist der Fall, wenn die nur deklariert, aber nicht initialisiert werden.

    Beispiel
    package main
    
    import "fmt"
    
    func main() {
        var f func(int) int
    
        if f == nil {
            fmt.Println("f ist nil")
        }
    }
    f is nil

    Es empfiehlt sich bei solchen Szenarien immer eine Funktion auf nil Wert zu prüfen, da sonst eine Panic ausgelöst wird.

    Beispiel
    package main
    
    import "fmt"
    
    func execute(f func() string) {
        if f != nil {
            res := f()
            fmt.Println(res)
        } else {
            fmt.Println("No function provided")
        }
    }
    
    func runnerFunc() string {
        return "Hello from function"
    }
    
    func main() {
        execute(runnerFunc)
    }
    Hello from func

    Anonyme Funktionen

    Anonyme Funktionen (auch Function Literals genannt) sind Funktionen ohne Namen. Sie werden inline definiert.

    Syntax

    func(parameter) returnType {
        // ...
    }

    Hier gibt es keine Namensangabe zwischen func und den Parametern. Zudem werden anonyme Funktionen oft direkt aufgerufen.

    Beispiel
    package main
    
    import "fmt"
    
    func main() {
        func() {
            fmt.Println("Hi aus anonymer Funktion")
        }()
    }
    Hi aus anonymer Funktion

    Mit Parametern

    Man kann auch eine anonyme Funktion parametrisieren.

    Beispiel
    package main
    
    import "fmt"
    
    func main() {
        func(name string) {
            fmt.Println("Hi,", name)
        }("Alice")
    }
    Hi, Alice

    Mit Rückgabewert

    Anonyme Funktionen können auch Rückgabewert haben.

    Beispiel
    package main
    
    import "fmt"
    
    func main() {
        res := func(a, b) int {
            return a + b
        }(5, 3)
    
        fmt.Println(res)
    }
    8

    Anonyme Funktionen einer Variable zuweisen

    Dies ist in der Tat sehr praktisch und kann nützlich sein.

    Beispiel
    package main
    
    import "fmt"
    
    func main() {
        greet := func(name string) {
            fmt.Println("Hi,", name)
        }
    
        greet("John")
        greet("Tom")
    }
    Hi, John
    Hi, Tom

    Use Case: Einmalige Logik

    Wenn eine Funktion nur an einer Stelle benötigt wird, kann der Einsatz von anonymen Funktionen ebenfalls sehr hilfreich sein.

    Beispiel
    package main
    
    import "fmt"
    
    func processData() {
        data := []int{1, 2, 3, 4, 5}
    
        // Anonyme Funktion für einmalige Transformation
        squared := func(nums []int) []int {
            res := make([]int, len(nums))
            for i, n := range nums {
                res[i] = n * n
            }
            return res
        }(data)
    
        fmt.Println(squared)
    }
    
    func main() {
        processData()
    }
    [1 4 9 16 25]

    Use Case: Goroutines

    Sehr oft werden anonyme Funktionen im Zusammenhang mit Goroutines verwendet. Das ist sehr praktisch, wenn man eine bestimmte Aktion als Goroutine ausführen will.

    Beispiel
    package main
    
    import (
        "fmt"
        "time"
    )
    
    func main() {
        go func() {
            fmt.Println("Running in goroutine")
        }()
    
        time.Sleep(100 * time.Millisecond)
    }
    Running in goroutine

    Closures - Funktionen mit Kontext

    Ein Closure ist eine Funktion, die auf Variablen aus ihrem umgebenden Scope zugreifen kann. In Go werden Closures vollständig unterstützt.

    Was ist ein Closure?

    Ein Closure ist im Grunde eine Funktion, die eine andere Funktion zurückgibt. Werfen wir einen Blick auf ein Beispiel, damit es klarer wird.

    Beispiel
    package main
    
    import "fmt"
    
    func makeCounter() func() int {
        count := 0
        return func() int {
            count++
            return count
        }
    }
    
    func main() {
        counter := makeCounter()
    
        fmt.Println(counter())
        fmt.Println(counter())
        fmt.Println(counter())
    }
    1
    2
    3

    Was passiert hier?

    1. makeCounter() erstellt eine lokale Variable count
    2. Die zurückgegebene anonyme Funktion “umschließt” count (ein Closure)
    3. Jeder Aufruf von counter() greift auf dieselbe count Variable zu
    4. count “überlebt” den Return von makeCounter()

    Wie funktioniert das?

    Normalerweise wird eine lokale Variable zerstört, wenn die Funktion endet. Bei Closures ist etwas anders.

    • Der Compiler erkennt, dass count (aus dem Beispiel drüber) von der zurückgegebenen Funktion verwendet wird
    • count wird daher auf dem Heap statt dem Stack alloziert
    • Die Closure speichert eine Referenz auf count
    • Solange die Closure existiert, bleibt count im Speicher

    Man spricht bei Closures auch über eine Art State-Management, weil bestimmte Elemente über die Rückgabe hinweg vorhanden sind.


    Mehrere Closures - geteilter State

    Im nachfolgenden Beispiel haben wir zwei Closures in einer Funktion, welche auf eine gemeinsame Variable zugreifen.

    Beispiel
    package main
    
    import "fmt"
    
    func makeCounter() (func() int, func() int) {
        count := 0
    
        increment := func() int {
            count++
            return count
        }
    
        decrement := func() int {
            count--
            return count
        }
    
        return increment, decrement
    }
    
    func main() {
        inc, dec := makeCounters()
    
        fmt.Println(inc())
        fmt.Println(inc())
        fmt.Println(dec())
        fmt.Println(dec())
    }
    1
    2
    1
    0

    Closure und defer

    Bei Closures die mit defer verwendet werden, werden die Variablen innerhalb der Closure-Funktion evaluiert. Das bedeutet, dass es ganz am Ende der Funktion geschieht.

    Beispiel
    package main
    
    import "fmt"
    
    func example() {
        x := 10
    
        defer func() {
            fmt.Println("x ist:", x)
        }()
    
        x = 20
    }
    
    func main() {
        example()
    }
    x ist: 20

    Im Gegensatz zu dieser Verwendung, bei welcher die Argumente sofort evaluiert.

    Beispiel
    package main
    
    import "fmt"
    
    func example() {
        x := 10
    
        defer fmt.Println("x ist:", x)
    
        x = 20
    }
    
    func main() {
        example()
    }
    x ist: 10

    Defer - Verzögerte Ausführung

    defer ist ein einzigartiges Feature in Go, das die Ausführung einer Funktion oder eines Statements bis zum Ende der umgebenden Funktion verzögert.

    Grundkonzept

    Im folgenden Beispiel sind zwei Ausgabe-Statements definiert. Eines davon wird sofort ausgeführt. Das andere (mit defer) wird erst am Ende der Funktion ausgeführt.

    Beispiel
    package main
    
    import "fmt"
    
    func example() {
        defer fmt.Println("Go")
        fmt.Println("Hello")
    }
    
    func main() {
        example()
    }
    Hello
    Go

    Wann wird defer ausgeführt? Deferred Funktionen werden ausgeführt:

    • Nach dem return Statement (aber bevor die Funktion tatsächlich endet)
    • Immer - auch bei panic
    • In LIFO Reihenfolge (Last In, First Out)

    Verwendung: Resource Cleanup

    Der häufige Anwendungsfall ist das Schließen von Ressourcen wie Dateien oder Streams.

    Beispiel
    func readFile(filename string) (string, error) {
        file, err := os.Open(filename)
        if err != nil {
            return "", err
        }
    
        // Wird IMMER ausgeführt
        defer file.Close()
    
        data, err := io.ReadAll(file)
        if err != nil {
            return "", err
        }
    
        return string(data), nil
    }

    Vorteile sind dabei:

    • file.Close() steht direkt nach os.Open() - leicht erkennbar
    • Wird garantiert ausgeführt, egal welchen Pfad die Funktion nimmt
    • Kein vergessenes Cleanup in Error-Pfaden

    Ausführungsreihenfolge: LIFO (Stack)

    Mehrere defers werden in umgekehrter Reihenfolge ausgeführt.

    Beispiel
    package main
    
    import "fmt"
    
    func example() {
        defer fmt.Println("1")
        defer fmt.Println("2")
        defer fmt.Println("3")
    
        fmt.Println("Function body")
    }
    
    func main() {
        example()
    }
    Function body
    3
    2
    1

    Hier noch ein Beispiel mit mehr Bezug zu Realität.

    func process() {
        db, _ := openDatabase()
        defer db.Close()
    
        file, _ := os.Open("data.txt")
        defer file.Close()
    
        conn, _ := net.Dial("tcp", "server:8080")
        defer conn.Close()
    
        // ...
    }

    Cleanup-Reihenfolge:

    1. conn.Close() (zuletzt geöffnet, zuerst geschlossen)
    2. file.Close()
    3. db.Close() (zuerst geöffnet, zuletzt geschlossen)

    Defer kann Rückgabewerte modifizieren

    Mit Named Returns kann defer Rückgabewerte modifizieren.

    Beispiel
    package main
    
    import "fmt"
    
    func increment() (result int) {
        defer func(
            result++
        ) {}()
    
        return 5
    }
    
    func main() {
        fmt.Println(increment())
    }
    6

    Defer in Loops - Performance Problem

    Bei Verwendung von defer in Schleifen kann es zum Problem kommen. Da alle defer(s) erst am Ende ausgeführt werden, kann es zu Überlastung/Memory-Leak oder File-Limit kommen.

    Beispiel - Problem
    func processFiles(files []string) error {
        for _, filename := range files {
            file, err := os.Open(filename)
            if err != nil {
                return err
            }
            defer file.Close() // ❌ Problem
        }
    
        // Process file
    
        return nil
    }

    Bei 1000 Dateien bleiben 1000 File-Handles offen. Welche Maßnahmen können hier unternommen bzw. welche Lösungen können hier verwendet werden?

    Lösung 1: Explizites Close

    Wir können die Dateien direkt schließen, ohne defer.

    Lösung 1
    func processFiles(files []string) error {
        for _, filename := range files {
            file, err := os.Open(filename)
            if err != nil {
                return err
            }
            // Process file
    
            file.Close()
        }
    }

    Lösung 2 - Separate Funktion

    Wir können auch die Datei-Verarbeitung vollständig in eine eigene Funktion auslagern. Das empfiehlt sich auch.

    Lösung 2
    func processFile(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
            return err
        }
        defer file.Close() // Jede Datei wird geschlossen
    
        // Process file ...
        return nil
    }
    
    func processFiles(files []string) error {
        for _, filename := range files {
            if err := processFile(filename); err != nil {
                return err
            }
        }
    
        return nil
    }

    Defer und Panic

    Deferred Funktionen werden auch bei Panic ausgeführt.

    Beispiel
    package main
    
    import "fmt"
    
    func example() {
        defer fmt.Println("This always runs")
        panic("Some panic here")
        fmt.Println("This never runs")
    }
    This always runs
    panic: Some panic here
    
    ... Rest der Panic-Meldung