Die Sprache Go

Kai Spichale

Wer Go noch nicht kennt, kann sich in diesem Blogpost einen Überblick verschaffen. Angeblich wurden die Grundlagen dieser Programmiersprache bei Google ausgetüftelt, während die Entwickler auf die Kompilierung größerer Anwendungen warten mussten. Go ist deswegen auch keine Sprache mit revolutionären Konzepten, sondern eine moderne, leicht handhabbare und vor allem schnell kompilierbare C-Alternative.

  • Google veröffentlichte 2009 die Programmiersprache Go.
  • Wichtige Entwurfsziele waren automatische Speicherverwaltung, schnelle Kompilierung und native Unterstützung für nebenläufige, systemnahe Programmierung.
  • Die Vorteile einer interpretierten, dynamisch typisierten Sprache sollten mit der Effizienz und Sicherheit einer kompilierten, statisch typisierten Sprache kombiniert werden.
  • Abhängigkeiten sollten direkt aus dem Quellcode ableitbar sein.

Ein erster Blick auf den Code zeigt, dass Go eine C-ähnliche Programmiersprache ist. Es gibt weniger Schleifen, weniger Variablendeklarationen und keine Semikolons:

package main

import "fmt"

func add(a, b int) int {
  return a + b
}

func main() {
  fmt.Println(add(1, 2))
}

Das Code-Snippet gehört zum Paket main und importiert das Standardpaket fmt. Innerhalb des Paketes main sind die Funktionen add und main definiert. In diesem Paket beginnt standardmäßig die Ausführung des Programms. Falls man ein wiederverwendbares Paket entwickeln möchte, das nicht selbst ausführbar ist, würde man das Paket main und die Funktion main weglassen.

Ein Go-Programm besteht aus Paketen, die zusammengehörige Funktionen und Variablen gruppieren. Ein Paket entspricht üblicherweise einem Dateiverzeichnis gleichen Namens. Die Dateien im Verzeichnis mit der gleichen package <name> Direktive bilden eine modulare Einheit. Denkbar wäre zum Beispiel das Paket blog im gleichnamigen Verzeichnis mit den Dateien blog.go, post.go and comment.go.

Gute Paketnamen können den Code ungemein verbessern. Der Paketname sollte einen passenden Kontext für seinen Inhalt bieten, sodass Benutzer diesen einfacher verstehen können. Auch die Entwickler eines Paketes können bei dessen Weiterentwicklung leichter entscheiden, was in das Paket gehört und was nicht. Gute Paketnamen wie time, list und http sind kurz und verständlich. Typischerweise werden kleingeschriebene Substantive verwendet. Wortzusammensetzung mit camelCase, PascalCase oder Bindestrichen sind für Go-Pakete unüblich. Falls es keinen kurzen, passenden Begriff gibt, kann man Paketnamen auch abkürzen. Bekannte Beispiele sind fmt, syscall und strconv.

Im obigen Code-Snippet wird die Funktion Println aus dem Paket fmt verwendet. Diese Wiederverwendung ist möglich, weil die Funktion von diesem Paket exportiert wurde. Generell werden alle großgeschriebenen Namen von einem Paket exportiert. Aus diesem Grund beginnt die Funktion Println mit einem großen “P”. Kleingeschriebene Namen werden nicht exportiert und können von anderen Paketen nicht aufgerufen werden.

Tipp: Um die Modularisierung des Quellcodes zu verbessern, sollte die Sichtbarkeit möglichst gering sein. Aus diesem Grund sollten Variablen und Funktionen standardmäßig kleingeschrieben werden. Nur wenn tatsächlich Zugriff aus einem anderen Paket notwendig ist, sollten Namen exportiert werden.

Abhängigkeiten zwischen Paketen

Die Google-Mitarbeiter verfolgten von Anfang an das Ziel bei der Entwicklung von Go, Abhängigkeiten zwischen Paketen direkt im Quellcode ausdrücken zu können. So entstand die Idee der Pakete und der Import-Blöcke, wo Namen und Pfade angegeben werden können.

Mit Hilfe von Konventionen vermeiden Go-Entwickler in vielen Fällen Konfigurationen. Typischerweise reicht die Information im Quellcode aus, um ein Go-Programm zu bauen. Makefiles, Shell-Skripte etc. sind nicht notwendig.

Tipp: Go funktioniert ohne Konfiguration dank seiner etablierten Konventionen, die Einfluss auf Workspace-Struktur, Namen und Werkzeuge haben. Deshalb ist es wichtig, diese Konventionen zu kennen und zu befolgen.

Basistypen

Go bietet eine Reihe von eingebauten Basistypen:

bool
string
int int8int64
uint unit8uint64 uintptr
byte
rune
float32 float64
complex64 complex128

Anhand des Namens kann man sicherlich erkennen, welche Bedeutung diese Datentypen haben. Erklärungsbedürftig sind vermutlich die Basistypen uintptr und rune. Ersterer ist ein ausreichend großer Integer, der beliebige Zeiger beinhalten kann. Letzterer entspricht int32 und wird zur Unterscheidung von Zahlen und Textzeichen verwendet.

Bei Zuweisungen von Werten unterschiedlichen Typs muss immer eine explizite Typumwandlung erfolgen. Bei einer Typumwandlung wird der Wert v in den Typ T mit dem Ausdruck T(v) umgewandelt:

var i int = 42
var u uint = uint(i)

Durch Typinferenz kann eine Variable auch ohne die Angabe eines Datentyps deklariert werden. Der Typ der Variable wird in diesem Fall aus dem Wert auf der rechten Seite abgeleitet.

pi := 3.142

Wertzuweisungen erfolgen mit dem = Operator. Der := Operator ist genaugenommen kein Operator, sondern Teil einer verkürzten Schreibweise zur Deklaration von Variablen innerhalb von Funktionen.

Variablen

Eine Variable kann mit der Anweisung var deklariert werden. Genauer gesagt wird mit var sogar eine Liste von Variablen deklariert. Der Typ der Variablen steht an letzter Stelle. Die Anweisung var kann auf Paket- und auf Funktionsebene genutzt werden.

var b1, b2 bool
var t1, t2 string

func print() {
  b2 = true
  t2 = "test"

  fmt.Printf("b1 = %v\n", b1)  // b1 = false
  fmt.Printf("b2 = %v\n", b2)  // b2 = true
  fmt.Printf("t1 = %v\n", t1)  // t1 = ""
  fmt.Printf("t2 = %v\n", t2)  // t2 = test
}

Variablen, die ohne Intializer deklariert werden, haben trotzdem einen sogenannten Nullwert. Numerische Typen haben implizit den Wert 0, boolesche Werte sind false und Strings werden mit einem Leerstring "" initialisiert. Der Nullwert von Maps und Arrays ist nil. Auf nil kommen wir später noch einmal zurück.

var i, j int = 1, 2

Variablen können bei ihrer Deklaration ebenfalls mit einem Wert versehen werden.

Die := Anweisung kann innerhalb einer Funktion benutzt werden, um etwas kompakter eine Variable zu deklarieren und mit einem Wert zu belegen. Außerhalb einer Funktion kann die :=Anweisung nicht verwendet werden.

a := 1
b, c := true, "some text"

Konstanten können mit dem Schlüsselwort const deklariert werden. Die := Anweisung kann nicht für Konstanten verwendet werden.

const MESSAGE = "Hello, World!"

Arrays und Slices

Arrays sind ein wichtiger Baustein von Go-Programmen. Ein Array mit der Länge n und dem Typ T wird mit der Anweisung [n]T deklariert. Die Länge eines Arrays kann nicht verändert werden, denn sie ist ein Bestandteil des Typs.

var array [3]string
array[0] = "eins"
array[1] = "zwei"
array[2] = "drei"

Wie man sieht, hat der erste Eintrag des Arrays den Index 0 und der letzte den Index len(array)-1. Alternativ kann man bei der Deklaration eines Arrays auch dessen initialen Inhalt angeben:

array := [] string { "eins", "zwei", "drei" }
// Kapazität cap(array) ist 3
// Länge len(array) ist 3

Nachvollziehbarerweise hat das Array eine Länge und Kapazität von 3. Warum Go zwischen Länge und Kapazität unterscheidet, wird schnell klar, wenn man sich die Slices anschaut. Slices sind eine weitere wichtige Datenstruktur. Ein Slice ist kein Array, sondern eine Sicht auf einen darunterliegenden Teil eines Arrays, ohne eigenen Speicherplatz für Einträge. Im übertragenen Sinn erfüllt ein Slice die Funktion einer Liste. Der folgende Slice zeigt auf eine zusammenliegende Sektion des Arrays mit den Elementen 0 und 1.

var slice = array[0:2]
// Kapazität cap(slice) ist 3
// Länge len(slice) ist 2

Die Länge eines Arrays kann nicht verändert werden. Es ist jedoch möglich, einen neuen Slice für ein Array zu erzeugen, das beispielsweise um das letzte Element verkürzt ist:

slice = slice[:len(slice)-1]
// Kapazität cap(slice) ist 3
// Länge len(slice) ist 1

Ein Slice hat eine Länge und eine Kapazität. Die Länge entspricht der Anzahl der Elemente, die es enthält. Die Kapazität bezieht sich auf die Anzahl der Elemente im darunterliegenden Array.

