Die Syntax von Go in Beispielen

In diesem Artikel wird die Syntax von Go anhand von Beispielen erläutert.

Hello World

Das beliebte “Hello World” Beispiel sieht in Go so aus:

// hello.go

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

Das Programm kann direkt ausgeführt werden:

$ go run hello.go
Hello, World!

Dabei wird das Programm compiliert, in einem temporären Verzeichnis abgelegt und dort ausgeführt. Man kann es auch explizit bauen:

$ go build hello.go
$ ./hello
Hello, World!

Anhand dieses Programms sieht man einige Eigenschaften von Go:

Module

Ein Go-Programm kann in mehrere Module (Packages) unterteilt werden. Bei einem ausführbaren Programm (command) muss mindestens das Modul main vorhanden sein. Eine Bibliothek besteht i.d.R. aus einem Modul, kann aber auch mehrere (interne) Module beinhalten.

Innerhalb eines Moduls werden Variablen, Konstanten, Funktionen, Typen, Methoden und Interfaces definiert. Nur wenn deren Name mit einem Großbuchstaben beginnt, sind sie “exportiert”, d.h. von außen (in anderen Modulen, die dieses Modul importieren) sichtbar. Ansonsten können die Bezeichner nur innerhalb des Moduls verwendet werden.

Andere Module werden mit import "Modul" eingebunden. Es dürfen nur Module eingebunden werden, die auch tatsächlich benutzt werden. Ein zyklisches Einbinden ist nicht erlaubt; wenn also Package A ein Package B importiert, darf B weder direkt noch indirekt A importieren.

Ein Modul kann aus einer oder mehrerer Dateien bestehen. Üblicherweise befinden sich diese im selben Verzeichnis, und der Name des Verzeichnisses entspricht dem Modulnamen.

Module werden mit vollem Pfadnamen importiert, z.B. import "net/http". Bei der Benutzung der Funktion wird nur der Modulname angegeben, z.B. http.ListenAndServe(). Bei Mehrdeutigkeiten ist es möglich, den Namespace lokal umzuschreiben, z.B. durch import rnd crypto/rand. Die Funktionen der Bibliothek sind nun mit rnd.Name() erreichbar.

Skalare Datentypen

Variablen

// Beispiele für Deklarationen

var a int
var b int8 = 1
var c = int64(2)

Der Deklaration einer Variablen wird das Schlüsselwort var vorangestellt. Der Typ steht hinter der Variablen. Optional kann eine Initialisierung (Zuweisung eines Wertes) erfolgen. Der Typ kann weggelassen werden, wenn er aus der Initialisierung bereits hervor geht.

Bei einer Deklaration ohne Initialisierung wird die Variable automatisch mit einem Defaultwert initialisiert. Dies ist, je nach Typ, die Null, der leere String, der logische Wert false oder der Nullpointer nil. Es ist erlaubt, eine Variable zu nutzen, ohne ihr vorher (explizit) einen Wert zugewiesen zu haben.

// nur in Funktionen erlaubt:

d := 3   // entspricht: var d int = 3

Innerhalb von Funktionen gibt es eine weitere Variante der Variablendeklaration, bei der der Typ und das Schlüsselwort var weggelassen werden können. Statt des Zeichens = wird := benutzt. Der Typ der Variable ergibt sich aus dem Typ des Ausdrucks rechts des := Operators.

Eingebaute (skalare) Datentypen in Go sind:

Konstanten

// Konstanten

const Pi float64 = 3.14159265358979323846
const zero = 0.0
const x, y, z = 5, 17.3, "foo"
const ti int = 42                // typisierte Konstante

Konstanten werden syntaktisch analog zu Variablen deklariert. Auf der rechten Seite darf nur ein konstanter Ausdruck stehen. Konstanten sind können typisiert (typed) oder typenlos (untyped) sein. Typenlose Konstanten nehmen erst im Kontext ihrer Verwendung einen konkreten Typ an.

Eingebaute Konstanten in Go sind

