Ein Einstieg in die Programmiersprache Go

Teil 1

Go ist für eine Programmiersprache noch recht jung. Die Veröffentlichung der ersten stabilen Version 1.0 ist etwas mehr als sechs Jahre her. Seitdem hat Go rasant an Popularität gewonnen. Zweimal war sie „Programming Language of the Year“ bei TIOBE – 2009 und 2016. 2018 ist Go die „Most promising programming language“ im „The State of Developer Ecosystem“ von JetBrains. Vor allem bei systemnaher Infrastruktursoftware ist Go nicht mehr wegzudenken: Docker, Kubernetes, etcd, Consul, NATS, Packer, Prometheus, Traefik und Vault sind alle in Go geschrieben. Alles prominente Projekte, die heutzutage für den Betrieb einer zeitgemäßen (Cloud-)Infrastruktur unverzichtbar sind.

Auf den ersten Blick erscheint der Erfolg etwas merkwürdig. Denn Go sticht nicht gerade durch Innovationen hervor. Im Gegenteil: Go lässt viele Dinge vermissen, die bei einer Programmiersprache selbstverständlich scheinen: Exceptions, ein Typsystem mit Typhierarchie und generischen Typen, kovariante Funktions-, Methoden- und Rückgabeparameter oder einen Option-Typ zur Vermeidung des „billion-dollar mistake“ (Null reference). Trotz und manchmal auch gerade wegen der Einschränkungen, lassen sich mit Go etliche Aufgaben des Programmieralltags gut und schnell umsetzen.

Die Ursprünge von Go

Go entstand 2007 ursprünglich bei Google, ist aber seit 2009 – weit vor Version 1.0 – ein Open-Source-Projekt auf GitHub. Gründungsväter sind unter anderem die Bell-Labs-/UNIX-/C-Berühmtheiten Rob Pike und Ken Thompson. Der Google-Ursprung ist klar erkennbar. Go soll Herausforderungen stemmen, die vor allem Google hat: Skalierung auf allen Ebenen, auf viele Prozessoren beziehungsweise Cores, auf große Codebasen, auf große Entwicklerteams sowie auf viele und schnelle Deployments.

Die Handschrift der Gründungsväter ist ebenfalls unverkennbar: Große Ähnlichkeiten mit C, nicht nur auf der Syntaxebene, sondern auch beim Abstraktionsniveau und den Designzielen. Ebenso die zahlreichen Anleihen bei Plan9, einem Betriebssystem, das Pike und Thompson bei Bell Labs entwickelt hatten.

Der Artikel beschreibt Im Folgenden einige der hervorstechenden Merkmale von Go, die trotz des Verzichts auf Innovation Go von anderen Programmiersprachen abhebt.

Nebenläufigkeit in Go

Go hat Nebenläufigkeit beziehungsweise Concurrency auf Sprachebene umgesetzt und sich an Hoares Communicating Sequential Processes orientiert. Die beiden Sprachkonstrukte zur Nutzung von Nebenläufigkeit sind Go-Routinen und Channels.

Go-Routinen sind Funktionen oder Methoden, die nebenläufig ausgeführt werden – sie blockieren also nicht die Ausführung des aktuellen Codes so lange, bis die Funktion ihre Berechnungen beendet hat. In Go können Entwickler jede Funktion oder Methode als Go-Routine ausführen, indem sie sie mit dem Schlüsselwort go aufrufen:

// Funktionen werden mit dem Schlüsselwort func deklariert
func print(s string) {
  fmt.Println(s)
}

// Die Funktion main ist der Einstiegspunkt in jedes Go-Programm
func main() {
  print("one")
  // print("two") wird in einer Go-Routine ausgeführt
  go print("two")
  print("three")
  time.Sleep(1 * time.Second)
}

Der Code erzeugt folgende Ausgabe:

one
three
two

da der zweite Aufruf als Go-Routine nicht blockiert. Der Aufruf von time.Sleep am Ende ist notwendig, weil sich das Programm sonst vorm Ausführen beenden würde – ein Go-Programm wartet vor dem Beenden nicht automatisch, bis alle Go-Routinen beendet sind.

Ruft man Funktionen oder Methoden mit Rückgabewerten als Go-Routinen auf, gibt es keine Möglichkeit, an die Rückgabewerte zu gelangen. Aus anderen Sprachen bekannten Konzepte wie Promises oder Futures gibt es in Go nicht.

Channels

Die Strategie in Go lautet: „Don’t communicate by sharing memory; share memory by communicating“. Es sollen nicht mehrere nebenläufige Prozesse mit derselben globalen Variablen arbeiten, deren Zugriff zum Beispiel Mutexe oder Semaphoren steuern, sondern Prozesse sollen untereinander durch sogenannte Channels kommunizieren. Channels dienen zum Austausch der Referenzen auf Daten.

