Blog

Go statt C#?

Lohnt sich als C#-Entwicklerin oder -Entwickler ein Blick auf Go?

Jan 7, 2020

Es hat sich herumgesprochen, dass wichtige Plattformen wie Docker und Kubernetes mit der Programmiersprache Go geschrieben sind. Kein Wunder, dass viele Entwicklerinnen und Entwickler neugierig geworden sind. In der Stack Overflow Survey 2019 landete Go auf der Liste der populärsten Entwicklungstechnologien auf Platz 13 und damit vor namhaften Sprachen wie Swift, Kotlin oder Dart. In der Rangliste der beliebtesten Sprachen erreicht Go Platz 9 und liegt damit sogar knapp vor C#.

Ist es also an der Zeit, C# an den Nagel zu hängen und sich neu zu orientieren? Nein, Go ist kein C#-Killer. Go ist aber auf jeden Fall eine interessante Alternative zu C# auf dem Server. In diesem Artikel werfe ich einen Blick auf Go aus meiner Sicht als Entwickler, der seit Jahren C# nutzt und gern hat.

 

ZUM NEWSLETTER

Regelmäßig News zur Konferenz und der .NET-Community

 

Wie lernt man Go?

Eine komplette Einführung in Go ist in einem Magazinartikel unmöglich, das soll erst gar nicht versucht werden. Dieser Artikel soll grundlegende Unterschiede und Gemeinsamkeiten zwischen C# und Go aufzeigen. Außerdem soll er durch ein paar Codebeispiele einen Eindruck davon vermitteln, wie sich Go anfühlt. Wer neugierig geworden ist und mehr wissen möchte, sollte einen Blick auf Go by Example werfen. Dort wird Go anhand vieler kleiner Beispiele erklärt. Wer noch tiefer einsteigen möchte, dem empfehle ich die Go-Dokumentation, zum erfolgreichen Starten insbesondere den Artikel Effective Go und die FAQ-Liste.

Natürlich stellt sich für Personen, die in Go einsteigen, auch die Frage nach der Entwicklungsumgebung. Wenn man von C# kommt, liegt Visual Studio Code mit der Go Extension auf der Hand. Viele .NET-Entwicklungsteams schätzen auch die Tools aus dem Hause JetBrains. Mit GoLand hat die Firma eine IDE für Go im Angebot, die im Gegensatz zu Visual Studio Code kostenpflichtig ist. Für Open-Source-Projekte, Schüler, Lehrer, User Groups etc. ist GoLand aber kostenlos.

Grundphilosophie: Code einfach halten

Entwicklungsteams mit C#-Hintergrund wird beim ersten Blick auf Go auffallen, wie reduziert die Sprache und auch das Tooling im Vergleich zu C# sind. So stehen beispielsweise den in C# über hundert Schlüsselwörtern in Go gerade einmal 25 gegenüber. Dieser erste Eindruck täuscht nicht. Die Sprache und den damit erstellten Code einfach und einheitlich zu halten, ist ein wichtiges Designziel von Go. Die Dokumentation des Speichermodells von Go beginnt beispielsweise mit nur drei Zeilen und bevor im Anschluss alle Details erklärt werden, folgt der Hinweis: „If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don’t be clever.“ Go kommt mit einem Tool zum Codeformatieren, das auch Visual Studio Code verwendet. Es hat genau keine Settings. Die Community erwartet, dass jeder Go-Code gleich aussieht, egal woher er kommt. „The output of the current gofmt is what your code should look like, no ifs or buts“. Willkommen in der Denkweise von Go.

Man kann einwenden, dass die Einfachheit daher kommt, dass Go jünger als C# ist. Go kam Ende 2009 heraus, die erste Version von C# sieben Jahr davor. Das ist aber nicht der alleinige Grund. Die relativ gemächliche Weiterentwicklung (nach zehn Jahren ist die Version 2 der Sprache gerade erst in der Designphase), der Prozess des ausführlichen Abwägens von Vorteilen gegenüber hinzugefügter Komplexität bei Spracherweiterungen, die Kultur der offenen Diskussion bei neuen Sprachfunktionen, all das unterstreicht, dass Stabilität und Einfachheit Designprinzipien der Sprache Go sind. Der Vorteil, den Entwicklungsteams davon haben, ist, dass Go, entsprechende generelle Informatikkenntnisse vorausgesetzt, leicht zu erlernen, und Go-Code relativ leicht zu lesen und zu warten ist. In Zeiten von Open Source und Microservices sind das wichtige Eigenschaften einer Sprache, da häufig viele verschiedene Personen im Lauf der Zeit mit dem Code einer Softwarelösung zu tun haben.