Das folgende Beispiel vereint mehrere syntaktische Besonderheiten. Es definiert die Konstanten Sonntag, Montag, …, Samstag zu den Zahlenwerten 0 bis 6 sowie anzahlTage auf 7.

const (
    Sonntag = iota
    Montag
    Dienstag
    Mittwoch
    Donnerstag
    Freitag
    Samstag
    anzahlTage   // nicht extern sichtbar
)

Zeiger

var a int
var b *int // Zeiger auf int

a = 15
b = &a  // b ist ein Zeiger auf a
*b = 20 // a ist nun 20

Ein Zeiger (pointer) ist eine Variable, die eine Referenz auf eine andere Variable enthält. Im obigen Beispiel ist b ein Zeiger auf die Variable a. Zur Deklaration des Zeigertyps und zur Dereferenzierung (Auswertung des Objekts, auf das ein Zeiger zeigt), dienst der * Operator. Die Adresse einer Variablen erhält man mit dem & Operator.

Go besitzt zwar Zeiger, aber keine Zeigerarithmetik; es ist also nicht möglich, durch Rechenoperationen einen Zeiger auf andere Objekte zu erhalten.

func foo() {
    x := new(int) // Zeiger auf anonymes int
    *x = 23
} // int wird durch Garbabe Collector gelöscht

Mit der eingebauten Funktion new() wird ein neues, anonymes Objekt des angegebenen Typs erstellt und ein Zeiger darauf zurück gegeben. Es gibt keinen Variablennamen zu diesem Objekt, es ist nur über den Zeiger erreichbar. Anders als z.B. in C++ können Objekte nicht explizit gelöscht werden. Ein Go Programm erkennt automatisch, wenn es auf ein Objekt keine Referenz mehr gibt; das Objekt wird dann durch die Garbage Collection gelöscht.

Ein Zeiger in Go kann somit nur entweder nil sein oder auf ein gültiges Objekt zeigen. Die Dereferenzierung eines nil Zeigers führt zu einer Laufzeit-Fehlermeldung.

Funktionen

func mult(a, b int64) int64 {
    return a * b
}

func add(a, b int64) (c int64) {
    c=a+b
    return
}

func main() {
    x := mult(3, 8)
    y := add(x, 10)
    fmt.Println(x, y)
}

Funktionen werden mit dem Schlüsselwort func eingeleitet. Danach folgt der Funktionsname, in Klammern eine Liste der Parameter sowie den Typ des Rückgabewertes. Wenn der Rückgabewert auch namentlich benannt wird oder es mehrere Rückgabewerte gibt, müssen diese ebenfalls in Klammern geschrieben werden.

// mehrere Rückgabewerte

func foo() (int, error) {
    x := ...
    return x, nil
}

func main() {
    d, err := foo()
    if err != nil {
        panic(err)
}
    ...
}

Das angegebene Beispiel zeigt die gängige Methode der Fehlerbehandlung in Go. Wenn die Funktion fehlerfrei ausgeführt wird, werden das Ergebnis und nil als Fehler zurückgegeben; ansonsten hat das Ergebnis einen neutralen Wert und die Fehlervariable ist mit einem Wert besetzt, der zum Typ error (ein Interface, dazu mehr später) passt.

// anonyme Funktion

func main() {
    k := func (i, j int) int {
             return i * j
         } (14, 9)
    ...
}

Funktionen können anonym, d.h. ohne Funktionsnamen, verwendet werden. Im Beispiel wird eine Funktion definiert und sogleich mit Parametern aufgerufen. Abgesehen vom fehlenden Namen erfolgt die Definition genauso wie oben beschrieben.

// Variadische Funktionen

func summe(s ...float64) float64 {
    // arbeitet mit []s
}

x := summe(1.5, 3, -4.5)

// auch erlaubt:
a := []float64{1.5, 3, -4.5}
y := summe(a...)

