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:
Der Code erzeugt folgende Ausgabe:
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.
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:
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:
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.
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:
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:
funktioniert, da die Schreiboperation nicht blockiert, wohingegen
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.
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.
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.
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).
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.
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