Limits

Die Kehrseite der Medaille ist, dass Go im Vergleich zu C# viele Funktionen fehlen. Keine Klassen und Vererbung? Keine Generics? Fehlerbehandlung ohne try-catch-Blöcke? Das lässt einen erst einmal den Kopf schütteln, wenn man von C# kommt. Manche dieser Dinge (z. B. Klassen, Vererbung) gibt es in Go bewusst nicht. Die Sprache hat andere Lösungsansätze für die zugrunde liegenden Herausforderungen. Listing 1 stellt einige dieser Ansätze an einem kleinen kommentierten Codebeispiel vor. Andere Dinge (z. B. Generics) sind in Go schlicht und einfach noch nicht fertig. Das sieht man aber nicht als Katastrophe, im Gegenteil. Es gibt Designs und experimentelle Implementierungen, die über Jahre diskutiert, ausprobiert und oft auch wieder verworfen werden. Lieber ein paar Jahre ein paar zusätzliche Zeilen Code schreiben als eine schlechte Lösung übers Knie brechen.

Listing 1: Struct und Interface

// Play with this code snippet online at https://play.golang.org/p/37kbcROPsZz

package main

import (
  "fmt"
  "math"
)

// Let us define two simple structs. In contrast to C#, there are no classes.
// Everything is a struct. Note that this does not say anything about allocation
// on stack or heap. The compiler will decide for you whether to allocate an
// instance of a struct on the stack or heap based on *escape analysis*
// See also https://en.wikipedia.org/wiki/Escape_analysis.

// ... and yes, dear C# developer, you can believe your eyes:
// No semicolons at the end of lines in Go ;-)

type Point struct {
  X, Y float64
}

type Rect struct {
  LeftUpper, RightLower Point
}

type Circle struct {
  Center Point
  Radius float64
}

// Now let us add some functions to our structs

func (r Rect) Width() float64 {
  return r.RightLower.X - r.LeftUpper.X
}

func (r Rect) Height() float64 {
  return r.RightLower.Y - r.LeftUpper.Y
}

func (r Rect) Area() float64 {
  return float64(r.Width() * r.Height())
}

func (r *Rect) Enlarge(factor float64) {
  // Note that this function has a *pointer receiver type*.
  // That means that it can manipulate the content of r and
  // the caller will see the changed values. The other methods
  // of rect have a *value receiver type*, i.e. the struct
  // is copied and changes to its values are not visible
  // to the caller.

  r.RightLower.X = r.LeftUpper.X + r.Width()*factor
  r.RightLower.Y = r.LeftUpper.Y + r.Height()*factor
}

func (c Circle) Area() float64 {
  return math.Pi * c.Radius * c.Radius
}

// Next, we define an interface. Note that the structs do not need to
// explicitly implement the interface. rect and circle fulfill the
// requirements of the interface (i.e. they have an area() method),
// therefore the implement the interface.

type Shape interface {
  Area() float64
}

const (
  WHITE int = 0xFFFFFF
  RED   int = 0xFF0000
  GREEN int = 0x00FF00
  BLUE  int = 0x0000FF
  BLACK int = 0x000000
)

// Go does not support inheritance of structs/classes. However, it
// supports embedding types. The following struct embeds Circle. Because of
// *member set promotion*, all members of Circle become available on
// ColoredCircle, too.

type ColoredCircle struct {
  Circle
  Color int
}

func (c ColoredCircle) GetColor() int {
  return c.Color
}

type Colored interface {
  GetColor() int
}

