Zurück zum Blog

Das Go-Keyword interface: Implizite Interfaces und nil-Fallen

So funktioniert das Go-Keyword interface: implizite Erfüllung, Pointer-Receiver, kleine Interfaces, any, Type Assertions, die nil-Error-Falle und Constraints.

Das Go-Keyword interface: Implizite Interfaces und nil-Fallen

Das Keyword interface deklariert eine Menge von Methoden. Jeder Typ, der diese Methoden hat, erfüllt das Interface automatisch. Es gibt kein Keyword implements und keine Liste von Interfaces am Typ. Ein Struct, das Jahre vor einem Interface geschrieben wurde, kann es also trotzdem erfüllen. Seit Go 1.18 kann ein Interface neben Methoden auch Typen auflisten. Damit wird es zu einem Constraint für generischen Code (Go spec).

Kurzfassung

  • type Notifier interface { Notify(...) error } deklariert ein Interface. Jeder Typ mit einer passenden Methode Notify ist ein Notifier.
  • Die Erfüllung ist implizit. Mit var _ Notifier = (*SlackNotifier)(nil) prüft der Compiler sie beim Build.
  • Eine Methode mit Pointer-Receiver gehört zu *T, nicht zu T. Die Zuweisung eines T scheitert mit method Notify has pointer receiver.
  • Halte Interfaces klein und deklariere sie in dem Package, das sie verwendet. Nimm Interfaces entgegen und gib konkrete Typen zurück.
  • any ist ein Alias für interface{} und nimmt einen Wert beliebigen Typs auf. Den Wert bekommst du mit v, ok := x.(T) oder einem Type Switch zurück.
  • Ein Interface ist nur dann nil, wenn sowohl sein Typ als auch sein Wert nicht gesetzt sind. Gibst du einen nil-*ValidationError als error zurück, ist err != nil trotzdem true.
  • Interfaces mit Typlisten wie ~int64 | ~float64 sind Constraints. Sie funktionieren in Typparameterlisten und nirgendwo sonst.

Wie deklariert man ein Interface in Go?

Schreib type, einen Namen und interface, gefolgt von den Methodensignaturen. Hier ist ein Alerting-System, das dieselbe Nachricht an Slack und per E-Mail schickt:

example.gogo
package main

import (
	"context"
	"fmt"
)

type Notifier interface {
	Notify(ctx context.Context, msg string) error
}

type SlackNotifier struct {
	Channel string
}

func (s SlackNotifier) Notify(ctx context.Context, msg string) error {
	fmt.Printf("slack #%s: %s\n", s.Channel, msg)
	return nil
}

type EmailNotifier struct {
	To string
}

func (e EmailNotifier) Notify(ctx context.Context, msg string) error {
	fmt.Printf("email %s: %s\n", e.To, msg)
	return nil
}

func alertAll(ctx context.Context, notifiers []Notifier, msg string) {
	for _, n := range notifiers {
		if err := n.Notify(ctx, msg); err != nil {
			fmt.Println("notify failed:", err)
		}
	}
}

func main() {
	notifiers := []Notifier{
		SlackNotifier{Channel: "oncall"},
		EmailNotifier{To: "[email protected]"},
	}
	alertAll(context.Background(), notifiers, "payment API p99 above 2s")
}
example.texttext
slack #oncall: payment API p99 above 2s
email [email protected]: payment API p99 above 2s

alertAll kennt nur die Methode Notify. Es ist ihm egal, ob es mit Slack, E-Mail oder einem PagerDuty-Client spricht, den jemand nächsten Monat hinzufügt. Jeder Wert im Slice trägt seinen konkreten Typ mit sich, und der Aufruf landet zur Laufzeit bei der Methode dieses Typs.

Ein Interface kann andere Interfaces einbetten. Die eingebetteten Methodenmengen werden zusammengeführt. So baut die Standardbibliothek io.ReadWriter aus io.Reader und io.Writer zusammen:

example.gogo
type ReadWriter interface {
	Reader
	Writer
}

Ein Typ erfüllt io.ReadWriter, wenn er sowohl Read als auch Write hat. *os.File und net.Conn haben beide, *strings.Reader dagegen nicht, weil ihm die Methode Write fehlt.

Wie implementiert ein Typ ein Interface in Go?

Indem jede Methode des Interfaces mit exakt derselben Signatur in seiner Methodenmenge steht. Go nennt das implizite Erfüllung. Das ist der wichtigste Unterschied zu Java oder C#, wo eine Klasse jedes Interface, das sie implementiert, beim Namen nennen muss.

So bleiben Packages entkoppelt. SlackNotifier importiert das Package nicht, das Notifier deklariert, und muss nicht einmal wissen, dass Notifier existiert. Du kannst heute ein Interface schreiben, das Typen aus der Standardbibliothek oder aus einem Drittanbieter-Package bereits erfüllen.

Fehlt eine Methode, meldet der Compiler das an der Stelle, an der der Wert als Interface verwendet wird:

example.gogo
type PagerDutyNotifier struct {
	RoutingKey string
}

func (p PagerDutyNotifier) Send(ctx context.Context, msg string) error {
	return nil
}

var _ Notifier = PagerDutyNotifier{}
example.texttext
./main.go:20:18: cannot use PagerDutyNotifier{} (value of struct type PagerDutyNotifier) as Notifier value in variable declaration: PagerDutyNotifier does not implement Notifier (missing method Notify)

Existiert die Methode, aber mit einer anderen Signatur, zeigt der Fehler beide Versionen nebeneinander:

example.texttext
./main.go:16:18: cannot use PagerDutyNotifier{} (value of struct type PagerDutyNotifier) as Notifier value in variable declaration: PagerDutyNotifier does not implement Notifier (wrong type for method Notify)
		have Notify(string) error
		want Notify(context.Context, string) error

Wie prüft man zur Compile-Zeit, ob ein Typ ein Interface implementiert?

Deklariere eine Variable auf Package-Ebene mit dem Blank Identifier:

example.gogo
var _ Notifier = (*SlackNotifier)(nil)

Die Zeile alloziert nichts und führt nie Code aus. Sie fragt den Compiler nur, ob sich ein *SlackNotifier einem Notifier zuweisen lässt. Der Build schlägt also fehl, sobald jemand Notify umbenennt oder ändert. Ohne diese Zeile merkst du es erst dort, wo der Typ zum ersten Mal als Notifier verwendet wird, und das kann in einem ganz anderen Package sein. Das Go-Keyword var zeigt denselben Trick mit http.Handler.

Warum verhindert ein Pointer-Receiver, dass ein Interface erfüllt wird?

Weil die Methode zum Pointer-Typ gehört. Jeder Typ hat eine Methodenmenge, und dafür gelten diese Regeln (Go spec):

  • Die Methodenmenge von T enthält die Methoden, die mit einem Value-Receiver (t T) deklariert sind.
  • Die Methodenmenge von *T enthält diese und zusätzlich die Methoden, die mit einem Pointer-Receiver (t *T) deklariert sind.

Ein Notifier, der Nachrichten sammelt, muss sich selbst verändern. Er verwendet deshalb einen Pointer-Receiver:

example.gogo
type BatchNotifier struct {
	mu      sync.Mutex
	pending []string
}

func (b *BatchNotifier) Notify(ctx context.Context, msg string) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.pending = append(b.pending, msg)
	return nil
}

func main() {
	var n Notifier = BatchNotifier{}
	fmt.Println(n)
}
example.texttext
./main.go:26:19: cannot use BatchNotifier{} (value of struct type BatchNotifier) as Notifier value in variable declaration: BatchNotifier does not implement Notifier (method Notify has pointer receiver)

Die Lösung ist var n Notifier = &BatchNotifier{}. Go lehnt die Value-Variante absichtlich ab. Ein Interface, das einen BatchNotifier hält, würde eine Kopie halten. Notify würde dann an den Slice pending der Kopie anhängen, während das Original unverändert bliebe. Auch ein Wechsel von Notify auf einen Value-Receiver hilft nicht. Er würde bei jedem Aufruf den sync.Mutex kopieren, und go vet meldet das als Notify passes lock by value.

Ein Typ mit Methoden auf Pointer-Receivern muss über seinen Pointer geprüft werden, also mit var _ Notifier = (*BatchNotifier)(nil). Die Pointer-Form funktioniert auch für Typen mit Value-Receivern wie SlackNotifier, weil ein *T jede Methode von T hat. Deshalb ist (*T)(nil) die übliche Schreibweise für diese Prüfung.

Wie sollte man Interfaces in Go entwerfen?

Das Go-Sprichwort lautet „the bigger the interface, the weaker the abstraction“, also: Je größer das Interface, desto schwächer die Abstraktion (Go Proverbs). Die meistgenutzten Interfaces der Standardbibliothek haben eine oder zwei Methoden:

InterfaceMethodenImplementiert von
io.ReaderReadDateien, Netzwerkverbindungen, HTTP-Bodies, gzip-Reader, bytes.Buffer
io.WriterWriteDateien, http.ResponseWriter, Hashes, bytes.Buffer
errorError*fs.PathError, *json.SyntaxError, deine eigenen Fehlertypen
fmt.StringerStringalles, was selbst bestimmt, wie fmt es ausgibt
http.HandlerServeHTTPRouter, Middleware, http.HandlerFunc

Ein Interface mit einer einzigen Methode ist leicht zu erfüllen, also erfüllen es viele Typen. io.Copy funktioniert mit jeder Quelle und jedem Ziel, weil es so wenig verlangt. Ein Storage-Interface mit zwölf Methoden hat meist genau eine echte Implementierung. Dazu kommt ein Mock, den man bei jeder Änderung am Interface nachziehen muss. http.Handler funktioniert genauso. Eine Middleware nimmt einen Handler und gibt einen zurück, deshalb lässt sich Go-Middleware aus jedem Paket um jeden Router stapeln.

fmt.Stringer zeigt, wie wenig ein Typ tun muss, um sich in die Standardbibliothek einzuklinken. Gib einem Enum eine Methode String, und jedes fmt-Verb verwendet sie:

example.gogo
type OrderStatus int

const (
	Pending OrderStatus = iota
	Paid
	Refunded
)

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "pending"
	case Paid:
		return "paid"
	case Refunded:
		return "refunded"
	}
	return fmt.Sprintf("OrderStatus(%d)", int(s))
}

func main() {
	fmt.Println(Paid)
	fmt.Printf("order 1001 is %v\n", Refunded)
}
example.texttext
paid
order 1001 is refunded

Wo sollte ein Go-Interface deklariert werden?

In dem Package, das es verwendet, nicht in dem Package, das es implementiert. Weil die Erfüllung implizit ist, kann der Verwender nur die Methoden auflisten, die er aufruft.

Ein Rückerstattungsservice muss nur eine Bestellung laden. Er deklariert dafür ein Interface mit einer Methode und nimmt es in seinem Konstruktor entgegen:

example.gogo
type orderGetter interface {
	GetOrder(ctx context.Context, id int64) (Order, error)
}

type RefundService struct {
	orders orderGetter
}

func NewRefundService(orders orderGetter) *RefundService {
	return &RefundService{orders: orders}
}

func (s *RefundService) CanRefund(ctx context.Context, id int64) (bool, error) {
	o, err := s.orders.GetOrder(ctx, id)
	if err != nil {
		return false, fmt.Errorf("load order %d: %w", id, err)
	}
	return o.Status == "paid", nil
}

In Produktion übergibst du den Postgres-Store, der zwanzig weitere Methoden hat, die der Rückerstattungsservice nie zu sehen bekommt. In einem Test reicht eine Map:

example.gogo
type fakeOrders map[int64]Order

func (f fakeOrders) GetOrder(ctx context.Context, id int64) (Order, error) {
	o, ok := f[id]
	if !ok {
		return Order{}, ErrNotFound
	}
	return o, nil
}

func main() {
	svc := NewRefundService(fakeOrders{1001: {ID: 1001, Status: "paid"}})
	fmt.Println(svc.CanRefund(context.Background(), 1001))
	fmt.Println(svc.CanRefund(context.Background(), 1002))
}
example.texttext
true <nil>
false load order 1002: order not found

fakeOrders ist ein definierter Map-Typ mit einer Methode (siehe das Go-Keyword type). Der Test kommt also ohne Mocking-Bibliothek aus.

Was bedeutet „accept interfaces, return structs“?

Es ist die andere Hälfte derselben Idee. Funktionen nehmen Interfaces als Parameter, damit Aufrufer alles übergeben können, was passt. Konstruktoren geben konkrete Typen wie *RefundService oder *PostgresStore zurück. So bekommen Aufrufer jede Methode und jedes exportierte Feld und können selbst entscheiden, welches kleine Interface sie brauchen.

Gibt ein Konstruktor ein Interface zurück, versteckt er die Methoden, die das Interface nicht auflistet. Außerdem legt er jeden Aufrufer auf die Abstraktion fest, die du gewählt hast. Die wichtigste Ausnahme ist error. Funktionen geben error zurück und nicht *ValidationError. Warum, erklärt der Abschnitt über nil weiter unten.

Was ist any in Go?

any ist ein Alias für interface{}, das Interface ohne Methoden. Jeder Typ hat mindestens null Methoden, also kann eine Variable vom Typ any jeden Wert aufnehmen. any kam mit Go 1.18 hinzu, und die beiden Schreibweisen sind austauschbar (Go spec).

any taucht dort auf, wo der Typ erst zur Laufzeit feststeht. fmt.Println(a ...any) nimmt Argumente beliebigen Typs entgegen, und json.Unmarshal in eine map[string]any erzeugt Werte, deren Typen vom JSON abhängen.

Dafür prüft der Compiler nichts mehr. Jeder Lesezugriff auf ein any braucht eine Type Assertion, und eine falsche Annahme wird zum Laufzeitfehler statt zum Compilerfehler. Wenn eine Funktion mit mehreren Typen arbeitet, aber für alle dieselben Operationen gelten, verwende stattdessen einen Typparameter. func Sum[T Amount](values []T) T aus dem Abschnitt über Constraints weiter unten behält die volle Typprüfung. func Sum(values []any) any verschiebt dagegen jede Prüfung in die Laufzeit.

Wie funktionieren Type Assertions und Type Switches?

Eine Type Assertion holt den konkreten Wert wieder aus einem Interface heraus. Die Form mit zwei Rückgabewerten löst nie eine Panic aus:

example.gogo
var payload map[string]any
body := `{"user_id": 42, "email": "[email protected]", "roles": ["admin", "billing"], "manager": null}`
if err := json.Unmarshal([]byte(body), &payload); err != nil {
	log.Fatal(err)
}

email, ok := payload["email"].(string)
fmt.Println(email, ok)

id, ok := payload["user_id"].(int)
fmt.Println(id, ok)
fmt.Printf("%T\n", payload["user_id"])
example.texttext
[email protected] true
0 false
float64

Die zweite Assertion scheitert, weil encoding/json jede JSON-Zahl als float64 dekodiert, wenn das Ziel any ist. Mit ok ist das Scheitern nur ein false und der Nullwert. Die Form mit einem Rückgabewert, payload["user_id"].(int), löst stattdessen eine Panic aus:

example.texttext
panic: interface conversion: interface {} is float64, not int

Assertions funktionieren auch für Interfaces. r.(io.Writer) fragt, ob der Wert in r eine Methode Write hat. Die Standardbibliothek nutzt das, um nach optionalem Verhalten zu suchen. io.Copy prüft, ob die Quelle ein io.WriterTo ist, und nimmt in dem Fall den schnelleren Weg.

Ein Type Switch führt mehrere Assertions in einer Anweisung aus. Er eignet sich für denselben JSON-Payload, in dem ein Wert einen von wenigen möglichen Typen haben kann:

example.gogo
func describe(v any) string {
	switch v := v.(type) {
	case nil:
		return "null"
	case float64:
		return fmt.Sprintf("number %g", v)
	case string:
		return fmt.Sprintf("string of %d bytes", len(v))
	case []any:
		return fmt.Sprintf("array of %d items", len(v))
	case map[string]any:
		return fmt.Sprintf("object with %d keys", len(v))
	default:
		return fmt.Sprintf("other %T", v)
	}
}

func main() {
	// payload decoded as above
	for _, key := range []string{"user_id", "email", "roles", "manager"} {
		fmt.Println(key+":", describe(payload[key]))
	}
}
example.texttext
user_id: number 42
email: string of 15 bytes
roles: array of 2 items
manager: null

In jedem case hat v den dort genannten Typ, also kompiliert len(v) für den String, den Slice und die Map. Type Switches über Fehler funktionieren genauso, sehen aber nur den äußersten Fehler. Das Go-Keyword type zeigt diesen Fall und erklärt, warum errors.As für gewrappte Fehler meist das bessere Werkzeug ist.

Warum ist mein nil-Error in Go nicht nil?

Ein Interface-Wert besteht aus zwei Teilen, einem dynamischen Typ und einem dynamischen Wert. Er ist nur dann gleich nil, wenn beide nicht gesetzt sind. Speicherst du einen nil-Pointer in einem Interface, ist der Typ-Teil gesetzt, und das Interface ist nicht nil (Go FAQ).

Am häufigsten stolpert man darüber bei eigenen Fehlertypen. Hier behält die Funktion eine Variable vom Typ *ValidationError und gibt sie zurück, egal ob ihr je etwas zugewiesen wurde:

example.gogo
package main

import (
	"fmt"
	"strings"
)

type ValidationError struct {
	Field string
}

func (e *ValidationError) Error() string {
	return "invalid " + e.Field
}

func validate(email string) error {
	var verr *ValidationError
	if !strings.Contains(email, "@") {
		verr = &ValidationError{Field: "email"}
	}
	return verr
}

func main() {
	err := validate("[email protected]")
	if err != nil {
		fmt.Printf("validation failed: %v (%T)\n", err, err)
		return
	}
	fmt.Println("ok")
}
example.texttext
validation failed: <nil> (*main.ValidationError)

Die E-Mail-Adresse ist gültig und verr ist nil. Der zurückgegebene error enthält aber den Typ *main.ValidationError mit einem nil-Pointer darin. err != nil vergleicht das ganze Interface mit einem Interface ohne Typ, also ist das Ergebnis true. Der Aufrufer lehnt eine gültige Registrierung ab, und in der Logzeile steht <nil>. Das macht den Bug schwer zu finden.

Die Lösung ist, im Erfolgsfall ein literales nil zurückzugeben und nie einen typisierten Pointer durch einen error-Rückgabewert zu schleusen:

example.gogo
func validate(email string) error {
	if !strings.Contains(email, "@") {
		return &ValidationError{Field: "email"}
	}
	return nil
}

Deklariere den Rückgabetyp als error, nicht als *ValidationError, und gib nil direkt zurück. Aufrufer, die das Feld brauchen, bekommen es weiterhin mit errors.As. Das findet den Fehler auch, nachdem er gewrappt wurde:

example.gogo
err := fmt.Errorf("signup: %w", validate("ana.example.com"))
var verr *ValidationError
if errors.As(err, &verr) {
	fmt.Println("bad field:", verr.Field)
}

Das gibt bad field: email aus. Dieselbe Falle gibt es bei jedem Interface. Auch ein nie gesetzter var store *PostgresStore, der dort übergeben wird, wo ein orderGetter erwartet wird, ergibt ein Interface, das nicht nil ist.

Der umgekehrte Fall ist ein Interface, das tatsächlich nil ist, etwa ein Struct-Feld vom Typ Notifier, das niemand gesetzt hat. Ein Methodenaufruf darauf löst eine Panic mit invalid memory address or nil pointer dereference aus, weil es keinen Typ gibt, an dem die Methode zu finden wäre. Ein Konstruktor, der die Abhängigkeit verlangt, wie NewRefundService(orders), verhindert das.

Wie werden Interfaces als generische Constraints verwendet?

Seit Go 1.18 kann ein Interface neben Methoden auch Typen auflisten. Diese Liste heißt Type Set, und ~T bedeutet „jeder Typ, dessen zugrunde liegender Typ T ist“:

example.gogo
type Cents int64

type Amount interface {
	~int64 | ~float64
}

func Sum[T Amount](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

func Dedupe[T comparable](ids []T) []T {
	seen := make(map[T]struct{}, len(ids))
	out := make([]T, 0, len(ids))
	for _, id := range ids {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func main() {
	fmt.Println(Sum([]Cents{4999, 1250, 300}))
	fmt.Println(Dedupe([]string{"u_42", "u_7", "u_42"}))
}
example.texttext
6549
[u_42 u_7]

Amount erlaubt +, weil jeder Typ in seinem Type Set es unterstützt. comparable ist ein vordeklarierter Constraint für Typen, die mit == funktionieren, und genau das braucht ein Map-Key. Einen Typ außerhalb des Type Sets lehnt der Compiler ab und benennt das Problem:

example.texttext
./main.go:27:17: string does not satisfy Amount (string missing in ~int64 | ~float64)

Ein Interface mit einer Typliste kann nur als Constraint verwendet werden. Eine Variable dieses Typs zu deklarieren scheitert:

example.texttext
./main.go:10:8: cannot use type Amount outside a type constraint: interface contains type constraints

Go hat also zwei Arten von Interfaces, die sich ein Keyword teilen. Interfaces, die nur Methoden enthalten, funktionieren überall, als Variablentypen, als Parameter und als Constraints. Interfaces mit Typlisten stehen nur in eckigen Klammern. Die Einführung in Generics im Go-Blog beschreibt, wie Type Sets die beiden Arten vereinen.

Wo LevelUpGo ins Spiel kommt

LevelUpGo bringt dir Go mit Übungen bei, die echten Go-Code im Browser ausführen. Interfaces & Polymorphism behandelt implizite Erfüllung, Methodenmengen, any, Type Assertions und die Komposition von Interfaces. Interface Design zeigt, wie du Interfaces klein hältst, sie dort deklarierst, wo sie verwendet werden, und auf Interfaces verzichtest, die nur fürs Mocking existieren. Go Generics Masterclass geht tiefer in Constraints und Type Sets. Im Training Ground findest du kurze, eigenständige Übungen zu Interfaces, auch außerhalb eines Kurses. Die anderen 24 reservierten Wörter findest du in Go Keywords: Alle 25 erklärt.

Häufig gestellte Fragen

Ist interface ein Keyword in Go?

Ja. interface ist eines der 25 reservierten Keywords von Go, du kannst es also nicht als Variablen- oder Funktionsnamen verwenden. Es kommt in Interface-Typdeklarationen wie type Notifier interface { ... } vor, im Literal interface{} und in Inline-Constraints wie [T interface{ ~int | ~string }].

Gibt es in Go ein Keyword implements?

Nein. Ein Typ erfüllt ein Interface einfach dadurch, dass er alle seine Methoden mit passenden Signaturen hat. Wenn der Compiler prüfen soll, ob ein Typ ein Interface erfüllt, füg var _ Notifier = (*SlackNotifier)(nil) neben dem Typ hinzu.

Was ist der Unterschied zwischen any und interface{} in Go?

Es gibt keinen. any ist ein vordeklarierter Alias für interface{}, der mit Go 1.18 hinzukam. Beide sind also derselbe Typ. Neuerer Code verwendet any, weil es kürzer ist, und gofmt -r 'interface{} -> any' kann älteren Code umschreiben.

Warum meldet Go „method has pointer receiver“?

Die Methode ist auf *T deklariert, also hat nur ein *T sie. Du hast versucht, einen T-Wert als Interface zu verwenden. Übergib stattdessen einen Pointer, etwa &BatchNotifier{}, oder stell die Methode auf einen Value-Receiver um, wenn sie den Receiver nicht verändert.

Warum ist mein Error nicht nil, obwohl ich einen nil-Pointer zurückgegeben habe?

Ein Interface ist nur dann nil, wenn sowohl sein Typ als auch sein Wert nicht gesetzt sind. Gibst du einen nil-*ValidationError aus einer Funktion mit dem Rückgabetyp error zurück, wird der Typ auf *ValidationError gesetzt, und err != nil ist true. Gib im Erfolgsfall ein literales nil zurück statt einer typisierten Pointer-Variable.

Können Go-Interfaces Felder haben?

Nein. Interfaces beschreiben nur Methoden, oder Type Sets, wenn sie als Constraints verwendet werden. Wenn Aufrufer einen Wert brauchen, füg eine Getter-Methode wie ID() string hinzu oder nimm das konkrete Struct statt eines Interfaces entgegen.

Quellen

Schreib Go wie ein Senior Engineer

Interaktive Lektionen im Browser. Die ersten sind kostenlos.

Kostenlose Lektion testenOder kostenloses Konto erstellen