Entwickler können Channels wie normale Variablen nutzen und als Funktionsparameter übergeben oder als Rückgabewert zurückliefern. Die einfachste Form eines Channels, der ungepufferte Channel, ist eine Warteschlange nach dem FIFO-Prinzip (First In, First Out). Dabei ist zu beachten, dass das Schreiben in einem ungepufferten Channel so lange blockiert, bis jemand von ihm liest. Umgekehrt blockiert das Lesen eines Channels so lange, bis jemand in den Channel schreibt.

func greeting(channel chan string) {
  // schreibe "hello world" in den channel
  channel <- "hello world"
}

func main() {
  // erzeuge einen channel vom Typ string
  channel := make(chan string)
  go greeting(channel)

  var text string = <-channel
  fmt.Println(text)
}

Wie im Beispiel sichtbar, müssen Anwender Channels mit dem Schlüsselwort make(…) erstellen. Sie haben außerdem einen Typ, der hier string ist. Die Funktion greeting erhält einen Channel, in den sie einen String schreibt. Die Channel-Syntax kann man sich bildlich vorstellen, wenn man einen Channel wie eine Röhre darstellt:

Der Channel als Röhre (Abb. 1)
Der Channel als Röhre (Abb. 1)

Möchte man in einen Channel schreiben, zeigt der Pfeil vom Wert auf die Channel-Variable. Möchte man hingegen aus dem Channel lesen, zeigt der Pfeil von der Channel-Variable zu der Variable, die den Wert speichern soll.

Da Lese- und Schreiboperationen blockieren, muss das Programm greeting(channel) zwingend als Go-Routine aufrufen, damit es nicht in eine Deadlock-Situation läuft. channel <- „hello world“ würde bei einem synchronen Aufruf so lange blockieren, bis jemand vom Channel liest: Der Lesebefehl wäre nie zu erreichen. Channels verwenden Entwickler deshalb häufig in Kombination mit Go-Routinen.

Ein Channel kann mehrere Go-Routinen miteinander verbinden:

func producer(text chan string) {
    text <- "one"
    text <- "two"
    text <- "three"
    close(text)
}

func print(text chan string, done chan bool) {
    var s string
    // eine for-Schleife in Go braucht keine Klammern um den
    // Schleifenkopf mit Initialisierung, Test und Fortsetzung
    for ok := true; ok; s, ok = <-text {
        fmt.Println(s)
    }

    done <- true
}

func main() {
    channel := make(chan string)
    done := make(chan bool)

    go producer(channel)
    go print(channel, done)

    <-done
}

Das Beispiel übergibt beiden Go-Routinen denselben Channel, über den sie kommunizieren können. Außerdem schließt der Producer den Channel am Ende, was die Go-Routine print durch s, ok = <- text abfragen kann: Wenn der zweite Wert (ok) false zurückliefert, ist der Channel geschlossen. Die main-Methode blockiert so lange, bis man in den done-Channel schreibt – er dient nur zur Synchronisation. Der Wert im Channel interessiert nicht und ist keiner Variable zugeordnet.

Channels sind sicher für die parallele Verwendung. Es dürfen mehrere Go-Routinen in denselben Channel schreiben, ohne dass Daten verloren gehen.

func producer(text chan string, s string) {
  for i := 0; i < 10; i++ {
    text <- s
  }
}

func main() {
  channel := make(chan string)

  go producer(channel, "-")
  go producer(channel, "|")

  for i := 0; i < 20; i++ {
    fmt.Print(<-channel)
  }
}

Das gezeigte Programm erzeugt Ausgaben wie „||—–|||—–|||||“ oder „||-||||||||———“. Es gehen keine Zeichen verloren, aber die Reihenfolge ist zufällig, je nachdem welche Go-Routine zuerst einen Wert in den Channel schreiben kann.

Mit dem select-Statement ist es möglich, auf die erste Antwort von beliebig vielen Channels zu warten:

func print(c chan string, msg string) {
    time.Sleep(time.Duration(rand.Intn(50)))
    c <- "message: " + msg
}

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go print(c1, "one")
    go print(c2, "two")

    select {
    case s1 := <-c1: fmt.Println(s1)
    case s2 := <-c2: fmt.Println(s2)
    }
}
Das Beispiel gibt entweder *message: one* oder *message: two* aus, je nachdem welcher *print*-Aufruf zuerst seinen Channel beschreibt.

Gepufferte Channels

Die bisher gezeigten Channels hatten keinen Puffer, das heißt Lese- und Schreiboperationen sind genau abzustimmen, damit keine Deadlock-Situation entsteht. Gepufferte Channels können eine bestimmte Anzahl von Werten aufnehmen, bevor Schreiboperationen sie blockieren:

// erzeuge einen channel mit der Puffergröße 1
c := make(chan string, 1)
c <- "text"
fmt.Println(<-c)
funktioniert, da die Schreiboperation nicht blockiert, wohingegen
c := make(chan string, 0) // entspricht "make(chan string)"
c <- "text"
fmt.Println(<-c)         // wird nie erreicht

in eine Deadlock-Situation läuft.

Gepufferte Channels sind nützlich, um Optimierungen vorzunehmen oder Produzenten und Konsumenten des Channels zeitlich zu entkoppeln. Eine Go-Routine, die einen gepufferten Channel befüllt, braucht nicht auf das Lesen eines Ergebnisses zu warten, bevor potenziell langwierige Berechnungen für den nächsten Wert starten können. Das ist für verschiedene Problemstellungen nützlich oder sogar notwendig.

Objektorientierung ohne Klassen und Vererbung

Objektorientierung dient in der Softwareentwicklung häufig zur Strukturierung und Modellierung. Obwohl es in Go weder Klassen noch Vererbung gibt, muss man nicht auf typische objektorientierte Konzepte wie Datenkapselung oder Polymorphie verzichten.

Statt Methoden innerhalb von Klassen zu definieren, erfolgt das in Go analog zu Funktionen, aber mit einem zusätzlichen receiver-Parameter. Er folgt nach dem Schlüsselwort func und gibt den Datentyp an, auf dem die Methode definiert wird. Eine Methode muss zum gleichen Package gehören.

// Die Deklaration des Package erfolgt immer am Anfang
package staff

type Clerk struct {
    Name string
    Age int
    salary int
}

// Methodendefinition auf dem Typ *Clerk
func (clerk *Clerk) ChangeSalary(amount int) {
    clerk.salary += amount
}

c := Clerk{"Max Mustermann", 30, 40000}

// Mit der Punkt-Notation können dann die Methoden auf den
// jeweiligen Datentypen
// aufgerufen werden, genau wie auf die einzelnen Felder eines
// structs per Punkt-Notation zugegriffen wird.

c.ChangeSalary(100)

Im Beispiel ist die Methode auf einem Pointer vom Typ Clerk definiert anstatt direkt auf dem Struct Clerk. Das ist wichtig, denn die Methode modizifizert das clerk struct. Wäre die Methode direkt auf Clerk definiert, hätte sie eine Call-by-Value-Semantik. Sie operiert dann auf einer Kopie des receiver-Parameters und c.ChangeSalary(100) hätte keinen sichtbaren Effekt, weil nur die Kopie innerhalb der Methode modifiziert ist – c.salary wäre nach dem Methodenaufruf immer noch 40.000.

Methoden können Datentypen und die zugehörigen Operationen bündeln und lassen sich wie Objekte benutzen. Zur Datenkapselung gehört noch das Verstecken der Interna, das heißt im obigen Fall sollen die Felder des Clerk Structs nicht direkt zu modifizieren sein, sondern nur über Methoden. Dazu können Entwickler in Go die Sichtbarkeit von Typen, Funktionen, Methoden, Variablen und Konstanten festlegen.

Die Sichtbarkeit bezieht sich immer auf ein Package und ist durch die Groß-/Kleinschreibung festgelegt. Fangen Typen, Funktionen, Methoden, Variablen oder Konstanten mit einem Kleinbuchstaben an, sind sie nur innerhalb des Packages sichtbar. Fangen sie mit einem Großbuchstaben an, sind sie exportiert und außerhalb des Packages sichtbar. Zu welchem Package Typen, Funktionen, Methoden, Variablen und Konstanten gehören, legt die Package-Deklaration am Anfang der Quelltextdatei fest. Ein Package kann aus mehreren Dateien bestehen.

Bei Structs können Anwender die Sichtbarkeit jedes einzelnen Felds kontrollieren. Im Beispiel sind die Felder Name und Age auch außerhalb des Package staff sichtbar, das Feld salary nicht. Der Zugriff außerhalb von staff kann nur über die exportierte Methode ChangeSalary(amount int) erfolgen.

Komposition

Vererbung dient zum Wiederverwenden vorhandener Objektdefinitionen, meistens in der Form von Klassen. Entwickler können die Funktionen ergänzen und/oder modifizieren und müssen sie nicht neu implementieren. Als Alternative bietet sich Komposition an, die komplexe Objekte durch eine Kombination von einfachen Objekten bildet.

Go unterstützt Komposition über das sogenannte Embedding direkt. Dies bettet einen Typ in einen anderen Typ ein und dessen Methoden sind per Promotion direkt auf dem einbettenden Typ aufrufbar.

type Address struct {
    street string
    zipcode string
    city string
}

// der Receiver wird nicht modifiziert,
// daher wird kein Pointer benötigt
func (a Address) humanReadableAddress() string {
    return a.zipcode + a.city + a.street
}

