Interfaces

Interfaces (Schnittstellen) sind eine Besonderheit in der Programmiersprache Go. Sie sind eine Art beschreibender Datentyp und gleichzeitig ein Platzhalter für irgend etwas. Beide Aspekte sollen hier getrennt beschrieben werden, obwohl sie streng genommen zwei Konsequenzen derselben Logik sind.

Interfaces sind ein sehr wichtiger Bestandteil von Go, sehr viele Funktionen in der Standardbibliothek benutzen sie. Dennoch sind sie für den Anwender nicht immer leicht zu verstehen, da sich in anderen Programmiersprachen keine direkte Entsprechung findet. In manchen anderen Programmiersprachen muss man explizit sagen, dass eine Funktion oder ein Datentyp ein Interface bedient, in Go passiert das automatisch. Es ist auch nicht leicht herauszufinden, welche Datentypen welche Interfaces implementieren.

Interfaces als Platzhalter für beliebige Datentypen

Wenn die Funktion fmt.Println() aufgerufen wird, man beliebige Datentypen übergeben.

package main

import "fmt"

func beispiel(dummy int) {
    // ohne Funktionalität
}

func main() {
    fmt.Println("Hallo", beispiel, 1234)
}

Hier ist das erste Argument vom Typ string, das zweite vom Typ func(int) und das dritte vom Typ int. Wie funktioniert das in der Praxis? Da man in einer Funktionsdefinition genau festlegen muss, welche Typen übergeben werden, warum hat fmt.Println() hier eine Sonderstellung? Wenn man sich unter https://golang.org/pkg/fmt/#Println die Funktionsbeschreibung anschaut, sieht man, dass das Argument ein Interface-Typ ist:

func Println(a ...interface{}) (n int, err error)

... bedeutet, dass eine beliebige Anzahl Parameter übergeben werden kann. Diese Parameter sind vom Typ interface{} und können beliebige Inhalte haben. Erst später wird überprüft, welcher Art ein bestimmtes Argument ist.

Als Beispiel soll eine Funktion erstellt werden, die ein Argument annimmt. Ist es eine Zahl (int), soll das doppelte der Zahl zurückgegeben werden. Ist das Argument eine Zeichenkette (string), soll sie zweimal hintereinander geschrieben zurückgegeben werden. In der Standardbibliothek im Paket strings gibt es eine passende Funktion dafür.

Die erste naive Implementierung schlägt fehl:

package main

import "fmt"

func verdoppeln(wert interface{}) interface{} {
    // Achtung, geht nicht:
    return wert * 2
}

func main() {
    fmt.Println(verdoppeln(4))
}

Die Funktionsdefinition verdoppeln sieht richtig aus, es wird ein Wert vom Typ interface{} erwartet und zurückgegeben. Wie erwähnt, kann interface{} einen beliebigen Datentyp speichern. Das Problem wird aber ersichtlich, wenn man das Programm laufen lässt:

./main.go:6: invalid operation: wert * 2 (mismatched types interface {} and int)

Der Go-Compiler sagt, dass er die Variable vom Typ interface{} nicht mit einer Ganzzahl (int) multiplizieren kann. Hier muss eine Typzusicherung benutzt werden (engl. type assertion). Eine Typzusicherung hat die Form variable.(Typ), in dem Beispiel heißt es dann:

func verdoppeln(wert interface{}) interface{} {
    return wert.(int) * 2
}

Damit sichern das Programm dem Compiler zu: »in der Variablen wert steckt eine Ganzzahl«. Die Schwierigkeit ist, dass der wirkliche Typ nur zur Laufzeit bekannt ist. Der Compiler kann nicht erkennen, mit welchem Wert die Funktion verdoppeln() aufgerufen wird. Was passiert also, wenn das folgendes Programm ausgeführt wird? (Bitte ausprobieren!)

package main

import "fmt"

func verdoppeln(wert interface{}) interface{} {
    return wert.(int) * 2
}

func main() {
    fmt.Println(verdoppeln("A"))
}

Hier ist der Datentyp des Arguments in Wirklichkeit eine Zeichenkette. Es wird aber dem Compiler zugesichert, dass es sich um eine Ganzzahl handelt. Das System erkennt aber erst zur Laufzeit das Problem und löst eine Ausnahmesituation (panic) aus:

panic: interface conversion: interface is string, not int

zusammen mit einem sogenannten stack trace, also einer Beschreibung der Funktionen, die aufgerufen wurden und zum eigentlichen Problem zeigen. Damit kann der Programmierer die problematische Stelle schnell finden und durch »scharfes hinsehen« meist den Fehler korrigieren.

Um nun zu unterscheiden, welcher Typ als Argument für die Funktion übergeben wurde, gibt es eine spezielle Syntax der Switch-Anweisung:

package main

import (
    "fmt"
    "strings"
)

func verdoppeln(wert interface{}) interface{} {
    switch wert.(type) {
    case int:
        return wert.(int) * 2
    case string:
        return strings.Repeat(wert.(string), 2)
    default:
        fmt.Println("Konnte Typ von wert nicht ermitteln!")
        return nil
    }
}

func main() {
    fmt.Println(verdoppeln(2))
    fmt.Println(verdoppeln("A"))
    // Typ float64, Fehler!
    fmt.Println(verdoppeln(1.1))
}

In der Funktion verdoppeln() wird nun der Typ von wert abgefragt und je nach Ergebnis die entsprechende Funktion aufgerufen.

Die Switch-Anweisung kann man auch etwas einfacher schreiben:

switch v := wert.(type) {
case int:
	return v * 2
case string:
	return strings.Repeat(v, 2)
...
Hier hat v je nach Fall (case) schon den richtigen Typ und braucht nicht durch eine erneute Typzusicherung ermittelt werden.

Interface als beschreibender Datentyp

Die zweite Variante​[1] des Interface-Datentyps gibt an, welche Methoden in einer Struktur (struct) definiert sein müssen, damit es diese Schnittstelle implementiert.

Als Beispiel sei noch einmal die Funktion fmt.Println() genommen. Die Argumente werden von Printf »schön« ausgegeben. Das kann je nach Datentyp unterschiedlich sein. Das funktioniert sogar mit eigenen Datentypen. Dafür muss das Stringer Interface implementiert werden. Was sich kompliziert anhört, ist ganz einfach. Es muss auf dieser Datenstruktur die Funktion String() string definiert werden, also eine Funktion, die eine Zeichenkette zurückgibt.

package main

import (
	"fmt"
	"strings"
)

type iban struct {
	iban string
}

// String formatiert die IBAN lesbar mit Leerzeichen getrennt
func (i iban) String() string {  
	var ret []string
	var pos int // initialisiert mit 0
	l := 4 // Länge der einzelnen Abschnitte

	for ; pos < len(i.iban); pos += l { 
		if pos+l > len(i.iban) { 
			l = len(i.iban) - pos
		}
		ret = append(ret, i.iban[pos:pos+l])
	}

	return strings.Join(ret, " ") 
}

func main() {
	konto := iban{"DE41500105170123456789"}
	fmt.Println(konto) 
}

① Die Funktion String() implementiert das Interface fmt.Stringer Damit wird sie von fmt.Println() benutzt.

② Nach jedem Schleifendurchlauf wird pos erhöht. Die Schleife läuft solange, bis pos nicht mehr kleiner als die Länge der iban ist.

③ Wenn hinten nicht mehr als vier Stellen ausgegeben werden müssen, dann wird die Variable l entsprechend verringert. Das würde sich auch auf den nächsten Schleifendurchlauf auswirken, in diesem Fall ist die Schleife aber beendet (die Bedingung wird nicht mehr erfüllt)

④ Die einzelnen Segmente sind als Einträge im Slice ret gespeichert. Hier wird der Inhalt von ret mit Leerzeichen getrennt zusammengeführt.

⑤ Die Funktion fmt.Println() prüft intern, ob der Datentyp iban eine Funktion String() string definiert und ruft diese auf.

Interfaces sind hauptsächlich dann von Nutzen, wenn verschiedene Datentypen dieselben Methoden haben. Für kleinere Programme wird das wohl eher seltener zum Einsatz kommen.

Ich möchte an dieser Stelle zwei Beispiele geben, die die Nützlichkeit von Interfaces andeuten sollen. Das eine ist die allgemeine Sortierroutine in der Standardbibliothek von Go (Paket sort), das andere ist die Funktion io.Copy(), die Daten aus beliebigen Quellen in beliebige Ziele kopiert.

Beispiel: Sortieren von Daten

Eine häufig vorkommende Situation in einem Programm ist die, dass Daten sortiert werden sollen. Dazu stellt Go eine allgemeine Sortierroutine zur Verfügung. Das Paket kann aber nicht für jede Datenstruktur wissen, nach welchen Kriterien sortiert werden muss und welche Konsistenzbedingungen erfüllt sein müssen, wenn zwei Daten vertauscht werden. Daher muss der Anwender »nachhelfen« und die Methoden Len(), Less() und Swap() implementieren. Wenn auf einem Datentyp diese Methoden definiert sind, ist der Datentyp konform zur Schnittstelle sort.Interface (mit großem i). Und das ist genau der Datentyp, den die Funktion sort.Sort() erwartet, um zu sortieren.

In diesem Beispiel wird ein Typ person definiert und ein Typ besucher, der ein Slice vom erstgenannten Typ ist:

package main

type besucher []person

type person struct {
    alter int
    name  string
}

func main() {
    var b besucher
    b = []person{
        {23, "Andreas"},
        {37, "Marie"},
        {27, "Peter"},
        {27, "Karina"},
    }
}

Um zu wissen, wie die genannten Funktionen aussehen sollen, muss man sich die Definition der Schnittstelle anschauen (https://golang.org/pkg/sort/#Interface):

type Interface interface {
        // Len is the number of elements in the collection.
        Len() int
        // Less returns whether the element with index i should sort
        // before the element with index j.
        Less(i, j int) bool
        // Swap swaps the elements with indexes i and j.
        Swap(i, j int)
}

Die drei Methoden, die implementiert werden, müssen genau diese Signatur haben, also genau die Argumenttypen und Rückgabewerte.

package main

import (
    "fmt"
    "sort"
)

type besucher []person

type person struct {
    alter int
    name  string
}

func (b besucher) Len() int {
    return len(b)
}

func (b besucher) Less(i, j int) bool {
    return b[i].alter < b[j].alter
}

func (b besucher) Swap(i, j int) {
    b[i], b[j] = b[j], b[i]
}

func main() {
    var b besucher
    b = []person{
        {23, "Andreas"},
        {37, "Marie"},
        {27, "Peter"},
        {27, "Karina"},
    }
    fmt.Println(b)
    sort.Sort(b)
    fmt.Println(b)
}

Da jetzt die drei geforderten Methoden für den Typ besucher implementiert sind, kann sort.Sort() auf unseren Typ angewendet werden.

Beispiel: Kopieren von Daten

Das zweite Beispiel taucht in der Praxis häufig auf. Es werden aus einer Quelle Daten eingelesen (z.B. Netzwerk, Benutzereingabe) und an anderer Stelle ausgegeben (Standard-Ausgabe, Datei). Dazu werden die Daten aus der Quelle gelesen und ins Ziel geschrieben.

Auch hier ist in der Standardbibliothek im Paket io eine praktische Funktion enthalten, die das Kopieren für uns übernimmt. Die Funktion ist wie folgt definiert:

func Copy(dst Writer, src Reader) (written int64, err error)

Die Quelle (src) muss vom Typ Reader sein, das Ziel (dst) vom Typ Writer. Es ist zu diesem Zeit unklar, wie genau die Interfaces aussehen müssen. Ein erster Hinweis auf die zu implementierenden Methoden ist der Name der Schnittstelle. Meist werden (englische) Verben mit er am Ende verwendet. Die Schnittstelle Reader erfordert also die Methode Read(). Eine andere Schnittstelle aus dem Paket io heißt ReadCloser. Dem Namen nach weiß man, dass das Interface die Methoden Read() und Close() erfordert. Letztendlich ist das aber nur eine Konvention, wie vieles in Go. Um sicher zu gehen muss man sich die Dokumentation der Schnittstele anschauen. In dem Fall aus dem Beispiel ist das im Paket io das Interface Reader, das wie folgt definiert ist (https://golang.org/pkg/io/#Reader):

type Reader interface {
        Read(p []byte) (n int, err error)
}

Writer hat diese Definition (https://golang.org/pkg/io/#Writer):

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

Das bedeutet in der Praxis, dass für unsere Quelle ein Datentyp Implementiert werden muss, das die Methode Read() wie oben beinhaltet und für das Datenziel (Senke) ein Datentyp mit der entsprechenden Methode Write(). Praktischerweise bieten fast alle Schreib- und Leseoperationen in Go (genauer: in der Standardbibliothek) diese Implementierung der Methoden Read() und Write() und können damit in io.Copy() benutzt werden.

Auch hier soll ein konkretes Beispiel zeigen, wie die Schnittstellen von io.Copy() genutzt werden können:

package main

import (
    "fmt"
    "io"
    "os"
)

func main() {
    file, err := os.Open("/etc/passwd")
    defer file.Close()
    if err != nil {
        fmt.Println("Fehler aufgetreten:", err)
        return
    }
    _, err = io.Copy(os.Stdout, file)
    if err != nil {
        fmt.Println("Fehler aufgetreten:", err)
        return
    }
}

Es wird mit os.Open() ein os.File-Objekt erzeugt. Auf diesem Objekt ist die Methode Read(b []byte) (n int, err error) definiert, die genau dem von io.Copy() Interface Reader entspricht.

os.Stdout ist ein Objekt, das die Methode Write() in der geforderten Ausprägung implementiert. Dadurch kann nun io.Copy() aufgerufen werden. Intern benutzt io.Copy() die Methode Read() auf der Quelle und Write() für das Ziel. Es wird vom Compiler sichergestellt, dass die beiden Methoden auf den Objekten auch definiert sind.

Noch zwei Hinweise zu dem Programm oben: mit defer wird eine Funktion für später vorgemerkt, sie wird ausgeführt, sobald die Funktion verlassen wird (z.B. nach einem return). Der Unterstrich (_) in der Zeile mit io.Copy() hat die Bedeutung, dass der Wert ignoriert wird und nur der zweite Rückgabewert (der Fehlerwert) von Bedeutung ist.


1. Strenggenommen ist das keine weitere Variante. Die zuerst beschriebene Form hat lediglich keine notwendige Funktionsdefinition, entspricht also »allen Typen«.