Stand: 2013-09-22

Pro und contra Go

Die von Google geschaffene Programmiersprache Go vereint die Vorteile statischer Typisierung mit der Leichtigkeit einer Skriptsprache, sie hat eingebaute Parallelisierung und wird mit einem kompletten Satz einfach zu bedienender Werkzeuge bereitgestellt. Bei so vielen positiven Aspekten war meine Neugier geweckt, aber in mehreren Wochen praktischer Versuche mit Go bin ich auch über einige störende Dinge gestolpert, die ich in diesem Artikel schildere.

Inhalt

Pro

Minimalismus

Im Gegensatz zu Sprachungetümen wie C++ hat Go einen sehr begrenzten Umfang an Sprachmitteln. Es gibt nur eine Schleife (for), es gibt keine Klassen und keine Vererbung, es gibt nur eine Form von Schnittstellen, es gibt keine generische Programmierung, es gibt keine Zeigerarithmetik, es gibt nur zwei Sichtbarkeitsebenen (private und public), es gibt keine Konstruktoren und Destruktoren und nur sehr eingeschränkte Reflection. Auch wenn diese Einschränkungen natürlich nicht nur Vorteile haben, führen sie dazu, dass die Sprache schnell zu lernen ist. Die begrenzten Sprachmittel führen unabhängig vom Autor auch zu einer gewissen Gleichförmigkeit des Quelltexts, was die spätere Wartung erleichert. Man könnte auch sagen, dass langweiliger Code langfristig gut ist. Go-Code enthält praktisch keine Überraschungen.

Statisches Duck-Typing

Schnittstellen werden in Go nicht explizit implementiert: wenn eine Komponente alle Methoden einer Schnittstelle enthält, erfüllt sie automatisch die Schnittstelle. Damit kann man auch Schnittstellen für Komponenten schreiben, die man nicht selbst entwickelt und deren Schnittstellendeklaration man deshalb auch nicht beeinflussen kann. Das führt zu sehr loser Kopplung. Ein weiterer Vorteil ist, dass dieses Prinzip tendenziell zu minimalen Schnittstellen führt, da die Schnittstellen aus Sicht des jeweiligen Nutzers definiert werden.

Wenn man zum Beispiel die folgende Schnittstelle verwendet, kann man als Implementierung alle Komponenten nutzen, die eine entsprechende Write-Methode haben, ohne dass diese Komponenten etwas von der Schnittstelle wissen müssten:

type Writer interface {
  Write(p []byte) (n int, err error)
}

Parallelisierung

Eine der herausragenden Eigenschaften von Go ist die eingebaute Parallelisierung durch Go-Routinen und Kommunikationskanäle (Channels). Die Eignung für massiv parallele Systeme mit vielen Prozessoren und Prozessorkernen war eines der zentralen Entwurfsziele von Go.

Aufgeräumte Syntax

Go-Code ist zwar nicht übermäßig kompakt, wirkt aber sehr aufgeräumt, wozu beispielsweise der Verzicht auf Semikolons am Zeilenende und runde Klammern bei if und for beiträgt:

for _, route := range router.routes {
  if route.pattern == nil { 
    continue
  }
}

Für lokale Variablen gibt es Typableitung, sodass der Quelltext nicht durch explizite Typangaben aufgebläht werden muss (aber werden kann):

b := 2 * a

Go erzwingt übrigens bestimmte Formatierungen wie geschweifte Klammern am Zeilenende. Darüber hinaus gibt es ein Formatierungswerkzeug, das für ein einheitliches Aussehen des Go-Codes sorgt - und tatsächlich sieht Go-Code aus verschiedenen Projekten immer ziemlich ähnlich aus.

Für Ordnung sorgt auch, dass nicht benötigte Variablen und importierte Pakete mit Compiler-Fehlern quittiert werden.

Praktisch sind auch "rohe" Zeichenketten, die keine Maskierungen enthalten können. Das erspart einem bei regulären Ausdrücken zahlreiche Schrägstriche. Beispiel: `\d+` statt "\\d+"

Rasend schneller Compiler

Aufgrund der reduzierten Sprachmittel kann der Compiler rasend schnell den Quelltext übersetzen. Dadurch eignet sich Go gut für Einsatzbereiche, die sonst eher Skriptsprachen vorbehalten sind.