func main() {
  // Note the declare-and-assign syntax in the next line. The compiler
  // automatically determines the type of r, no need to specify it explicitly.
  r := Rect{LeftUpper: Point{X: 0, Y: 0}, RightLower: Point{X: 10, Y: 10}}
  c := Circle{Center: Point{X: 5, Y: 5}, Radius: 5}

  // Note that we can access the Radius of the ColoredCircle although Radius
  // is a member of the embedded type Circle.
  cc := ColoredCircle{c, RED}
  fmt.Printf("Colored circle has radius %f\n", cc.Radius)

  // Next, we create an array of shapes. As you can see, rect
  // and circle are compatible with the shape interface
  shapes := []Shape{r, c, cc}

  // Note the use of range in the for loop. In contrast to C#, the for-range
  // loop provides the value and an index.
  for ix, shape := range shapes {
    fmt.Printf("Area of shape %d (%T) is %f\n", ix, shape, shape.Area())

    // Note the syntax of the if statement in Go. You can write
    // declare-and-assign and boolean expression in a single line.
    // Very convenient once you got used to it.
    
    // Additionally note how we check whether shape is compatible
    // with the Colored interface. You get back a variable with the
    // correct type and a bool indicator indicating if the cast was ok.
    if colCirc, ok := shape.(Colored); ok {
      fmt.Printf("\thas color %x\n", colCirc.GetColor())
    }
  }

  r.Enlarge(2)
  fmt.Printf("Rectangle's area after enlarging it is %f\n", r.Area())
}

Einfaches Deployment

Einfachheit ist also eine positive Eigenschaft. Das allein kann die Faszination von Go aber nicht ausmachen. Als C#-Entwickler haben mich Go-Programme von Anfang an durch das fast schon triviale Deployment fasziniert. Wenn man ein Go-Programm kompiliert, erhält man als Ergebnis eine einzelne ausführbare Datei. Die Datei ist in Maschinensprache, es gibt keine Intermediate Language wie in .NET. Die ausführbare Datei hat keinerlei Abhängigkeiten, erfordert am Zielsystem also keine Installation einer Go Runtime. Sie könnte in einem Linux-Container sogar From scratch, also direkt am Linux-Kernel, ausgeführt werden. Man hat es nicht mit hunderten DLLs zu tun, wie man es heutzutage von .NET Core bei Self-contained Deployments kennt. Es gibt kein Herumschlagen mit einem riesigen node_modules-Ordner wie bei Node.js. Man kompiliert mit Go bei Bedarf über Plattformgrenzen hinweg, gerne auch ein Linux Executable auf dem Windows-Entwicklungsrechner.

Ein einfacher Lösungsansatz kommt auch beim Verteilen wiederverwendbarer Packages zum Einsatz. Das go-get-Tool lädt Packages mit ihrem Sourcecode aus einem lokalen Ordner oder direkt aus der Quellcodeverwaltung (z. B. Git, Mercurial). Packages werden immer aus dem Quellcode gebaut. Binary-only Packages wie bei NuGet Packages mit DLLs ohne Sourcecode gab es bei Go früher, sie werden aber nicht mehr unterstützt.

Der Name des Package enthält die Quelle (z. B. github.com/gorilla/mux verweist auf GitHub). Größere Firmen können Module Proxies (z. B. Athens Project) betreiben, über die man steuern kann, welche Pakete verwendet werden dürfen, und die außerdem Package Cache sind. Für kleine Teams wird Google eigene Module Mirrors betreiben.
Die Organisation von Sourcecode am lokalen Entwicklungsrechner hat sich seit Go 1.11 schrittweise verändert. Früher gab es einen einzelnen Ordner (referenziert durch die GOPATH-Umgebungsvariable), in dem alle Pakete sowie der eigene Quellcode in einer definierten Verzeichnisstruktur abgelegt werden mussten. In Go 1.11 und 1.12 wurde diese Einschränkung aufgehoben und Go erhielt ein eigenes Modulsystem, durch das wiederholbare Builds viel einfacher wurden. Ab Go 1.13 (erscheint in Kürze) ist das Modulsystem der Standard.

 

 

Pointer

Wenn man aus C# kommt, sind Pointer in Go am Anfang etwas ungewohnt. Man erinnert sich vielleicht mit Schrecken an Fehler aus C-Zeiten, mit denen in C# Schluss war. Keine Angst, Pointer in Go können nicht mit Pointern in C verglichen werden. Es gibt keine Pointer-Arithmetik und Pointer haben klar definierte Typen. Einen Eindruck, wie Pointer in Go funktionieren, bekommt man am besten, wenn man einen Blick auf ein Beispiel wirft. Listing 2 enthält einige Zeilen Go-Code, der Pointer verwendet. Achten Sie auf die Erklärungen in den Codekommentaren.

Listing 2: Pointer

// Play with this code snippet online at https://play.golang.org/p/1CtnUde0Kps

package main

import "fmt"

type person struct {
	firstName string
	lastName string
}

func main() {
  // Let us create an int value and get its address
  x := 42
  px := &x
  // Note the *dereferencing* with the * operator in the next line
  fmt.Printf("x is at address %v and it's value is %v\n", px, *px)

  // Let us double the value in x. Again, we use dereferencing
  *px *= 2
  fmt.Printf("x is at address %v and it's value is %v\n", px, *px)
  
  // We can allocate memory and retrieve a point to it using *new*.
  // The allocated memory is automatically set to zero.
  px = new(int)
  fmt.Printf("x is at address %v and it's value is %v\n", px, *px)

  // Go only knows call-by-value. In the following case, the value
  // is a pointer and the method can dereference it to write something
  // into the memory the pointer points to.
  func(val *int) {
    *val = 42
  }(px)
  fmt.Printf("x is at address %v and it's value is %v\n", px, *px)
  
  // We can also create pointers to structs. Note that although we
  // have a pointer, we can still access the struct's members using
  // a dot.
  pp := &person{"Foo", "Bar"}
  fmt.Printf("%s, %s\n", pp.lastName, pp.firstName)

  // We can pass a pointer to a struct to a method. In this case,
  // the method can change the struct's content.
  func(somebody *person) {
    somebody.firstName, somebody.lastName = somebody.lastName, somebody.firstName
  }(pp)
  fmt.Printf("%s, %s\n", pp.lastName, pp.firstName)
}

Fehlerbehandlung

Über die Fehlerbehandlung in Go-Programmen könnte man lange schreiben. Sie ist auch innerhalb der Go-Community ein oft diskutiertes Thema, und für Go 2 liegen einige Designvorschläge vor, was man daran verbessern könnte. Für jemanden, der von C# kommt, wirkt die Fehlerbehandlung von Go auf den ersten Blick rückschrittlich. Man ist schließlich seit Jahren try/catch gewöhnt. Warum darauf verzichten?

Die Grundidee der Fehlerbehandlung in Go geht von der Überlegung aus, dass Fehlerbehandlung im Code möglichst direkt am Ort der Fehlerentstehung zu finden sein soll. Außerdem muss im Code klar sein, welche Methoden Fehler zurückgeben. Bei try/catch ist das oft nicht der Fall. Die Fehlerbehandlung kann an einer ganz anderen Stelle geschehen als die, an der der Fehler auftrat. Außerdem ist beim Ansehen eines Methodenaufrufs nicht klar, ob die Methode einen Fehlerzustand in Form einer Ausnahme zurückgeben kann oder nicht.

 

SIE LIEBEN C#?

Entdecken Sie die BASTA! Tracks

 

In Go geben Methoden einen Fehlerzustand per Konvention als letzten Rückgabewert zurück. Dazu muss man wissen, dass Go ohne Weiteres mehrere Rückgabewerte bei Methoden unterstützt. Listing 3 zeigt einen kurzen Codeausschnitt, der demonstriert, wie einfache Fehlerbehandlung in Go abläuft. Bitte beachten Sie, dass dieses kurze Beispiel nur an der Oberfläche des Themas kratzt. Wer mehr wissen möchte, dem empfehle ich den Blogartikel „Error handling and Go“.

Listing 3: Fehlerbehandlung

// Play with this code snippet online at https://play.golang.org/p/eGlvSiQJgcF

package main

import (
  "errors"
  "fmt"
)

// The following method returns the result AND an error. The error is nil if
// everything is ok.

func div(x int, y int) (int, error) {
  if y == 0 {
    return -1, errors.New("Sorry, division by zero is not supported")
  }

  return x / y, nil
}

func main() {
  // Here we declare-and-assign the result and the error variable.
  result, err := div(42, 0)
  if err != nil {
    fmt.Printf("Ups, something bad happened: %s\n", err)
    return
  }

  fmt.Printf("The result is %d\n", result)
}

Nebenläufigkeit

Go zeichnet sich durch besonders gute Unterstützung von Concurrency, also Nebenläufigkeit aus. Statt C# Tasks kommen in Go Goroutines zum Einsatz. Eine Goroutine ist eine ganz normale Go-Funktion. Erst der Aufruf mit dem go-Schlüsselwort macht aus der Funktion eine Goroutine. Wie Tasks laufen Goroutines auf einem Pool von Betriebssystem-Threads. Ebenfalls wie Tasks sind Goroutines wesentlich leichtgewichtiger als Threads, insofern kann es viel mehr davon geben.

Spätestens wenn es um das Scheduling geht, enden die Gemeinsamkeiten. In C# werden Tasks typischerweise in Verbindung mit async/await genutzt. Der C#-Compiler erzeugt daraus im Hintergrund eine State Machine. Eine Goroutine kann man sich im Gegensatz dazu als leichtgewichtigen, von der Go-Laufzeitumgebung (nicht vom Betriebssystem!) verwalteten Thread mit eigenem Stack vorstellen. Der Stack einer Goroutine ist aber klein (2kb) und kann je nach Bedarf vergrößert und verkleinert werden. Dadurch geht bei einer größeren Anzahl an Goroutines nicht gleich der Speicher aus.

Go verwendet beim Scheduling von Goroutines kein Zeitscheibenverfahren (Preemtive Scheduling), sondern kooperatives Multitasking (Non-preemtive oder Cooperative Scheduling). Eine Goroutine gibt also zu genau definierten Zeitpunkten die Kontrolle ab (z. B. wenn sie auf das Ergebnis einer blockierenden Operation wartet), wird aber ansonsten nicht unterbrochen.

Channels

Das volle Potenzial von Goroutines erschließt sich erst, wenn man sich mit Go-Channels beschäftigt. Channels sind ein Mittel, wie Goroutines ohne Shared Memory und Locking (Sync Package) miteinander kommunizieren können. Durch sie kann eine Goroutine einer anderen Nachrichten schicken. Man kann auf das Eintreffen einer Nachricht warten (Blocking) oder auf das Vorhandensein einer Nachricht prüfen, ohne zu blockieren (Non-blocking). Channels können Nachrichten auch puffern. Listing 4 zeigt exemplarisch einige Anwendungsfälle von Channels. Bitte beachten Sie beim Lesen die eingefügten Kommentare. Erfahrenen C#-Entwicklerinnen und – Entwicklern empfehle ich, beim Durchsehen des Codes zu überlegen, wie man die jeweilige Aufgabe in C# mit Tasks und async/await lösen würde.

.NET hat im System.Threading.Channels ebenfalls Channels im Angebot. Sie werden im Rahmen der .NET Platform Extensions als eigene NuGet Packages ausgeliefert und kommen zu .NET Core 3 dazu. Im Gegensatz zu Go sind diese Klassen aber nicht speziell in die Sprache C# integriert. In Go sind Channels eine Besonderheit, durch die sich die Sprache von anderen Programmiersprachen abgrenzt, und dementsprechend gut ist die Integration.

Listing 4: Channels

// Play with this code snippet online at https://play.golang.org/p/lHTHywrgcuA

package main

import (
  "fmt"
  "time"
)

func sayHello(source string) {
  fmt.Printf("Hello World from %s!\n", source)
}

// Note that the following method receives a channel through which it can
// communicate its result once it becomes available.

func getValueAsync(result chan int) {
  // Simulate long-running operation (e.g. read something from disk)
  time.Sleep(10 * time.Millisecond)

  // Send result to calling goroutine through channel
  result <- 42
}

// Note that the following method does not return a value. The channel is
// just used to indicate the completion of the asynchronous work.

func doSomethingComplex(done chan bool) {
  // Simulate long-running operation
  time.Sleep(10 * time.Millisecond)

  done <- true
}

func main() {
  // Call sayHello directly and using the *go* keyword on a separate goroutine.
  sayHello("direct call")
  go sayHello("goroutine")

  // Call method on a different goroutine and give it a channel through which it can send back the result.
  result := make(chan int)
  go getValueAsync(result)
  // Wait until result is available and print it.
  fmt.Println(<-result)

  // Do something asynchronously and wait for it to finish
  done := make(chan bool)
  go doSomethingComplex(done)
  // Wait until a message is available in the channel
  <-done
  fmt.Println("Complex operation is done")

  // Note the select statement here. You can use it to wait on
  // multiple channels. In this case, we use a channel from a
  // timer to implement a timeout functionality.
  go getValueAsync(result)
  select {
  case m := <-result:
    fmt.Println(m)
  case <-time.After(5 * time.Millisecond):
    fmt.Println("timed out")
  }

  // Let us print a status message for a certain amount of time.
  ticker := time.NewTicker(100 * time.Millisecond)
  // Note the anonymous function here.
  go func() {
    // Note that Go's range operator supports looping over
    // values received through a channel.
    for range ticker.C {
      fmt.Println("Tick")
    }
  }()
  // Wait for some time and then stop timer.
  <-time.After(500 * time.Millisecond)
  ticker.Stop()
}


Go wofür?

Man sieht, dass Go gegenüber C# teilweise andere Schwerpunkte hat und eine etwas andere Philosophie verfolgt. Einen wichtigen Unterschied habe ich bisher noch nicht ausdrücklich erwähnt: Go spielt zum überwiegenden Teil nur auf dem Server beziehungsweise in der Konsole eine Rolle. Laut der jährlichen Go-Umfrage arbeiten 65 Prozent der Go-Nutzer im Bereich Webentwicklung. Danach folgen die Aufgabengebiete DevOps (41 Prozent) und Systemprogrammierung (39 Prozent). Go wird nur in Ausnahmefällen für GUI-Entwicklung verwendet. Es gibt Packages dafür, in der Regel greift man aber auf webbasierende Benutzerschnittstellen zurück.

 

 

Fazit

Joel Spolsky, Mitgründer von Stack Overflow, meinte kürzlich in einem Interview: „Produktivität verbessert man am besten, indem man Komplexität verringert.“ In Zeiten rasend schneller Veränderung und Erweiterung von Plattformen und Programmiersprachen ist der Ansatz von Go erfrischend. Der Fokus liegt auf einer einfachen Sprache, leicht verständlichem Code mit einheitlichem Aufbau, einfachem Deployment und guter Performance. Gleichzeitig bietet die Sprache eine Menge interessanter Features für nebenläufige Programmierung.

In der Praxis ist für eine Plattform nicht nur die Programmiersprache, sondern auch die Verfügbarkeit von Bibliotheken entscheidend. Hier glänzt Go speziell in den Bereichen Webentwicklung (Web-Apps und Web-APIs) sowie System- und Netzwerkprogrammierung. Natürlich ist die Verwendung von Go in Verbindung mit Containern besonders angenehm, da schließlich die Docker-Plattform auf Go aufbaut.

Alles in allem halte ich Go für eine interessante Alternative beziehungsweise Ergänzung zu C#, wenn es um Microservices geht. Die gewohnten Tools wie Visual Studio Code, Azure DevOps und Azure muss man deshalb nicht über Bord werfen, ganz im Gegenteil. Sie alle bringen sehr gute Go-Unterstützung mit.

Ihr aktueller Zugang zur .NET- und Microsoft-Welt.
Der BASTA! Newsletter:

Behind the Tracks

.NET Framework & C#
Visual Studio, .NET, Git, C# & mehr

Agile & DevOps
Agile Methoden, wie Scrum oder Kanban und Tools wie Visual Studio, Azure DevOps usw.

Web Development
Alle Wege führen ins Web

Data Access & Storage
Alles rund um´s Thema Data

JavaScript
Leichtegewichtig entwickeln

UI Technology
Alles rund um UI- und UX-Aspekte

Microservices & APIs
Services, die sich über APIs via REST und JavaScript nutzen lassen

Security
Tools und Methoden für sicherere Applikationen

Cloud & Azure
Cloud-basierte & Native Apps