Funktionen können eine variable Anzahl von Parametern haben, wobei diese vom gleichen Typ sein müssen. Innerhalb der Funktion sind die Werte in einem Slice verfügbar (dazu später mehr). Umgekehrt kann ein Slice genutzt werden, um Parameter an eine Funktion mit variadischer Parameterliste zu übergeben.

// Verzögerte Ausführung

func foo() {
    lock(l)
    defer unlock(l)
    ...
    // unlock geschieht hier
}

Mit dem Schlüsselwort defer kann die Ausführung einer Anweisung an das Ende einer Funktion verschoben werden. Somit lassen sich zusammengehörige Operationen (z.B. lock/unlock oder open/close) optisch zusammen schreiben, obwohl sie im Kontrollfluß weit auseinander liegen. defer ist insbesondere praktisch, wenn mehrere Dinge geöffnet werden (z.B. Netzwerkverbindung, File, Datenbankverbindung) und bei jedem davon Fehler auftreten können.

Kontrollstrukturen

for-Schleife

// for-Schleife

summe := 0
for i := 1; i <= 10; i++ {
     summe += i
}

// while Schleife

for ; i <= 10; { ... }
// oder
for i <= 10 { ... }

// Endlosschleife

for { ... }

Die for-Schleife setzt sich, wie bei vielen anderen Sprachen auch, aus einer Initialisierung, einer Endbedingung und einem Increment zusammen; die drei Teile werden durch Semikolon getrennt. Indem man den ersten und dritten Teil weg lässt, wird daraus eine while-Schleife. In diesem Fall können auch die beiden Semikolon entfallen. Eine for-Schleife ohne Endbedingung ist eine Endlosschleife.

Innerhalb der Schleife springt continue zum nächsten Schleifendurchlauf, break verlässt die Schleife. Bei verschachtelten Schleifen können Labels gesetzt werden, so dass mit break auch die äußere Schleife verlassen werden kann.

if-Abfrage

// if

if a < 0 { a = -a } 

// if-else

if x := foo(); x < A {
    return A
} else if x > B {
    return B
} else {
    return x
}

Es gibt keine eigenständige elsif Anweisung. Statt dessen kann else if geschachtelt werden, geschweifte Klammern hinter else können entfallen.

switch/case Abfrage

// switch

switch m {
    default: foo()
    case 0, 2, 4, 6: bar()
    case 1, 3, 5, 7: biz()
}

// Switch ohne Variable
switch {
    case a < b: foo()
    case a > c: bar()
    case b == d: biz()
}

Bei der switch-Anweisung werden die Fälle (case) von oben nach unten abgearbeitet. Die Position der default-Bedingung ist egal; sie wird in jedem Fall nur angesprungen, wenn sonst keiner der Fälle zutrifft.

Nach der Ausführung der Anweisungen hinter einem Fall wird die Abarbeitung beendet. Es ist kein separater Ausstieg (wie z.B. break in C) nötig. Mit fallthrough kann direkt in den nächsten Fall gesprungen werden, auch wenn die Bedingung dort nicht zutrifft.

Eine Besonderheit ist die switch-Anweisung ohne Variable. Hier können hinter den einzelnen case Statements vollständige Vergleiche stehen, die nicht notwendigerweise disjunkt sein müssen. Eigentlich ist das also eine kürzere Schreibweise für if Abfragen.

Zusammengesetzte Datentypen

Verbundtypen

Eigene Datentypen werden mit type definiert. Zur Definition eines Verbundtyps dient struct.

// Verbundtyp

type Person struct {
    ID int 
    Vorname string 
    Nachname string 
} 

// Deklarationen
a := Person{1, "Harald", "Weidner"} 

b := Person{                  // ID bleibt 0
    Vorname: "Erika",
    Nachname: "Mustermann",
}

fmt.Println(a, b)

// ergibt:
// {1 Harald Weidner} {0 Erika Mustermann}

Eine Variable von einem Verbundtyp kann direkt initialisiert werden. Dazu wird die Schreibweise Person{...} genutzt. Innerhalb der geschweiften Klammern werden entweder alle Felder in der Reihenfolge der Typdefinition belegt, oder es wird jeweils der Feldname und -inhalt angegeben. Im letzteren Fall können Felder weggelassen werden; sie werden mit dem Defaultwert des jeweiligen Typs gefüllt (Null, Leerstring, nil oder false).