Statisch gelinkte Binärdateien

Die Verwaltung von Bibliotheken in verschiedenen Versionen kann die Hölle sein. Bei Linux-Distributionen sieht man das Problem in Extremform, denn dort müssen Pakete oft für verschiene Distributionen und dann auch noch für alle möglichen Versionen davon erzeugt werden. Go erspart einem diesen Umstand und bindet alle abhängigen Bibliotheken in die erstellte Binärdatei ein. Das Deployment beschränkt sich also auf das Kopieren einer einzigen Datei!

Laufzeiteigenschaften

Zumindest in Mikrobenchmarks kommt Go der guten Performance von Java ziemlich nahe und sollte für die meisten Szenarien somit gut genug sein. In realen Szenarien wird durchgehend von deutlich geringerem Speicherbedarf verglichen mit Java berichtet. Auch bei meinen überschaubaren Programmen kann ich bestätigen, dass Go nur einen Bruchteil des Speichers vergleichbarer Java-Programme erfordert.

Integriertes Unit-Test-Framework

Unit-Tests gehören seit ein paar Jahren fest zu ernsthafter Softwareentwicklung und Go bringt praktischerweise gleich ein eigenes Framework dafür mit. Das ist schlicht, erfüllt seinen Zweck aber vollkommen. Es erspart einem Suche und Installation eines Frameworks und ermuntert so, Unit-Tests auch wirklich zu schreiben. Besonders gut gefällt mir, dass die Test-Dateien (mit der Endung _test.go) direkt neben den getesteten Dateien liegen. Dadurch sind die Dateien, die man bei Änderungen zusammen benötigt, auch tatsächlich zusammen.

Paketmanager

Ähnlich dem Unit-Test-Framework erleichtert auch der mitgelieferte Paketmanager das Leben sehr. Man gibt nur die URL einer externen Bibliothek ein und schon kann man sie in seinem Projekt verwenden. Verglichen mit ausgefeilteren Lösungen anderer Sprachen ist der Paketmanager allerdings primitiv und erlaubt z. B. keine Versionseinschränkungen.

Playground

Zum Ausprobieren im Browser und Austauschen von kleinen Go-Programmen gibt es den Go Playground, was ein extrem praktisches Mittel zum Lernen und Kommunizieren ist.

Contra

Keine generische Programmierung

Außer für die fest eingebauten Datentypen wie Maps bietet Go keinerlei Unterstützung für typsichere generische Programmierung an. Während man in Java, Scala oder C# allgemeingültige Datentypen wie diesen definieren kann:

// Scala
trait Stack[A] {
  def push[A](element: A): Unit
  def pop(): A
}

Implementierungen dieser Schnittstelle können mit beliebigen Datentypen benutzt werden, wobei der Compiler prüft, ob die Datentypen konsistent verwendet werden. Dadurch werden Laufzeitfehler ausgeschlossen.

In Go bleibt nur der Rückgriff auf die leere Schnittstelle, die per Definition immer erfüllt wird, und Typumwandlung zur Laufzeit:

package main

import "fmt"

type stackEntry struct {
    next  *stackEntry
    value interface{}
}
type stack struct {
    top *stackEntry
}

func (s *stack) Push(v interface{}) {
    var e stackEntry
    e.value = v
    e.next = s.top
    s.top = &e
}
func (s *stack) Pop() interface{} {
    if s.top == nil {
        return nil
    }
    v := s.top.value
    s.top = s.top.next
    return v
}

func main() {
    stack := &stack{}
    stack.Push("string")
    stack.Push(2)
    var x int = stack.Pop().(int)
    fmt.Println(x)
    var y string = stack.Pop().(string)
    fmt.Println(y)
}

Ausführbares Beispiel, in Anlehnung an die Implementierung aus dem Buch The Go Programming Language.

Der Stack im obigen Beispiel enthält also gleichzeitig einen int und einen string! Der Compiler weiß aber nichts über diese Typen und wenn man eine der Typzusicherungen (stack.Pop().(int)) mit einem anderen Typ versähe, würde das nicht zu einem Compiler-Fehler sondern zu einer "runtime panic" führen.

