Ein Einstieg in die Programmiersprache Go

Teil 2

Der erste Teil des Go-Artikels hat sich auf Sprachfeatures wie Go-Routinen, Channels und Objektorientiertung konzentriert. Er zeigte, wie es den Go-Autoren gelungen ist, eine breit einsetzbare Sprache zu kreieren, die leicht erlernbar ist.

Der folgende Teil beschäftigt sich zunächst damit, wie Go die Fehlerbehandlung löst. Anschließend verlässt der Beitrag die Sprachenebene, um die Standardbibliothek, Cross-Compiling und Dependency-Management zu betrachten und einige Schwachpunkte der Sprache zu diskutieren.

Error-Handling in Go

Produktionsreife Programme bestehen oft zu einem großen Teil aus Fehlerbehandlung, unabhängig von der Programmiersprache. Go nutzt dazu Multivalue Returns, Early Returns und defer-Statements. Mit Multivalue Returns ist es leicht, einen Fehler in einer Funktion oder Methode zu signalisieren. Neben den eigentlichen Rückgabewerten gibt Go immer noch einen weiteren Wert zurück, der das Interface error implementiert. Ist er nil, gab es keinen Fehler bei der Ausführung der Funktion beziehungsweise Methode, und Rückgabewerte sind bedenkenlos einsetzbar. Sollte er jedoch nicht nil sein, müssen Entwickler den Fehler beheben und die Rückgabewerte als invalide betrachten.

Operationen auf Dateien sind typischerweise anfällig für allerlei Fehler – Dateien können zum Beispiel nicht vorhanden, zu öffnen oder beschreibbar sein. All das muss man bei der Programmierung berücksichtigen. In Go sieht das wie folgt aus:

// versuche eine Datei zu öffnen
// im Erfolgsfall ist file ein Dateihandle und err ist nil
file, err := os.OpenFile(filename, os.O_RDWR, 0755)
// prüfe, ob das Öffnen erfolgreich war
if err != nil {
    // das Öffnen war nicht erfolgreich
    // der normale Programmfluss kann nicht fortgesetzt werden
    return err
}
// das Öffnen war erfolgreich
// file kann genutzt werden, um zum Beispiel in die Datei zu schreiben
n, err := file.WriteString("Hello Go!")
// prüfe, ob das Schreiben erfolgreich war
if err != nil {
    return err
}
fmt.Println("Wrote", n, "bytes to file:", filename)
file.Close()

Obiges Listing hat noch ein schwerwiegendes Problem. Wenn beim Schreiben in die Datei ein Fehler auftritt, verlässt es die Funktion beziehungsweise Methode vorzeitig im if-Block (early return). Die Zeile, die die Datei schließt (file.Close()), wird nicht mehr erreicht und es kommt zu einem Ressourcen-Leak. Genau deshalb sind Early Returns in Sprachen ohne automatisches Speichermanagement und try-catch-finally-Konstrukte verpönt. In C nutzt man ein goto, um zum Ende der Funktion zu springen und dort die Ressourcen aufzuräumen und auch nur dort die Funktion zu verlassen (one-point return). In Java wäre die Fehlerbehandlung in einem try-catch-Block angesiedelt, während ein finally-Block die Ressourcen aufräumt. Ein Early Return wäre möglich, aber es ist umstritten, ob das der richtige Stil ist.

Idiomatische Early Returns

In Go sind Early Returns idiomatisch, das heißt, man benötigt einen Weg, um Ressourcen wieder aufzuräumen. Im Beispiel könnte das noch von Hand im if-Block passieren, aber sobald mehrere Ressourcen im Spiel sind, wird es schnell unübersichtlich. Deshalb gibt es in Go das defer-Statement. Es kann Funktionen oder Methoden nach einem return-Statement ausführen. Im Beispiel sieht das wie folgt aus:

// versuche, eine Datei zu öffnen
// im Erfolgsfall ist file ein Dateihandle und err ist nil
file, err := os.OpenFile(filename, os.O_RDWR, 0755)
// prüfe, ob das Öffnen erfolgreich war
if err != nil {
    // das Öffnen war nicht erfolgreich
    // der normale Programmfluss kann nicht fortgesetzt werden
    return
}
// das Öffnen war erfolgreich
// file kann genutzt werden, um zum Beispiel in die Datei zu schreiben
// Schließe die Datei beim Verlassen der Funktion
defer file.Close()
n, err := file.WriteString("Hello Go!")
// prüfe, ob das Schreiben erfolgreich war
if err != nil {
    return
}
fmt.Println("Wrote", n, "bytes to file:", filename)