Methoden

Für Verbundtypen können Methoden definiert werden.

// Methoden

func (p Person) FullName() string {
    return p.Vorname + " " + p.Nachname
}

fmt.Println(a.FullName())
// ergibt: Harald Weidner

Eine Methode ist eine Funktion, die für einen bestimmten Typ definiert wird. Der Typ steht links des Funktionsnamens in Klammern. Aufgerufen wird die Methode mit der Syntax Variable.Methode().

// Magische Methoden ;-)

func (p Person) String() string {
    return fmt.Sprintf("[%d] %v %v", p.ID, p.Vorname, p.Nachname)
}

fmt.Println(b)
// ergibt:
// [0] Erika Mustermann

Manche Methoden haben eine spezielle Bedeutung für Funktionen der Go Standardbibliothek. Ein Beispiel hierfür ist String(). Wenn diese Methode existiert und einen Rückgabewert vom Typ string hat, dann wird sie von den Ausgabefunktionen wie fmt.Println() benutzt, um den Inhalt der Variablen auszugeben.

Interfaces

// Interface

type FullNamer interface {
    FullName() string
}

var x, y FullNamer

x = Person{23, "Hagbard", "Celine"}   // erlaubt
y = int(3)                            // Fehler, int erfüllt interface nicht

Ein Interface besteht aus einer Liste von Methoden, die ein Typ besitzen muss, um das Interface zu erfüllen (“implementieren”). Ob ein Typ ein Interface erfüllt, wird nicht explizit angegeben, sondern ergibt sich implizit aus der Definition des Typs und seiner Methoden.

Das Interface wird selber als benutzerdefinierter Typ definiert. Es kann also Variablen vom Typ des Interfaces geben. Auf diese Weise wird Polymorphie in Go realisiert.

Ob ein Typ ein bestimmtes Interface erfüllt, kann zur Laufzeit geprüft werden. Von dieser Möglichkeit macht die Go Standardbibliothek häufig Gebrauch.

Es ist eine Konvention, dass ein Interface mit nur einer Methode den Namen der Methode mit angehängtem “-er” bekommt. Ein Interface, dessen einzige Funktion String() heißt, erhält also den Namen Stringer.

Das leere Interface interface{} wird von allen Typen erfüllt. Einer Variable vom Typ interface{} können also Variablen jedes beliebigen anderen Typs zugewiesen werden. So lassen sich Funktionen definieren, die mit beliebigen Argumenten umgehen können. Bekanntestes Beispiel dafür sie die Ausgabefunktionen wie fmt.Println(), der man Variablen und Konstanten aller Typen übergeben kann. Mittels der Reflection API prüft die Funktion, welchen konkreten Typ ein Argument hat, und verhält sich entsprechend. Das geschieht bei Bedarf auch rekursiv, z.B. bei Verbundtypen oder Arrays.

Array

// Arrays

var a [5]int64                  // Array mit 5 Feldern
var b = [3]int{1, 2, 3}         // entspricht [1, 2, 3]
var c = [...]int{1, 7, 4, 8, 3} // automatische Längenerkennung

Arrays sind statisch, d.h. in der Länge nicht änderbar. Die Inhalte können natürlich geändert werden.

Slice

// Slices

var s []int                   // uninitialisierter Slice
var t = make([]int32, 5)      // Slice der Länge 5
var r = make([]int32, 5, 10)  // Slice der Länge 5 und Kapazität 10

s = c[1:4]                    // s ist [7, 4, 8]

r = append(r, 9)              // Anhängen eines Elementes, len(r) ist jetzt 6
t = append(t, 1)              // Kapazitätserweiterung nötig, t wird umkopiert