Eine gängige Umgehungslösung für die nicht vorhandene generische Programmierung ist die Implementierung für alle benötigten konkreten Datentypen, was für meinen Geschmack nicht besonders elegant ist.

Fast noch mehr stört mich an der fehlenden generischen Programmierung, dass sie kompakte und gut lesbare Programmkonstrukte wie im folgenden Scala-Beispiel verhindert:

scala> List(1, 2, 3, 4, 5).filter(_ > 3).map(_ * 2)
res1: List[Int] = List(8, 10)

Der Unterstrich im Beispiel steht für einen Parameter, dessen Namen einem egal ist, in dem Fall das jeweils aktuelle Element.

for-Schleifen als Go-typische Alternative dazu blähen den Code unnötig auf und lassen die eigentlich Absicht oft untergehen.

nil statt Option

Fehlende generische Programmierung macht ein Konstrukt wie Scalas Option, das die mögliche Abwesenheit von Werten explizit macht, schwer möglich. Stattdessen wird der Code mit Prüfungen auf nil durchzogen, die leicht vergessen werden können und dann zu Fehlern führen.

Wenig grundlegende Datenstrukturen

Java, Scala und C# bieten einen reichhaltigen Satz sogenannter "Collections" für alle möglichen Zwecke. Go beschränkt sich in diesem Bereich auf Arrays, Slices und Maps. Nicht mal ein Set gehört zur Standardbibliothek, man muss es mit einer zweckentfremdeten Map improvisieren.

Keine Methodenüberladung

Mehrere Varianten einer Methoden mit einem Namen aber unterschiedlichen Parameterlisten sind in Go nicht möglich. Das klingt erst einmal nach einer guten Idee, denn tendenziell enthalten Methodennamen so eher die Besonderheiten der Varianten. Andererseits führt das zu seltsamen Namen wie ToLowerSpecial. Immerhin hat das Verfahren den Vorteil, dass der Quelltext schneller übersetzt werden kann.

Unbefriedigende API-Dokumentation

In der API-Dokumentation sind immerhin alle Funktionen, Schnittstellen und Strukturen dokumentiert, aber der Informationsgehalt ist oft nicht zufriedenstellend. Der Rückgabetype [][][]byte der Methode func (re *Regexp) FindAllSubmatch(b []byte, n int) [][][]byte wird zum Beispiel nicht wirklich erklärt. Im Gegensatz zu etwa Java ist das Dokumentationsformat sehr simpel, was dazu führt, dass selten Methodenparameter und Rückgabewerte erklärt werden und Querverweise fehlen. Außerdem stört mich, dass es keinen einfachen Weg gibt, statische HTML-Dateien mit der Dokumentation zu erzeugen, um sie einfach auf einem Webserver ablegen zu können. Stattdessen startet Godoc einen Server, der die Dokumentation sozusagen live serviert.

Teilweise umständliche APIs

Das Paket für reguläre Ausdrücke bietet benannte Capturing Groups ((?P<name>re)), aber keine einfache Möglichkeit, über deren Namen auf die Treffer zuzugreifen. Will man einen Treffer anhand des Namens ermitteln, muss man mit der Methode SubexpNames die Namen der Capturing Groups herausfinden und anhand deren Position eine Zuordnung vom Namen zum eigentlichen Treffer herstellen. Für diese Funktionalität, die andere Sprachen schon mit der Standardbibliothek mitbringen, musste ich immerhin so viel Code schreiben:

package regexp

import "regexp"

// Custom type for extending regexp.Regexp.
type RichRegexp struct {
    regexp.Regexp
}

// Like regexp.Compile from the standard library, but returns *RichRegexp.
func Compile(expr string) (*RichRegexp, error) {
    re, error := regexp.Compile(expr)
    if error != nil {
        return nil, error
    }
    return &RichRegexp{*re}, error
}

// Like regexp.MustCompile from the standard library, but returns *RichRegexp.
func MustCompile(expr string) *RichRegexp {
    return &RichRegexp{*regexp.MustCompile(expr)}
}

// Like regexp.CompilePOSIX from the standard library, but returns *RichRegexp.
func CompilePOSIX(expr string) (*RichRegexp, error) {
    re, error := regexp.CompilePOSIX(expr)
    return &RichRegexp{*re}, error
}