Nach dem erfolgreichen Öffnen der Datei merkt defer file.Close() das Schließen der Datei vor. defer sorgt dafür, dass file.Close() beim Verlassen der Funktion mit return ausführt, egal mit welchem return man die Funktion verlässt. Sollten mehrere Aufräumaktionen notwendig sein, können Entwickler weitere defers benutzen. Beim Verlassen der Funktion erfolgt die Abarbeitung nach dem Prinzip Last-In-First-Out.

Im Gegensatz zu einer Fehlerbehandlung mit Exceptions ist die Fehlerbehandlung per Rückgabewert repetitiv. Nutzer können aber sofort sehen, wo ein Fehler auftreten kann, und es existiert keine Möglichkeit für einen versteckten Kontrollfluss, wie es bei Exceptions der Fall ist. Mit defer und Early Returns stehen darüber hinaus zusammenhängende Teile immer dicht beisammen und nicht über den Code verteilt. Im Beispiel sind das Öffnen, die eventuell benötigte Fehlerbehandlung und das Schließen der Datei direkt untereinander positioniert. Dadurch ist der Code insgesamt leicht lesbar.

Batterien inklusive

Der Erfolg einer Programmiersprache ist nicht nur von ihren Features abhängig. Maßgeblich beteiligt sind auch die verfügbaren Bibliotheken. Schließlich möchten Entwickler im Programmieralltag nicht jedes Rad neu erfinden. Go verfolgt, ähnlich wie Python, den Kurs der „Batteries included“. Das heißt, die Standardbibliothek bringt von Haus aus reichlich Funktionen mit und enthält unter anderem Packages für XML und JSON, HTML- und Text-Templates, über produktionsreife HTTP-Server/Client-Bibliotheken (inklusive HTTP 2.0) bis hin zur Kryptografie.

Gerade letzteres fehlt bei vielen Programmiersprachen in den Standardbibliotheken, weshalb Anwender häufig auf C-Bibliotheken wie OpenSSL oder libsodium zurückgreifen. Das wiederum verursacht Probleme beim Build und Deployment: Auf dem Build-System braucht es die passende Compiler-Infrastruktur. Bibliotheken und deren Abhängigkeiten müssen in den richtigen Versionen vorhanden sein, sowohl auf dem Build- als auch auf dem Deployment-System.

Es gibt außerdem ein reichhaltiges Ökosystem von Drittanbieter-Bibliotheken, sollte eine Funktion nicht in der Standardbibliothek vorhanden sein. Das Einbinden von C-Bibliotheken ist leicht und über das syscall-Package der Standardbibliothek können Entwickler direkt Systemaufrufe ausführen.

Deployment: schnelle Kompilierung, statische Binaries und cross-compiling

Laut Rob Pike, einem der Go-Väter, sind die ersten Ideen zu Go bei einem längeren Kompiliervorgang von C++ entstanden. Ziel war es, die Schmerzen mit C++ wie die langen Kompilierzeiten zu beseitigen.

Go kompiliert in der Tat schnell. Auch große Projekte lassen sich meist in wenigen Sekunden kompilieren. Dazu kommt, dass Go-Kompilate statisch gelinkte Binaries sind, die ohne weitere Abhängigkeiten lauffähig sind. Das mühsame und oft fragile Aufsetzen von Abhängigkeiten auf dem Deployment-Ziel entfällt. Ein erfreulicher Nebeneffekt ist das einfache Kompilieren für andere Betriebssysteme und Prozessorarchitekturen. Entwickler müssen nur die passenden Umgebungsvariablen setzen.

# Cross-compiling von Windows oder macOS für den Raspberry Pi:
$ GOOS=linux GOARCH=arm GOARM=6 go build -o example

Die unschönen Seiten von Go

Neben den vielen Vorteilen von Go gibt es einige Dinge, die nicht gut gelöst sind. Herauszustellen sind vor allem: fehlende generische Datentypen und das Dependency-Management.

Generische Datentypen erlauben parametrische Polymorphie. Das ist gerade bei statisch typisierten Programmiersprachen wünschenswert. Viele Algorithmen und Container-Datentypen müssen Entwickler nur einmal programmieren und dann über den Typ parametrisieren. Ansonsten müsste man sie für jeden Typ ausprogrammieren.