Ein Slice ist eine Referenz auf ein (anonymes) Array oder einen Ausschnitt eines Arrays. Ein Slice besitzt eine Länge und eine Kapazität, die gleich oder größer der Länge ist. Die Länge kann verkleinert oder bis zum Erreichen der Kapazität vergrößert werden.

Die eingebaute Funktion append() hängt Elemente an einen Slice an. Wenn die Kapazitätsgrenze erreicht ist, erzeugt sie dabei einen neuen, doppelt so großen Slice und kopiert die Elemente um.

Die eingebaute Funktion copy() kopiert einen Slice in einen anderen. Bei der Zuweisung eines Slices zu einem anderen findet dagegen keine Kopie statt; beide Slices referenzieren anschließend die selben Daten.

Map

// Map

var Size map[string]float32

Size["Alice"] = 1.67
Size["Bob"] = 1.82 

age := map[Person]uint {
    Person{1, "Harald", "Weidner"}: 43,
    Person{2, "Erika", "Mustermann"}: 29,
}

for k, v := range(age) {
    fmt.Println(k, „=>“, v)
}

Eine Map ist ein assoziatives Array, vergleichbar mit dem Hash in Perl. Bei der Definition einer Map werden Schlüssel und Werte-Typ angegeben. Für den Schlüsseltyp muss es dabei eine Vergleichsfunktion (= bzw. !=) geben, damit entschieden werden kann, ob ein Schlüssel bereits vorhanden ist oder nicht. Ansonsten können beide Typen beliebig gewählt werden.

Die eingebaute Funktion range() kann genutzt werden, um über alle Elemente einer Map zu iterieren. Sie kann auch für Arrays, Slices oder Strings verwendet werden.

Nebenläufige Programmierung

Goroutine

// Goroutine

import (
    "fmt"
    "time"
)

func foo() {
    fmt.Println("foo")
}

func bar() {
    fmt.Println("bar")
}

func main() {
    go foo()
    go bar()
    time.Sleep(time.Second)
}

Goroutinen sind nebenläufige Funktionen. Eine Goroutine wie eine Funktion mit dem vorangestellten Schlüsselwort go aufgerufen. Sie terminiert am Ende der Funktion, spätestens aber mit dem Ende des Hauptprogrammes. Um den Effekt zu zeigen wartet in obigem Beispiel das Hauptprogramm eine Sekunde.

Ohne weitere Vorkehrung laufen alle Goroutinen zusammen mit dem Hauptprogramm im selben Thread. Das Scheduling wird von der Go Runtime übernommen. Dadurch sind Goroutinen sehr effizient. Es ist problemlos möglich, mehrere Hundertausend Goroutinen nebenläufig zu betreiben.

import "runtime"

func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    ...
}

Um die Kapazitäten von Mehrkernprozessoren auszunutzen, muss die Runtime angewiesen werden, Goroutinen auf mehrere Threads zu verteilen. Dazu kann die Funktion runtime.GOMAXPROCS verwendet werden. Alternativ ist es möglich, die Environment-Variable GOMAXPROCS zu setzen. Im Beispiel werden so viele Threads verwendet, die das System CPU-Cores hat.

Channel

// Channel

func fib(chan c) {
    var f0, f1 int = 0, 1
    for {
        c <- f0
        f0, f1 = f1, f0+f1
    }
}

func main() {
    c := make(chan int)
    go fib(c)
    for i:=0; i<25; i++ {
        fmt.Println(<- c)
    }
}

Ein Channel ist ein typisierter Transportkanal. Er kann bidirektional verwendet werden, um Daten des angegebenen Typs zu transportieren. Der Channel kann von allen Goroutinen und Funktionen verwendet werden, die ihn kennen. Typischerweise wird ein Channel im Hauptprogramm erzeugt und dann an die Goroutine als Argument übergeben.

// gepufferter Channel

c := make(chan int, 5)

Ein Channel kann gepuffert oder ungepuffert sein. Ein ungepufferter Channel blockiert den Schreiber, bis von ihm gelesen wird, oder den Leser, bis der Schreiber bereit zum Schreiben ist. Ein gepufferter Channel kann eine angegebene Anzahl von Variablen zwischenspeichern und somit als Pipeline genutzt werden.