// Like regexp.MustCompilePOSIX from the standard library, but returns *RichRegexp.
func MustCompilePOSIX(expr string) *RichRegexp {
    return &RichRegexp{*regexp.MustCompilePOSIX(expr)}
}

// Matches against the candidate and returns a Match struct or nil if the 
// candidate doesn't match.
func (re *RichRegexp) Match(candidate string) *Match {
    matches := re.FindStringSubmatch(candidate)
    if matches == nil {
        return nil
    }
    return re.newMatch(matches)
}

func (re *RichRegexp) newMatch(matches []string) *Match {
    groupMap := make(map[string]string)
    for i, name := range re.SubexpNames() {
        // ignore the whole match and unnamed groups
        if i == 0 || name == "" {
            continue
        }
        groupMap[name] = matches[i]
    }
    return &Match{matches, groupMap}
}

// 'Groups' contains all the strings captured by the capturing groups, the first
// element is the whole match. 'NamedGroups' contains only the strings captured by 
// named capturing groups, the names of the capturing groups are used as keys.
type Match struct {
    Groups      []string
    NamedGroups map[string]string
}

Beim Programmieren ging es mir immer wieder so, dass ich das Gefühl hatte, Dinge neu erfinden zu müssen, die z. B. bei Java einfach vorhanden sind.

Umständliches Mocking

Go erlaubt nicht, einen Mock zur Laufzeit aus einer Schnittstelle zu erzeugen und einzelne Methoden zu implementieren. Man muss deshalb immer eine eigene Mock-Implementierung ausprogrammieren. So werden Unittests lang und umständlich.

Kleines Ökosystem

Man kommt zwar auch mit einem Texteditor einigermaßen zurecht, aber eine gute Entwicklungsumgebung könnte die Produktivität erheblich steigern. Erste Ansätze gibt es, aber zufriedenstellend waren sie bei meinen Tests nicht. Dem geringen Alter ist wohl auch geschuldet, dass es noch nicht allzuviele Go-Bibliotheken gibt und man oft auf C-Bibliotheken zurückgreifen muss. Es ist aber sicherlich nur eine Frage der Zeit, bis die Entwicklungsumgebungen besser und die Bibliotheken zahlreicher werden.

Hakelige Unicode-Unterstützung

Obowhl einer der Sprachschöpfer auch an der Entwicklung von UTF-8 beteiligt war, macht die Arbeit mit Unicode in Go wenig Spaß. Statt eines "ü" gibt das folgende Beispielprogramm einen kaputten Buchstaben (�) zurück, weil Go Strings als Byte-Array betrachtet und ein Unicode-Buchstabe mehr als ein Byte haben kann.

package main

import "fmt"

func main() {
    fmt.Println("Süße"[1:2])
}

Ausführbares Beispiel

Nicht wiederholbare Builds

Der Werkzeugsatz, mit dem Go ausgeliefert wird, enthält auch einen Paketmanager, der fremde Bibliotheken aus dem Internet lädt. Es ist jedoch nicht möglich, ein fremdes Paket auf eine bestimmte Version oder einen Versionsbereich festzulegen, weshalb man nie sicher sein kann, welche Version einer Bibliothek tatsächlich verwendet wird.

Fazit

Der Minimalismus von Go hat schon seinen Charme. Der geringe Hauptspeicherbedarf von Go-Programmen und die eingebaute Parallelisierung machen die Sprache für bestimmte Einsatzgebiete gut geeignet. Go versucht, ein besseres C und eine gute Alternative zu C++ zu sein und erreicht dieses Ziel in vielen Szenarien meiner Meinung auch. Wer Java, Scala oder C# gewöhnt ist, dürfte sich mit Go eher nicht so schnell anfreunden. Und so kommen auch die meisten Entwickler von Skriptsprachen wie Python und Ruby zu Go. Verglichen mit Java war ich mit Go trotz einiger Wochen (freizeitmäßiger) Beschäftigung mit dieser Sprache deutlich unproduktiver. Ich ziehe bis auf weiteres Java und Scala vor, werde Go aber im Auge behalten.

Weitere Informationen