type Clerk struct {
    // ein Feld ohne Bezeichner ist embedded
    Address
    Name string
    Age int
    salary int
}

a := Address{"Karl-Wiechert-Allee 10", "30625 ", "Hannover"}
c := Clerk{a, "Max Mustermann", 30, 4000}

// Die Methoden des eingebetteten Typs sind direkt auf dem
// einbettenden Typ aufrufbar
c.humanReadableAddress()
### Polymorphie

Go unterstützt Polymorphie bei Parametern und Rückgabewerten von Funktionen und Methoden sowie bei eingebetteten Datentypen mit Interfaces. Ein Interface ist eine definierte Menge von Methoden.

type PaidEmployee interface {
    ChangeSalary(amount int)
}

type Manager struct {
    salary int
}

func (m *Manager) ChangeSalary(amount int) {
    if amount < 0 {
            amount *= -1
    }
    m.salary += amount
}

Im Unterschied zu Programmiersprachen wie Java muss ein Typ nicht explizit deklarieren, dass er ein Interface implementiert. Es reicht, wenn alle Methoden des Interfaces implementiert sind. Im obigen Beispiel implementiert der Typ Clerk und der Typ Manager das Interface PaidEmployee. Interface-Typen entscheiden über die aufzurufende Methode erst zur Laufzeit (dynamic binding).

func RaiseSalaries(e []PaidEmployee) {
    for i := 0; i < len(e); i++ {
            // welche ChangeSalary Methode aufgerufen
            // wird, entscheidet sich erst zur Laufzeit
        e.ChangeSalary(-100)
    }
}

Interface-Typen lassen sich mit Embedding kombinieren, entweder um ein Interface aus mehreren Interface-Typen zusammen zu setzen oder um als Typ in einen anderen Typ eingebettet zu werden.

// Interface aus mehreren Interface-Typen zusammengesetzt
type Payroll interface {
    Address
    PaidEmployee
}

type worker struct {
    // Interface als eingebetteter Typ
    PaidEmployee
    Adress
}

Eine Besonderheit stellt das leere [i]interface{} dar. Es enthält keine Methoden: Jeder Typ implementiert es. Nützlich ist es vor allem, wenn die Struktur eines Typs beim Kompilieren noch nicht bekannt ist, beispielsweise beim Encoding oder Decoding von XML oder JSON.

Mit der Kombination von Methoden, Komposition und Interface-Typen lässt sich genausogut objektorientiert programmieren wie mit Klassen und Vererbung. Die Unterschiede sind eher syntaktischer Natur. Methoden müssen im Quelltext nicht mehr zusammen mit ihren Typen definiert werden, wie es bei Klassen der Fall ist. Das hat den Vorteil, dass Entwickler einem Typ nachträglich Methoden hinzufügen können, ohne zwingend den Quelltext des Typs verändern zu müssen.

Ähnlich verhält es sich mit Interface-Typen. Nutzer können sie auch nachträglich einführen, ohne den Quelltext des implementierenden Typs verändern zu müssen, weil eine explizite Deklaration nicht nötig ist. Gerade bei größeren Entwicklungsteams und einer sich schnell ändernden Codebasis ist das von Vorteil, da die Vorgehensweise viele Merges und somit Merge-Konflikte vermeiden kann.

Titelfoto von Juan Di Nella auf Unsplash

Fazit

Go orientiert sich sprachlich von der Komplexität eher an C als an C++, ohne jedoch auf die Möglichkeit objektorientierter Programmierung zu verzichten, die bei Go eher an Structs mit Funktions-Pointern als an Klassen mit Konstruktoren und Mehrfachvererbung erinnert. Die Lernkurve ist für Neueinsteiger deshalb wesentlich flacher und die Zeit bis zum ersten produktiv einsetzbaren Code kürzer.

Auf neue Konzepte verzichtet die Programmiersprache, sofern sie nicht absolut nötig sind. Nebenläufigkeit als solch ein kritisches Feature zu sehen ergibt Sinn, da Anwendungen die Fortschritte zeitgemäßer Mehrkern-Prozessoren nur gut nutzen können, wenn Code nebenläufig ausgeführt wird. Go-Routinen und Channels sind als Konzepte einfach verständlich und daher schnell erlernbar.

Mit der Strategie, Go schlank zu halten und neue Konzepte konservativ einzuführen, erreichen die Go-Autoren ihr definiertes Ziel ziemlich gut: Go leicht erlernbar und vielseitig einsetzbar machen.

Der nächste Teil der Artikelserie wird sich dem Error-Handling und der Standardbibliothek zuwenden und einige Konzepte von Go kritisch beurteilen, bevor ein Gesamtfazit gezogen wird.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen