Interfaces in Go: reine Typ-Sache

Wie Entwickler Go-Interfaces verwenden, was sie dabei beachten sollten und wie aus einem Interface wieder ein konkreter Typ wird.

In Pocket speichern vorlesen Druckansicht 13 Kommentare lesen
Lesezeit: 14 Min.
Von
  • Andreas Schröpfer
Inhaltsverzeichnis

Interfaces sind in statisch typisierten Sprachen ein wichtiges Element, um Funktionen mit unterschiedlichen Eingangswerten schreiben zu können. Dieser Artikel gibt einen Überblick, wie das Feature in Go umgesetzt ist und wie es sich auf den Code auswirkt. Nach den wichtigsten Grundlagen und der Syntax folgt ein genauerer Blick auf die idiomatische Verwendung von Interfaces. Dabei liegt ein besonderer Fokus darauf, wie sich Abhängigkeiten innerhalb des Codes vermeiden lassen, beziehungsweise wie Entwicklerinnen und Entwickler sie abbauen können. Denn das ist eine der wichtigsten Eigenschaften von Interfaces.

Für die abschließende Untersuchung von Interface-Variablen zur Laufzeit und deren Rückumwandlung zu einem konkreten Typ sind Type-Switch und Type-Assertion die Werkzeuge der Wahl. Diese Elemente kommen in der Praxis zwar nicht oft zum Einsatz, es ist dennoch hilfreich sie zu kennen.

Die Programmiersprache Go ist statisch typisiert. Daher behält eine einmal deklarierte Variable innerhalb ihres Gültigkeitsbereiches immer ihren Typ. Eine Variable vom Typ string kann somit lediglich Strings aufnehmen. Dynamisch typisierte Sprachen wie JavaScript oder Python verhalten sich da flexibler.

Funktionen sollen jedoch mit unterschiedlichen Typen umgehen können. Das zugrunde liegende Konzept nennt sich Polymorphismus. Es setzt voraus, dass sich unterschiedliche konkrete Typen zu einem Typ zusammenfassen lassen. Dazu nutzt Go, wie auch andere Programmiersprachen, das Interface.

Listing 1: Definition und Verwendung eines Interface

type Stringer interface {
    String() string
}

func meinPrinter(s Stringer) {
        fmt.Println(s.String())
}

Wie Listing 1 zeigt, sind Interfaces auch ein Typ. Das Stringer-Interface besitzt nur eine Methode. Das ist typisch für Go. Denn dem Interface-Segregation-Prinzip zufolge ist es sinnvoller viele kleine Interfaces zu definieren als ein großes. Die Vorgehensweise entspricht den von Clean-Code-Verfechter Robert C. Martin (Uncle Bob) formulierten SOLID-Prinzipien (I = Interface Segregation Principle). Demnach soll ein Interface nur so viele Methoden besitzen, wie unbedingt notwendig sind.

Das Design der Sprache Go unterstützt diese Herangehensweise bewusst. Damit ein Typ ein Interface implementiert, muss dieses nur die definierten Methoden besitzen, ansonsten sind keine weiteren Anweisungen wie implements Stringer notwendig. Die Prüfung auf die Implementierung übernimmt der Compiler. Da Go keine Klassen kennt, beziehen sich die Interfaces immer auf einen Typ.

Bei kleinen Interfaces, die ein bis drei Methoden umfassen, hat sich eine Namenskonvention durchgesetzt. Sie wird in der Standardbibliothek konsequent angewendet. Der Name für das Interface setzt sich zusammen aus dem Methodennamen plus einem "er" am Ende. Aus der Methode String() wird ein Stringer-Interface oder aus Read() ein Reader-Interface. Der Vorteil der Konvention liegt auf der Hand: Auch ohne die Dokumentation, ist klar, welche Methoden das ReadWriter- oder das ReadWriteCloser-Interface enthalten.

Entwicklerinnen und Entwickler, die bereits in Go programmiert haben, dürften das Stringer-Interface bereits kennen. Es ist im fmt-Paket der Standardbibliothek definiert. Das Interface aus Listing 1 leitet sich als Kopie davon ab. Das wirft die Frage auf, ob solche doppelten Interface-Definitionen sinnvoll sind – denn sie widersprechen dem Grundsatz "don't repeat yourself".

Der Einwand ist an dieser Stelle gerechtfertigt. Bei Interfaces, die Schnittstellen zu verschiedenen Implementierungen darstellen, ist es allerdings in Ordnung, wenn der Verwender seine Schnittstelle definiert. Das gilt selbst dann, wenn es dabei zu Doppelungen im Code kommt. So lässt sich der Code unabhängiger gestalten. Ein Import des Pakets fmt entfällt, wenn der Stringer eigens definiert ist. Die beiden Gopher in Abbildung 1 sind zusammengekettet. Die Kette symbolisiert eine überflüssige Abhängigkeit zwischen zwei Paketen. Durch Interfaces eröffnet sich die Möglichkeit, diese Ketten zu zerschneiden.

Abhängigkeiten aufbrechen (Abb. 1).

Bei Interfaces aus der Standardbibliothek spielt Entkopplung keine große Rolle, da sie direkter Bestandteil der Sprache sind. Das Beispiel soll daher vor allem das Prinzip dahinter verdeutlichen. Da das Stringer-Interface zum Standard gehört, existieren viele Implementierungen innerhalb der Standardbibliothek. So lässt sich beispielsweise der Typ time.Time an meinPrinter übergeben:

Listing 2: time.Time als Stringer

func main() {  
        t := time.Now()
        meinPrinter(t)
}
// Output im Playground:
// 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

Das Beispiel funktioniert, obwohl das time-Paket das Stringer-Interface aus Listing 1 gar nicht kennt. In Go ist es nicht notwendig, dass das Time-Paket explizit angibt, welche Interfaces durch den Typ time.Time implementiert werden. Die Prüfung auf die Implementierung übernimmt der Go-Compiler. Da der Typ time.Time eine String-Methode besitzt, lässt er sich in Verbindung mit meinPrinter verwenden.

Nach dieser Logik wird das Interface immer auf die Anforderungen der Funktion ausgeprägt. Die Funktion definiert somit, welche Methoden ein Interface benötigt. Interfaces lassen sich dadurch – in Übereinstimmung mit dem Interface-Segregation-Prinzip – möglichst klein halten, und Entwickler erhalten automatisch eine stärkere Abstraktion.

Ein nicht ganz so abstraktes Beispiel wäre eine Funktion CheckUser, die Daten des Typs User auswertet, der aus einer Datenbank zu laden ist. Um diesen Vorgang von einer konkreten Datenbank zu entkoppeln, lässt sich ein Loader-Interface definieren. Dieses Interface besitzt, gemäß Namenskonvention, nur die Methode Load(), die wiederum den Typ UserKey als Input und den User als Output hat. Die Anforderungen an den Loader sind dabei durch die Funktion CheckUser definiert.

Listing 3: Definition des Interface bei der Funktion

type Loader interface {
        Load(UserKey) (User, error)
}

func CheckUser(uk UserKey, l Loader) error {
        u, err := l.Load(uk)
        if err != nil {
                return err
        }
        // ...
}

Durch Verwenden des Interface ist Listing 3 komplett von einer Implementierung des Loaders entkoppelt. Es spielt dabei keine Rolle, ob der User über eine Datenbank, das File-System oder eine andere Quelle geladen wird. Die Implementierungen können somit in verschiedenen unabhängigen Paketen erfolgen.

Ein weiterer Vorteil ergibt sich beim Erstellen von Tests. Um die Funktion CheckLoader testen zu können, benötigen Entwickler eine Implementierung des Loaders. Dieser kann sich ohne große Umwege allein auf die Testanforderungen konzentrieren. Das geht umso leichter, je kleiner die Interfaces sind. So lässt sich alles mit wenig Testcode auch ohne Datenbankanbindung testen.

Listing 4: Test-Implementierung des Loader-Interface

type LoadTester struct {
        users map[UserKey]User
}

func (l LoadTester) Load(uk UserKey) (User, error) {
        u, ok := users[uk]
        if !ok {
                return u, errors.New("User not found!")
        }
        return u, nil
}

Die Implementierung aus Listing 4 besteht nur aus einer Map, in der sich Testdaten direkt erfassen lassen. Fehlt ein Eintrag, gibt die Implementierung einen Fehler zurück. Der Typ LoadTester sollte dabei idealerweise direkt in der Testdatei gespeichert sein.