Alle ernstzunehmenden statisch typisierten Programmiersprachen der letzten Jahre unterstützen generische Datentypen – außer Go. Zwar sind die eingebauten Container-Datentypen generisch und über das Verwenden von Interface-Typen lässt sich Codeduplizierung vermeiden, trotzdem fühlt man sich oft in die Zeiten von Java vor Version 1.5 versetzt. Mit dem Unterschied, dass Go das leere Interface statt wie in Java den Typ Object nutzt. In den konkreten Typ wird dann per Cast manuell umgewandelt: ein aufwendiges und fehleranfälliges Verfahren.

Den Kritikpunkt, den viele oft und regelmäßig noch vor Version 1.0 artikulierten, soll Go 2 beheben. Ein Proposal für die Einführung generischer Datentypen liegt vor. Wann Go 2, das nicht mehr mit Go 1.x kompatibel sein wird, erscheint und wie die generischen Datentypen letztlich umgesetzt werden, steht indes noch nicht fest.

Fehlendes Dependency-Management

Der zweite große Kritikpunkt, der ebenfalls von Anfang an besteht, ist das fehlende Dependency-Management. Es ist zwar seit jeher möglich gewesen, Dependencies ganz einfach herunterzuladen, auch werden dabei Git und GitHub sowie andere Versionskontrollsysteme und Repository-Hoster unterstützt. Ein Beispiel:

$ go get github.com/golang/example/hello

Das Problem ist allerdings, dass es keine Möglichkeit gibt, eine bestimmte Version einer Abhängigkeit anzugeben. Go lädt immer die aktuelle Version. Inkompatibilitäten sind programmiert und reproduzierbare Builds unmöglich. Das Ergebnis ist abhängig davon, wann man die Dependency geladen hat. Bei einem großen Monorepo, das entsprechend gepflegt ist (wie bei Google), ist das selten ein Problem. Will man jedoch Bibliotheken von Drittanbietern nutzen, kann jede Änderung dort den eigenen Build kaputt machen.

Eine äußerst unzufrieden stellende Situation, die dazu geführt hat, dass eine Reihe von Dependency- beziehungweise Paketmanagern in der Go-Community entstanden sind – godep, glide, goom, goop, um nur einige zu nennen. Sie sind (meistens) inkompatibel zueinander, sodass sie nur weiterhelfen, wenn der gleiche Paketmanager alle Dependencies eines Projekts verwaltet.

Go ist seit Version 1.5 in der Lage, Dependencies aus einem speziellen vendor-Verzeichnis zu laden, das mit im Quelltextbaum liegt. Die dorthin kopierten Dependencies können Entwickler zusammen mit dem Projekt versionieren. Das löst zwar das Versionsproblem, ist aber wenig komfortabel und entspricht nicht einem zeitgemäßen Dependency-Management.

In der im letzten Jahr veröffentlichten Version 1.11 hat ein Modulsystem in Go Einzug gehalten. Module sind ein oder mehrere Packages, die ein gemeinsames Import-Präfix haben und nach Semantic Versioning versioniert werden. Go hat somit endlich ein modernes Dependency-Management. Bedauerlicherweise wurde es entwickelt, ohne die Community einzubeziehen, was dort für großen Unmut gesorgt hat, denn sie hatte viel Zeit und Energie in eine Lösung investiert. Es bleibt zu hoffen, dass das Modulsystem trotzdem angenommen wird und sich als Standard durchsetzt.

Go in der Praxis

Die Autoren konnten in den letzten Jahren reichlich Erfahrungen mit Go im professionellen Umfeld sammeln. Trotz – oder zum Teil gerade wegen – fehlender Spracheigenschaften, die zeitgemäße Sprachen wie Scala oder Rust mitbringen, ist Go heute häufig die Sprache der Wahl, um Probleme effizient zu lösen.

Durch die Schlichtheit der Sprache ist es nicht möglich, Probleme so elegant und ausdrucksstark wie in anderen Sprachen zu beseitigen. Stattdessen erinnert der Code Neueinsteiger eher an Spaghetticode. Vor allem die fehlende Fehlerbehandlung und die daraus resultierenden if err == nil-Blöcke wirken, vor allem am Anfang, abschreckend. Bei unbekanntem Code kann das jedoch ein großer Vorteil sein, da Go-Code in der Regel einfach nachvollziehbar ist.