Der Null-Wert eines Slice ist nil. Ein nil-Slice hat kein darunterliegendes Array, weswegen Kapazität und Länge 0 sind.

var nilSlice []string
// Kapazität cap(nilSlice) ist 0
// Länge len(nilSlice) ist 0

Mit der Standardfunktion append können Elemente zu einem Slice bzw. Array hinzugefügt werden.

slice = append(slice, "vier", "fünf")

Maps

Eine weitere wichtige Datenstruktur sind Maps, die Schlüsseln Werte zuordnen. Maps werden mit dem Ausdruck make erzeugt.

var vehicles map[string] int      // entspricht einer leeren nil-Map
vehicles = make(map[string] int)  // jetzt gibt es eine Map
vehicles["BMW"] = 3               // neuen Eintrag hinzufügen

Funktionen

Innerhalb eines Paketes können Funktionen definiert werden. Die Funktion add im folgenden Beispiel gibt einen Integer als Anzahl der Fahrzeuge zurück. Die Funktion get gibt einen Integer und einen booleschen Wert zurück, sodass man zwischen Anzahl 0 und Nichtvorhandensein unterscheiden kann. Das Beispiel zeigt außerdem, dass Wertzuweisungen und Deklarationen auch für mehr als eine Variable möglich sind.

import "fmt"

var vehicles = make(map[string] int)

func add(name string, number int) int {
  vehicles[name] = number + vehicles[name]
  return vehicles[name]
}

func get(name string) (int, bool) {
  number, exists := vehicles[name]
  return number, exists
}

func main() {
  add("BMW",1)
  add("BMW",2)
  fmt.Println(get("BMW"))
}

Go bietet keine automatische Unterstützung für Getter und Setter. Es spricht aber nichts dagegen, diese programmatisch hinzuzufügen. Es gibt keine allgemeine Regel zur Präfixbenutzung von “Get” oder “Set” im Namen. Falls man einen öffentlichen Getter für ein Feld firstname hinzufügen möchte, sollte man GetFirstname oder Firstname als Namen verwenden.

In jedem Paket kann wahlweise eine init Funktion genutzt werden, die nach der Initialisierung der Variablen ausgeführt wird.

func init() {
  // ...
}

var ConfigSuccess = configureApplication()

func configureApplication() bool {
  // ...
  return true
}

func main() {
  fmt.Println(ConfigSuccess);
}

In diesem Beispiel wird die Funktion configureApplication zur Initialisierung der Variablen ConfigSuccess ausgeführt. Die Ausführung von init folgt im Anschluss. Erst am Ende wird die Funktion main aufgerufen.

First-Class-Funktionen sind ein großer Vorteil von Go. In diesem Fall kann man eine Funktion als Wert betrachten. Eine Funktion kann deswegen einer anderen Funktion als Argument übergeben werden. Man kann eine Funktion auch einer Variablen zuordnen oder sie als Rückgabewert nutzen. Go unterstützt anonyme Funktionen, mit denen Closures gebildet werden können.

Verzweigungsstrukturen

Die for-Schleife funktioniert wie in Java oder C. Die runden Klammern fehlen, dafür sind die geschweiften nicht optional. Go unterstützt ebenfalls die aus anderen Programmiersprachen bekannten Anweisungen break und continue. Weil man Start- und Zählschritt weglassen kann, könnte eine for-Schleife auch als while-Schleife geutzt werden.

for i:=0; i<10; i++ {
  k:=0
  for k<10 {
    fmt.Println(i k)
    k++
  }
}

Ein for-Schleife kann in Kombination mit dem Ausdruck range benutzt werden, um über ein Slice oder eine Map zu iterieren:

array := []int{1,2,3}
for i := range array {
  fmt.Println(i)
}

Die if- und else-Anweisungen sehen bis auf die fehlenden Klammern wie bei Java oder C aus. Wie bei der for-Schleife kann man optional eine Anweisung vor der Bedingung ausführen.

if sum := add(x, y); sum < 42 {
  // ...
} else if sum == 100 {
  // ...
}

Go bietet ebenfalls ein switch-Statement mit den Schlüsselwörtern switch, case, default und fallthrough. Switch-Anweisungen werten die angegebenen Cases von oben nach unten aus. Sobald ein Case zutrifft, wird abgebochen. Nur durch explizite Angabe von fallthrough wird mit dem folgenden Case fortgefahren.

func TestSwitch(a int) string {
  var msg string
  switch a {
    case 1:
      msg = "eins"
    case 2:
      msg = "zwei"
       fallthrough
    case 3:
      msg = "drei"
    default:
      msg = "default value"
  }
  return msg
}

Strukturen und Interfaces

Ein Verbund von Elementen kann mit struct erzeugt werden. Es handelt sich hierbei um eine Typdeklaration. Es ist möglich, Strukturen ineinander zu schachteln, um komplexere Modelle abzubilden.

type Point struct {
  Longitude int
  Latitude int
}

func main() {
  p := Point{0,0}
  p.Latitude = 1
  fmt.Println(p.Latitude)
}

Go verzichtet auf Klassen zur Umsetzung von Objektorientierung, stattdessen gibt es Interfaces. Ein Interface ist eine Liste von Funktionen. In diesem Beispiel hat das Interface Shape eine Funktion zur Berechnung der Fläche:

type Shape interface {
  area() float
}

make und new

Go kennt die zwei eingebauten Funktionen new und make zur Speicherreservierung. Mit new wird Speicherplatz für Werte wie int, Address oder Person allokiert und “nullisiert”. Die Rückgabe von new(T) ist ein Pointer vom Typ T.

type Address struct {
  city string
}

a1 := new(Address)
fmt.Println(a1.city == "") // true

var a2 *Address 
fmt.Println(a2.city == "") // invalide Speicheradresse

Anschließend kann der “nullisierte” Speicher ohne weitere Initialisierung benutzt werden. Die Dokumentation von bytes.Buffer besagt zum Beispiel, dass der Nullwert von Buffer ein leerer, einsetzbarer Puffer ist.

Channels, Slices und Maps werden hingegen mit make erzeugt. Die Funktion make allokiert und initialisiert Speicher für diese Datenstrukturen. Die Rückgabe von make(T) ist kein Zeiger, sondern ein initialisierter Wert von Typ T.

v := make([]int, 2, 10) // allokiert ein Slice der Länge 2 und der Kapazität 10

Go-Entwickler müssen nicht wissen, wo im Speicher die Variablen abgelegt sind. Der Go-Compiler entscheidet, ob eine Variable auf dem Stack oder auf dem Heap liegt. Eine Variable existiert solang es eine Referenz darauf gibt. Die Garbage Collection funktioniert automatisch.

Zeiger

Im Gegensatz zu Java bietet Go auch die Möglichkeit, mit Zeigern zu arbeiten. Ein Zeiger hält die Adresse einer Variablen im Speicher. Im folgenden Snippet wird eine Variable p deklariert deren Typ ein Zeiger auf einen Wert des Typs string ist:

var p *string

Mihilfe des & Operators wird ein Zeiger auf seinen Operanden erzeugt:

text := "zeichenkette"
p = &text

Das Dereferenzieren des Pointers erfolgt mit dem * Operator. Der Ausdruck *p liefert den Wert, auf den der Pointer verweist:

fmt.Println(*p)

Verzögerte Funktionsaufrufe

Funktionsaufrufe können mit der Anweisung defer verzögert ausgeführt werden. Die markierten Funktionen werden auf einen Stack gelegt und nach der Rückkehr der umgebenden Funktion in Last-In-First-Out-Reihenfolge ausgeführt. Dieses Feature kann man beispielsweise einsetzen, um Aufräumarbeiten durchzuführen.

Nennenswert sind ebenfalls die Standardfunktionen panic und recover. Sobald panic aufgerufen wird, stoppt die Ausführung einer Funktion und die verzögerten Funktionen werden der Reihe nach gestartet. Innerhalb der verzögerten Funktionen kann recover genutzt werden, um zur normalen Ausführung zurückzukehren.

Ausnahmen und try-catch-finally-Muster, wie man sie beispielsweise aus Java kennt, werden in Go nicht eingesetzt.

Objektorientierung

Go wurde in diesem Blogpost als moderne C-Alternative vorgestellt. Die ursprünglich für systemnahe Entwicklung konzipierte Programmiersprache kann prinzipiell überall eingesetzt werden. Wer eine Sprache wie Go lernen möchte, wird früher oder später verstehen wollen, wie Objektorientierung mit dieser Sprache funktioniert. Mit dieser Fragestellung beschäftigt sich der nächste Blogpost über Go.

Thumb spichale

Kai Spichale beschäftigt sich leidenschaftlich seit mehr als 10 Jahren mit Softwarearchitekturen verteilter Systeme und sauberen Code. Als IT-Berater arbeitet er für innoQ Deutschland GmbH. Sein technologischer Schwerpunkt liegt auf modernen Architekturansätzen, API-Design und NoSQL. Er ist regelmäßiger Autor in verschiedenen Fachmagazinen und Sprecher auf Konferenzen.

Weitere Inhalte

Kommentare

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