// Prüfung auf geschlossenen Channel

val, ok := <- c
// ok == false wenn c geschlossen und leer

Ein Channel kann mit close() geschlossen werden. Beim Lesen kann geprüft werden, ob der Channel bereits geschlossen wurde.

// Multiplexing

var c1, c2 chan int
var i1, i2 int

select {
    case i1 = <- c1: { ... }
    case i2 = <- c2: { ... }
    default: { ... } // macht die Schleife nichtblockierend
}

Eine Goroutine kann mit mehreren Channels arbeiten. Mittels einer select Anweisung bedient sie immer den Channel, der zum Lesen/Schreiben bereit ist. Wenn mehrere Channel bereit sind, ist die Reihenfolge undefiniert. Wenn keiner der Channel bereit ist, wird, falls vorhanden, die default Anweisung ausgeführt; ansonsten blockiert die Anweisung, bis einer der Channels bereit wird.

// Goroutine kontrolliert beenden

package main

import (
    "fmt"
    "time"
)

func fib(c chan int) {
    var f0, f1 int = 0, 1
    for {
        select {
            case c <- f0: {
                f0, f1 = f1, f0 + f1
            }
            case <- c: {
                fmt.Println("fib() has ended")
                return
            }
        }
    }
}

func main() {
    c := make(chan int)
    go fib(c)
    for i:=0; i<20; i++ {
        fmt.Println(<- c)
    }
    c <- 0
    time.Sleep(time.Second)
}

In diesem Beispiel kann das Hauptprogramm die Goroutine kontrolliert beenden. Dazu wird ausgenutzt, dass ein Channel bidirektional verwendet werden kann. Die Goroutine schreibt Fibonacci-Zahlen in den Channel, falls er dazu bereit ist, oder terminiert, wenn der Channel zum Lesen bereit ist. Das Hauptprogramm liest einige Werte und schreibt schließlich eine Zahl hinein, wodurch die Terminierung ausgelöst wird.

Reflection

Reflection bezeichnet die Möglichkeit einer Programmiersprache, zur Laufzeit den Typ einer Variable zu ermitteln oder Eigenschaften des Typsystems zu prüfen.

Go bietet leistungsfähige Möglichkeiten der Reflection. Eine davon ist der Type Switch:

// Type switch
func foo(t interface{}) {
    switch t := t.(type) {
        default:
            fmt.Printf("unexpected type %T\n", t)
        case bool:
            fmt.Printf("boolean %t\n", t)
        case int:
            fmt.Printf("integer %d\n", t)
        case *bool:
            fmt.Printf("pointer to boolean %t\n", *t)
        case *int:
            fmt.Printf("pointer to integer %d\n", *t)
    }
}

Der Type Switch ist eine spezielle Form der switch - case Anweisung. Hier wird nicht der Inhalt, sondern der Typ einer Variablen zum Kriterium gemacht. Die einzelnen Fälle sind skalare Typen des Typsystems von Go.

Der Type Switch ist leistungsfähig, aber nicht hinreichend, um wirklich beliebige Datenstrukturen zu erfassen. Wenn z.B. auch Verbundtypen vorkommen können, muss zusätzlich auf andere Methoden aus der reflect Bibliothek zurückgegriffen werden.

// explizite Interface-Prüfung

type Stringer interface {
    String() string
}

func foo(a interface{}) {
    if _, ok := a.(Stringer); ok {
        fmt.Println("Methode String() vorhanden")
    } else {
        fmt.Println("Methode String() nicht vorhanden")
    }
}

Eine andere, häufig genutzte Reflection Funktion ist die Prüfung, ob ein Typ ein bestimmtes Interface erfüllt. Dazu kann die Schreibweise Variable.(Interface) verwendet werden. Wenn ok anschließend den Wert true hat, erfüllt der Typ das Interface, und die im Interface definierten Methoden können aufgerufen werden.