Trotz des beschränkten Umfangs der Sprache gibt es eine Sammlung von Best-Practice-Regeln, die allgemein in der Community anerkannt sind. Dieses „Idiomatic Go“ prägten maßgeblich die Go-Autoren, und es ist anfangs zum Teil gewöhnungsbedürftig, da sie beispielsweise Map- und Filter-Funktionen als unnütz betrachten. Teil von Idiomatic Go ist auch das Formatierprogramm gofmt, das ein Quasi-Standard bei Go-Programmierern ist und den Code einheitlich formatiert. Diskussionen, ob die geschweifte Klammer in dieselbe oder in die nächste Zeil gehört, oder ob man mit Tabs oder Leerzeichen einrückt (bei Go sind es übrigens Tabs), braucht man somit nicht zu führen.

Die Kombination einer unkomplizierten Sprache mit einer umfangreichen Standardbibliothek erlaubt es, Applikationen oftmals ohne weitere Drittanbieter-Bibliotheken zu erstellen. Werden Bibliotheken eingesetzt, handelt es sich oft nur um API-Wrapper, die REST-Calls abstrahieren (zum Beispiel gegen die API von Kubernetes oder AWS). Bei der Standardbibliothek fällt auf, dass erfahrene Programmierer beteiligt waren, die Probleme pragmatisch lösen möchten.

Einsatz in Infrastrukturprojekten Der häufige Einsatz von Go in Infrastrukturprojekten hat mehrere gute Gründe. Zunächst sind Nebenläufigkeiten einfach durch Channels und Go-Routinen umsetzbar. Außerdem enthält die Standardbibliothek häufige Abhängigkeiten wie HTTP-Client, HTTP-Server oder JSON En- und Decoding. Da Go die Programme statisch kompiliert, reicht es, das Ergebnis einfach auf den Zielrechner zu kopieren. Installationen von Abhängigkeiten wie dynamische Bibliotheken, Gems oder einer Java-VM, um ein Programm starten zu können, entfallen. Entwickler können statisch kompilierte Go-Programme außerdem einfach als Docker-Images verpacken: Da man keine dynamischen Bibliotheken benötigt, besteht das Docker-Image nur aus dem Go-Binary.

Die Ressourcenanforderungen von Go-Programmen sind gering. In Systemen wie Kubernetes eignen sich Go-Programme daher hervorragend für kleine Sidecar-Umsetzungen, um beispielsweise eine Autorisierung zu implementieren, die mehrere Apps nutzen können.

In Projekten, bei denen Wahlfreiheit bezüglich Programmiersprachen bestand, haben die Autoren die Beobachtung gemacht, dass Go-Neulinge nicht müde werden, sich über die sprachlichen Unzulänglichkeiten zu beschweren, das nächste Projekt dann aber trotz allem in Go statt Scala oder Haskell zu implementieren, da es „für das vorliegende Problem gerade gut passt“.

Titelfoto von Steven Lelham auf Unsplash

Conclusion

Go ist keine Programmiersprache, die State-of-the-Art-Konzepte implementiert, und begeistert Sprachenthusiasten deshalb weniger. Pragmatiker dafür umso mehr: Features wurden so gewählt, dass die Sprache so klein und hantierbar wie C bleibt und gleichzeitig die Komplexität eines manuellen Speichermanagements unnötig macht. Nebenläufigkeit ist in die Sprache gut und leicht verständlich integriert und effizient implementiert, sodass sie auch den Herausforderungen einer Multi-Core-Welt gewachsen ist.

Trotz einiger Beschränkungen und Eigenwilligkeiten schafft es Go oft, seinem Anspruch gerecht zu werden, die Produktivität einer dynamischen Sprache mit der Sicherheit einer statischen Sprache zu verbinden. Anforderungen sind oft effizient umsetzbar.

Eine stetig wachsende Community und die Rückendeckung von Google sichert eine fortlaufende Weiterentwicklung: Sie addressiert ebenfalls die genannten Schwachpunkte. Wobei man sich fragt, warum ein, in typischer Go-Manier, wenig revolutionäres Dependency-Management so lange braucht. Die fehlenden generischen Datentypen sind da kniffliger, da eine Umsetzung konträr zu anderen Zielsetzungen ist: Einfachheit der Sprache, schnelle Kompilierzeiten, geringe Implementationskomplexität sowie Stabilität und Kompatibilität der Sprache. Der aktuelle Vorschlag zu Contracts zeigt, dass das nicht unmöglich ist.

In einer zeitgemäßen, polyglotten Microservices-Umgebung ist es unkompliziert, Go einmal auszuprobieren. Die flache Lernkurve und schnellen Turn-around-Zeiten von Go machen es einfach, einen Microservice oder zugehöriges Tooling probehalber mal in Go zu implementieren.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more