©Sergey Emelyanov 2025 | Alle Rechte vorbehalten
Wie Entwickler Schnittstellen in Go verwenden, was man beachten sollte und wie eine Schnittstelle zu einem konkreten Typ wird.
Schnittstellen sind ein wichtiges Element statisch typisierter Sprachen, um Funktionen mit verschiedenen Eingangswerten zu schreiben. Hier versuche ich, einen Überblick darüber zu geben, wie diese Möglichkeit in Go implementiert ist und wie sie sich auf den Code auswirkt.
Auf die wichtigsten Grundlagen und die Syntax folgt eine detailliertere Betrachtung der idiomatischen Verwendung von Schnittstellen. Besonderes Augenmerk wird darauf gelegt, wie Abhängigkeiten im Code vermieden oder wie Entwickler sie reduzieren können. Schließlich ist dies eine der wichtigsten Eigenschaften von Schnittstellen.
Die Programmiersprache Go ist statisch typisiert. Daher behält eine Variable nach ihrer Deklaration immer ihren Typ innerhalb ihres Gültigkeitsbereichs. Daher kann eine Variable vom Typ String nur Strings aufnehmen. Dynamisch typisierte Sprachen wie JavaScript oder Python sind flexibler.
Funktionen müssen jedoch in der Lage sein, mit verschiedenen Typen zu arbeiten. Das grundlegende Konzept wird als Polymorphismus bezeichnet. Es geht davon aus, dass bestimmte Typen zu einem einzigen Typ zusammengefasst werden können. Dazu wird in Go, wie auch in anderen Programmiersprachen, eine Schnittstelle verwendet.
type Stringer interface {
String() string
}
func printer(s Stringer) {
fmt.Println(s.String())
}
Schnittstellen sind auch ein Typ. In unserem Beispiel hat die Stringer-Schnittstelle nur eine Methode. Das ist typisch für Go. Gemäß dem Prinzip der Schnittstellentrennung ist es sinnvoller, viele kleine Schnittstellen zu definieren als eine große. Dieser Ansatz entspricht den SOLID-Prinzipien (I = Interface Segregation Principle), die von dem Verfechter von Clean Code, Robert C. Martin (Uncle Bob), formuliert wurden. Nach diesem Prinzip sollte eine Schnittstelle so viele Methoden haben, wie sie benötigt.
Das Design der Go-Sprache unterstützt diesen Ansatz bewusst. Damit ein Typ eine Schnittstelle implementiert, muss er nur die entsprechenden Methoden haben, ansonsten sind keine zusätzlichen Anweisungen wie implements stringers erforderlich. Die Überprüfung der Implementierung übernimmt der Compiler.
Für kleine Schnittstellen, die aus ein bis drei Methoden bestehen, hat sich eine Namenskonvention etabliert. Sie wird konsequent in der Standardbibliothek angewendet. Der Name der Schnittstelle besteht aus dem Namen der Methode und den Buchstaben "er" am Ende. Die Methode String() wird zur Stringer-Schnittstelle und Read() zur Reader-Schnittstelle. Der Vorteil dieser Konvention liegt auf der Hand: Auch ohne Dokumentation ist klar, welche Methoden die Schnittstelle ReadWriter oder ReadWriteCloser enthalten.
Entwickler, die bereits mit Go programmiert haben, kennen sicherlich die Stringer-Schnittstelle. Sie ist im fmt-Paket der Standardbibliothek definiert. Die Schnittstelle im obigen Beispiel ist eine Kopie davon. In diesem Zusammenhang stellt sich die Frage, ob es sinnvoll ist, Schnittstellen selbst zu definieren, anstatt fertige zu verwenden? Wie steht es mit dem Prinzip "do not repeat yourself"?
In diesem Fall ist der Einwand berechtigt. Für Schnittstellen, die Schnittstellen zu verschiedenen Implementierungen darstellen, ist es jedoch durchaus zulässig, dass der Benutzer sie selbst schreibt. Dies gilt auch dann, wenn dies zu Duplizierungen im Code führt. Auf diese Weise kann der Code unabhängiger gestaltet werden. Es ist nicht notwendig, das fmt-Paket zu importieren, wenn wir nur eine kleine Schnittstelle definieren müssen.
Für Schnittstellen aus der Standardbibliothek spielt die Minimierung von Abhängigkeiten keine besondere Rolle, da sie ein direkter Bestandteil der Sprache sind. Daher soll dieses Beispiel in erster Linie das zugrunde liegende Prinzip veranschaulichen. Da die Stringer-Schnittstelle Teil des Standards ist, gibt es in der Bibliothek viele Implementierungen davon. Zum Beispiel kann der Typ time.Time an den Drucker übergeben werden:
func main() {
t := time.Now()
printer(t)
}
// 2009-11-10 23:00:00 +0000 UTC m=+0.000000001
Das Beispiel funktioniert, selbst wenn das Paket time die Stringer-Schnittstelle nicht kennt. In Go ist es nicht erforderlich, dass das Paket time explizit angibt, welche Schnittstellen vom Typ time.Time implementiert werden. Die Überprüfung der Implementierung übernimmt der Go-Compiler. Da der Typ time.Time eine String-Methode hat, kann er zusammen mit dem Printer verwendet werden.
Gemäß dieser Logik wird eine Schnittstelle immer entwickelt, um die Anforderungen einer Funktion zu erfüllen. Somit bestimmt die Funktion, welche Methoden die Schnittstelle benötigt. Schnittstellen sollten so klein wie möglich gehalten werden – gemäß dem Prinzip der Schnittstellensegregation – und Entwickler erhalten automatisch eine stärkere Abstraktion.
Ein nicht ganz abstraktes Beispiel ist die Funktion CheckContact, die Daten vom Typ Contact auswertet, die aus einer Datenbank geladen werden. Um diesen Prozess von einer bestimmten Datenbank zu entkoppeln, kann eine Loader-Schnittstelle definiert werden. Gemäß der Namenskonvention hat diese Schnittstelle nur eine Load()-Methode, die wiederum den Typ ContactKey als Eingabe und Contact als Ausgabe hat. Die Anforderungen an den Loader werden durch die Funktion CheckContact definiert.
type Loader interface {
Load(ContactKey) (Contact, error)
}
func CheckContact(uk ContactKey, l Loader) error {
u, err := l.Load(uk)
if err != nil {
return err
}
// ...
}
Die Implementierung besteht nur aus einer Karte, in die Testdaten direkt eingegeben werden können. Wenn ein Eintrag fehlt, gibt die Implementierung einen Fehler zurück. Idealerweise sollte der Typ LoadTester direkt in der Testdatei gespeichert werden.
Es ist sehr einfach, Interfaces in Go zu definieren und zu verwenden. In der Praxis führt dies oft dazu, dass Entwickler zu viele Interfaces für Funktionen definieren. Nur weil es ohne großen Aufwand möglich ist, heißt das nicht, dass es immer sinnvoll ist. Wenn im Code zu viele oder sogar unnötige Interfaces definiert werden, sprechen wir von Interface-Verschmutzung. Aber wie viel ist zu viel?
Wie bereits eingangs erläutert, sind Entwickler gezwungen, auf Interfaces zurückzugreifen, wenn sie eine Funktion für mehr als einen Typ verwenden wollen. Ausgehend davon lassen sich unnötige Interfaces leicht identifizieren. Interfaces, die nur eine Implementierung haben, sind unnötig. Stattdessen ist es ausreichend, in der Funktion einen konkreten Typ zu verwenden und so unnötige Abstraktion zu vermeiden.
Möglicherweise haben Entwickler mit C# oder Java gearbeitet und es schien ihnen natürlich, Interfaces zu erstellen, bevor sie die Implementierung schreiben. In Go ist das jedoch nicht der Fall.
Wie bereits erwähnt, werden Interfaces erstellt, um Abstraktionen zu schaffen. Und die wichtigste Vorsichtsmaßnahme beim Programmieren mit Abstraktionen ist, sich daran zu erinnern, dass Abstraktionen nicht erstellt, sondern aufgedeckt werden müssen. Was bedeutet das? Das bedeutet, dass man nicht anfangen sollte, Abstraktionen im Code zu erstellen, wenn es keinen unmittelbaren Grund dafür gibt. Wir sollten keine Interfaces entwerfen, sondern warten, bis wir sie brauchen. Mit anderen Worten, wir sollten ein Interface erstellen, wenn wir es brauchen, und nicht, wenn wir vorhersehen, dass wir es brauchen könnten.
Was ist das Hauptproblem bei der übermäßigen Verwendung von Interfaces? Die Antwort ist, dass sie den Codefluss verkomplizieren. Das Hinzufügen einer nutzlosen Abstraktionsebene bringt keinen Nutzen; sie schafft eine nutzlose Abstraktion, die das Lesen, Verstehen und Nachvollziehen des Codes erschwert. Wenn wir keinen triftigen Grund haben, ein Interface hinzuzufügen, und nicht klar ist, wie das Interface den Code verbessert, sollte man seine Zweckmäßigkeit in Frage stellen. Warum nicht die Implementierung direkt aufrufen?
Beim Aufruf einer Methode über ein Interface können wir auch mit dem Problem der Leistungseinbußen konfrontiert werden. Um den konkreten Typ zu finden, auf den das Interface verweist, muss eine Suche in der Hash-Tabellen-Datenstruktur durchgeführt werden. In vielen Fällen ist dies jedoch kein Problem, da der Overhead minimal ist.
"Daher sollten wir vorsichtig sein, wenn wir Abstraktionen in unserem Code erstellen - Abstraktionen sollten entdeckt und nicht erstellt werden. Normalerweise machen wir Softwareentwickler unseren Code zu kompliziert, indem wir versuchen, die ideale Abstraktionsebene zu erraten, basierend auf dem, was wir glauben, dass wir in Zukunft brauchen könnten. Dieser Prozess sollte vermieden werden, da er in den meisten Fällen unseren Code mit unnötigen Abstraktionen verschmutzt und ihn schwieriger verständlich macht.
Entwerfen Sie keine Interfaces, sondern decken Sie sie auf."
-Rob Pike
Versuchen wir nicht, ein Problem abstrakt zu lösen, sondern tun wir das, was jetzt gelöst werden muss. Und zu guter Letzt: Wenn nicht klar ist, wie ein Interface den Code verbessert, sollte man wahrscheinlich darüber nachdenken, es zu entfernen, um den Code zu vereinfachen.
Go bietet Werkzeuge, mit denen Schnittstellen während der Laufzeit des Programms genauer untersucht werden können. Mit ihrer Hilfe können Schnittstellenvariablen im Code detaillierter betrachtet werden. Es ist jedoch wichtig zu beachten, dass der Basiswert nur während der Laufzeit des Programms zugewiesen wird.
Das erste Werkzeug wird verwendet, um auf Nil zu prüfen. Dieses Muster wird üblicherweise zur Fehlerbehandlung verwendet.
err := openSomething(name)
if err != nil {
// error handling
}
Die Prüfung auf Nil zeigt nur, ob ein Wert existiert, aber nicht, welcher Wert sich dahinter verbirgt. Dafür gibt es den Typ-Switch und die Typ-Assertion.
Mit dem Typ-Switch können Schnittstellenvariablen auf mehrere verschiedene Typen geprüft werden. Die entsprechende Prüfung erfolgt in der Case-Anweisung. In diesem Block ist der Typ der Variablen bekannt, und somit kann der Basistyp direkt verwendet werden.
func myFunc(s Stringer) {
switch v := s.(type) {
case nil:
fmt.Println("nil Pointer")
case *bytes.Buffer:
fmt.Println("bytes.Buffer", v.Len())
default:
fmt.Println("Unknown type")
}
}
Das Beispiel zeigt die Syntax eines Typ-Switches. Hier ist auch eine Prüfung auf Nil möglich. Im Falle von *bytes.Buffer kann über die Variable v auf alle Felder, Eigenschaften oder Methoden dieses Typs zugegriffen werden. In diesem Beispiel wäre das die Methode Len().
Ein Typ-Switch ist immer dann nützlich, wenn mehrere verschiedene Typen geprüft werden müssen. Wenn Entwickler jedoch nur einen bestimmten Typ prüfen wollen, ist es besser, eine Typzusicherung zu verwenden. In diesem Fall wird versucht, einer Variablen einen bestimmten Typ zuzuweisen.
Die entsprechende Syntax bleibt einfach. Nach der Interface-Variablen steht der Typ in runden Klammern. Eine Typzusicherung hat immer zwei Rückgabeparameter, wobei der zweite Parameter optional ist. Wenn er nicht angefordert wird, kann es zu einer Panik kommen. Dies geschieht, sobald der Typ aus der Interface-Hülle nicht dem erwarteten Typ entspricht.
func myFunc(r io.Reader) {
buf, ok := r.(*bytes.Buffer)
if ok {
fmt.Println(buf.Bytes())
}
}
Hier wird eine Standard-Typzusicherungsvorlage gezeigt. Die Variable ok prüft, ob die Konvertierung erfolgreich war. Die Variable buf hat den Typ *bytes.Buffer und kann als solche verwendet werden. Wenn die Typzusicherung nicht erfolgreich war, hat buf den Nullwert des entsprechenden Typs. Da es sich um einen Zeiger handelt, ist dies nil.
Typschalter und Typzusicherung funktionieren auch mit Schnittstellentypen. Das bedeutet, dass eine Schnittstelle in eine andere Schnittstelle konvertiert werden kann.
func ReadAndClose(r io.Reader) ([]byte, error) {
type closer interface {
Close()
}
c, ok := r.(closer)
if ok {
defer c.Close()
}
return ioutil.ReadAll(r)
}
Hier wird beispielhaft die Konvertierung einer Schnittstelle in eine andere beschrieben. Eine Typzusicherung ist hier sehr nützlich. Dies liegt daran, dass die Methode Close() für die Funktion nicht unbedingt erforderlich ist. Die Funktion kann diese Methode aber trotzdem verwenden. If ok stellt sicher, dass die Methode nur verwendet wird, wenn sie existiert.
In welchen Fällen sollten wir Schnittstellen erstellen? Es gibt drei gängige Anwendungsfälle, in denen Schnittstellen einen echten Mehrwert für die Anwendung bieten:
Um die Verwendung von Schnittstellen zu veranschaulichen, betrachten wir ein Beispiel aus dem wirklichen Leben. Stellen Sie sich vor, wir haben ein System zur Verwaltung verschiedener Arten von Medieninhalten wie Bücher, Lieder und Filme. Obwohl diese Medientypen unterschiedlich sind, haben sie einige Gemeinsamkeiten. Zum Beispiel können sie alle auf einem Bildschirm angezeigt werden und haben einen Titel.
Wir können eine Media-Schnittstelle wie folgt definieren:
type Media interface {
Display() string
Title() string
}
Die Display-Methode gibt eine Zeichenkette zurück, die die Art und Weise darstellt, wie die Mediendatei angezeigt wird, und die Title-Methode den Titel der Mediendatei. Es wird davon ausgegangen, dass jeder Typ, der diese beiden Methoden implementiert, die Media-Schnittstelle erfüllt.
Definieren wir nun einige Typen, die diese Schnittstelle implementieren:
type Book struct {
bookTitle string
author string
}
func (b Book) Display() string {
return "Book: " + b.bookTitle + " by " + b.author
}
func (b Book) Title() string {
return b.bookTitle
}
type Song struct {
songTitle string
artist string
}
func (s Song) Display() string {
return "Song: " + s.songTitle + " by " + s.artist
}
func (s Song) Title() string {
return s.songTitle
}
Hier implementieren sowohl Book als auch Song das Interface Media, da sie die Methoden Display und Title definieren.
Eine der mächtigsten Eigenschaften von Interfaces ist, dass man mit ihnen Funktionen schreiben kann, die mit jedem Typ funktionieren, der das Interface erfüllt. Wir können zum Beispiel eine Funktion schreiben, die jeden Medientyp anzeigt:
func displayMedia(m Media) {
fmt.Println(m.Display())
}
Diese Funktion kann mit Book, Song oder jedem anderen Typ verwendet werden, der das Media-Interface implementiert. Das ist sehr praktisch, da es ermöglicht, universellen Code zu schreiben, der mit den unterschiedlichsten Typen arbeiten kann.
Ein weiteres sehr wichtiges Beispiel ist die Abstrahierung unseres Codes von der Implementierung. Wenn wir uns auf eine Abstraktion anstelle einer konkreten Implementierung verlassen, kann die Implementierung der Funktionalität einfach durch eine andere Logik ersetzt werden, ohne den Hauptteil des Codes zu verändern. Das sagt uns das Substitutionsprinzip von Barbara Liskov.
Einer der Vorteile der Entkopplung hängt eng mit Unit-Tests zusammen. Stellen wir uns vor, wir wollen eine Methode CreateNewUser schreiben, deren Aufgabe es ist, einen neuen Benutzer in der Datenbank zu speichern. Bei der Implementierung haben wir uns entschieden, uns auf eine konkrete Implementierung zu stützen und mit der Struktur mysql.Store in der Datenbank zu speichern:
type UserService struct {
store mysql.Store
}
func (us UserService) CreateNewUser(id string) error {
user := User{id: id}
return us.store.StoreUser(user)
}
Was machen wir nun mit den Tests? Da der UserService auf einer bestehenden Implementierung basiert, um den Benutzer zu speichern, ist die einzige Möglichkeit, diese Funktionalität zu testen, nur mit Integrationstests, die von uns verlangen, eine MySQL-Instanz hochzufahren. Oder eine alternative Technologie wie go-sqlmock zu verwenden. Obwohl Integrationstests sehr nützlich sind, sind sie nicht immer das, was wir verwenden möchten. Um mehr Flexibilität zu erhalten, müssen wir uns von der Implementierung abstrahieren, und dies kann mit einer Schnittstelle wie folgt geschehen:
type userStorer interface {
StoreUser(User) error
}
type UserService struct {
storer userStorer
}
func (us UserService) CreateNewUser(id string) error {
user := User{id: id}
return us.store.StoreUser(user)
}
Da die Speicherung des Benutzers jetzt über eine Schnittstelle erfolgt, gibt uns dies mehr Flexibilität bei der Art und Weise, wie wir die Methode testen wollen. Zum Beispiel können wir:
Eingeschränktes Verhalten
Das letzte Beispiel für die korrekte Verwendung von Schnittstellen mag auf den ersten Blick nicht so intuitiv erscheinen. Es geht um eingeschränktes Verhalten. Wir wollen dies an einem Beispiel veranschaulichen. Betrachten wir die Situation, in der Sie eine Konfigurationsstruktur haben, die Sie in Ihrer Anwendung weitergeben möchten, aber die Änderung ihrer Eigenschaften einschränken möchten, um mögliche Nebenwirkungen zu vermeiden.
type IntConfig struct {
Value int
}
type intConfigGetter interface {
Get() int
}
func (ic IntConfig) Get() int {
return ic.Value
}
type Cache struct {
cfg intConfigGetter
}
func NewCache(cfg intConfigGetter) *Cache {
return &Cache{cfg: cfg}
}
func (f *Cache) Operate() {
fmt.Println("Config Value: ", f.cfg.Get())
}
Im obigen Beispiel implementiert die Struktur IntConfig die Schnittstelle intConfigGetter, die eine einzige Methode Get() hat. Diese Methode wird verwendet, um den Konfigurationswert abzurufen. Die Struktur Cache wiederum erhält die Schnittstelle intConfigGetter in ihrer Factory-Funktion NewCache(), wodurch sie Zugriff auf die Methode Get() erhält, aber die Modifikation von Value einschränkt.
Der Client der Funktion NewCache kann weiterhin die Struktur IntConfig übergeben, da sie die Schnittstelle intConfigGetter implementiert. Innerhalb der Struktur Cache können wir jedoch die Konfiguration nur in der Methode Operate lesen, aber nicht modifizieren.
func main() {
cfg := IntConfig{Value: 10}
cache := NewCache(cfg)
cache.Operate()
// Outputs: Config Value: 10
}
In diesem Fall kann die Funktion NewCache jedes Objekt akzeptieren, das die Schnittstelle intConfigGetter erfüllt, wodurch die Funktion von dem spezifischen Typ IntConfig getrennt wird. Die von dieser Schnittstelle bereitgestellte Funktionalität ist jedoch begrenzt, d. h. sie kann nur einen Wert empfangen, ihn aber nicht ändern.
Die Implementierung von Schnittstellen in Go unterscheidet sich nur geringfügig von anderen Programmiersprachen. Diese Unterschiede sind jedoch auf bewusste Entscheidungen zurückzuführen. Go unterstützt aufgrund seines spezifischen Designs das Prinzip der Trennung von Schnittstellen. Dadurch lassen sich mühelos kleine effiziente Schnittstellen definieren.
Der Compiler stellt sicher, dass die Variablen mit den entsprechenden Schnittstellen kompatibel sind. Im Idealfall werden Schnittstellen zusammen mit der Funktion definiert, die sie verwendet. Wenn es jedoch Schnittstellen gibt, die nur eine Implementierung haben, ist dies ein Zeichen für eine Schnittstellenkollision. In solchen Fällen sollten Sie prüfen, ob eine Schnittstelle tatsächlich erforderlich ist.
Schnittstellen verbergen Informationen über die dahinter liegenden Variablen. Wenn sie verwendet werden, kann auf die zugrunde liegende Variable nicht direkt zugegriffen werden. Entwickler sollten leere Schnittstellen vermeiden, da sie keinen Kontext liefern. Mit einem Typschalter und einer Typanweisung kann ein bestimmter Typ sicher aus der Schnittstellenschale extrahiert werden.
©Sergey Emelyanov 2025 | Alle Rechte vorbehalten