İçeriğe geç / Skip to content / Zum Inhalt

Dart: Interface und implements richtig einsetzen

Ahmet Balaman

Zuletzt aktualisiert:

8 Min. Lesezeit

FlutterDartInterfaceAbstractOOP
Dart: Interface und implements richtig einsetzen

Im Beitrag über Vererbung und Polymorphie haben wir gesehen, dass eine Klasse mit extends von genau einer Oberklasse abgeleitet wird. Echte Objekte haben aber oft mehrere Fähigkeiten: Ein Apfel lässt sich essen und auspressen, und ein Zahlungsdienst soll protokollieren und sich im Test durch eine Attrappe ersetzen lassen. Dart bietet dafür drei getrennte Werkzeuge: abstrakte Klassen, Interfaces und Mixins.

Am häufigsten verwechseln Lernende, dass diese drei dieselbe Aufgabe hätten. Kurz auseinandergehalten:

  • Abstrakte Klasse: Teilimplementierung und Vertrag zusammen. Sie stellt eine „Ist ein“-Beziehung her.
  • Interface: Nur ein Vertrag. Es sagt „kann das“, den Code schreibt die implementierende Klasse.
  • Mixin: Ein fertiges Verhaltenspaket. Es liefert Code, stellt aber keine „Ist ein“-Beziehung her.

Dieser Beitrag behandelt alle drei zusammen mit den Klassenmodifikatoren aus Dart 3.

Live-Demo: Abstrakte Klasse und implements

Lernen Sie den Einsatz von abstrakten Klassen und implements interaktiv kennen:

In Dart ist jede Klasse ein Interface

Wer von Java oder C# kommt, ist gewohnt, mit dem Schlüsselwort interface ein eigenes Konstrukt zu deklarieren. In Dart definiert jede Klasse implizit auch ihr eigenes Interface. Sie können also jede Klasse per implements umsetzen:

class Begruesser {
  final String name;
  Begruesser(this.name);

  String gruessen(String wen) => 'Hallo $wen, ich bin $name.';
}

class TestBegruesser implements Begruesser {
  @override
  String get name => 'Test';

  @override
  String gruessen(String wen) => 'Hi $wen';
}

implements übernimmt nur den Vertrag; keiner der Rümpfe von Begruesser kommt mit. Beachten Sie, dass sogar das Feld name neu geschrieben werden musste. Felder gehören ebenfalls zum Interface und müssen als Getter (bei veränderbaren Feldern auch als Setter) implementiert werden.

Dart 3: abstract interface class

Eine konkrete Klasse zu implementieren ist möglich, lässt die Absicht aber offen. Der mit Dart 3 eingeführte Modifikator interface ist der ausdrückliche Weg zu sagen: „Diese Klasse ist ein Vertrag.“ Für ein reines Interface, das nur einen Vertrag enthält, schreiben Sie abstract interface class:

abstract interface class Essbar {
  void wieEssen();
}

abstract interface class Auspressbar {
  void wieAuspressen();
}

class Apfel implements Essbar, Auspressbar {
  @override
  void wieEssen() => print('Abbeißen und kauen');

  @override
  void wieAuspressen() => print('In die Saftpresse legen und auspressen');
}

void main() {
  final apfel = Apfel();

  print('Wie isst man einen Apfel?');
  apfel.wieEssen();

  print('\nWie presst man einen Apfel aus?');
  apfel.wieAuspressen();
}

Ausgabe:

Wie isst man einen Apfel?
Abbeißen und kauen

Wie presst man einen Apfel aus?
In die Saftpresse legen und auspressen

Eine frühere Fassung dieses Beitrags definierte die Interfaces als void howToSqueeze() {}, mit leeren geschweiften Klammern. Dieser klein wirkende Unterschied ist wichtig: {} gibt der Methode einen leeren Rumpf, die Methode ist also nicht mehr abstrakt. Erweitert jemand eine solche Klasse per extends und vergisst die Methode, sagt der Compiler nichts, und eine Methode, die nichts tut, läuft still. Wenn Sie einen Vertrag definieren, lassen Sie die Methode ohne Rumpf und schließen sie mit einem Semikolon ab.

Warum zwei getrennte Interfaces?

Weil nicht jedes Objekt jede Fähigkeit hat. Ein Stein ist nicht essbar, lässt sich aber zerbrechen; Brot ist essbar, lässt sich aber nicht auspressen. Kleine Interfaces sorgen dafür, dass jede Klasse nur die Verträge übernimmt, die sie wirklich erfüllen kann:

abstract interface class Zerbrechlich {
  void wieZerbrechen();
}

class Stein implements Zerbrechlich {
  @override
  void wieZerbrechen() => print('Mit einem Hammer');
}

Machen Sie es umgekehrt und packen Essen, Auspressen, Schälen und Trocknen in ein großes Interface „Obst“, muss jede implementierende Klasse die unnötigen Methoden mit leeren Rümpfen füllen. Leer gelassene @override-Methoden sind ein Zeichen dafür, dass das Interface zu groß ist.

Der Unterschied zwischen extends und implements

Mit extends erbt eine Unterklasse den fertigen Methodenrumpf, mit implements übernimmt die Klasse nur den Vertrag und schreibt den Rumpf selbst

extends implements with (Mixin)
Wie viele? Nur eine Mehrere Mehrere
Kommen Rümpfe mit? Ja Nein Ja
Jedes Member selbst schreiben? Nein, nur die abstrakten Ja, alle Nein
Beziehung „Ist ein“ „Kann“ „Nutzt dieses Verhalten“

Der Code zum Beispiel im Diagramm verwendet dieselben türkischen Namen (Hayvan = Tier, Kopek = Hund, nefesAl = atmen, Yuruyebilir = kann laufen, yuru = laufen):

class Hayvan {
  void nefesAl() => print('Atmet');
}

class Kopek extends Hayvan {
  // nefesAl kam fertig mit; wir können es bei Bedarf überschreiben
}

abstract interface class Yuruyebilir {
  void yuru();
}

class Robot implements Yuruyebilir {
  @override
  void yuru() => print('Der Roboter läuft'); // Muss geschrieben werden
}

Eine Klasse kann auch beides gleichzeitig nutzen, etwa class Katze extends Tier implements Laufend.

Dieselbe abstrakte Klasse, zwei verschiedene Verwendungen

Diese Situation verwirrt Lernende am meisten: Eine abstrakte Klasse lässt sich sowohl mit extends als auch mit implements verwenden, und das Ergebnis unterscheidet sich.

abstract class Form {
  void zeichnen() => print('Eine Form wird gezeichnet');
  double flaeche();
}

class Quadrat extends Form {
  final double seite;
  Quadrat(this.seite);

  @override
  double flaeche() => seite * seite;
  // zeichnen() kam fertig mit
}

class Kreis implements Form {
  final double r;
  Kreis(this.r);

  @override
  double flaeche() => 3.14159 * r * r;

  @override
  void zeichnen() => print('Ein Kreis wird gezeichnet'); // Pflicht, obwohl es einen Rumpf gab
}

Quadrat mit extends schreibt nur die abstrakte Methode flaeche. Kreis mit implements muss alles schreiben, auch zeichnen, dessen Rumpf schon vorhanden war.

Modifikatoren in Dart 3: Wer darf was?

Seit Dart 3 können Sie festlegen, wie eine Klasse außerhalb der Bibliothek (meist der Datei), in der sie steht, verwendet werden darf:

Deklaration Instanziierbar? extends von außen implements von außen
class Ja Ja Ja
abstract class Nein Ja Ja
interface class Ja Nein Ja
abstract interface class Nein Nein Ja
base class Ja Ja Nein
final class Ja Nein Nein

Wozu interface class? Rufen sich die Methoden einer Klasse gegenseitig auf, kann jemand, der sie von außen erweitert und eine Methode überschreibt, die anderen unerwartet kaputt machen. interface schließt dieses Risiko aus: Die Klasse zu benutzen und zu implementieren ist erlaubt, aber niemand kann von ihr erben und ihr Inneres verändern. Diese Einschränkungen zählen vor allem beim Schreiben von Paketen; innerhalb einer einzelnen App ist abstract interface class die Form, die Sie am häufigsten brauchen.

Mixins: fertiges Verhalten statt Vertrag

Manchmal brauchen Sie keinen Vertrag, sondern ein Stück Code, das sich in mehreren Klassen wiederholt. Statt ein Verhalten wie Logging in jede Klasse zu kopieren, verwenden Sie ein Mixin:

mixin Protokollierer {
  void protokollieren(String nachricht) => print('[$runtimeType] $nachricht');
}

class ZahlungsDienst with Protokollierer {
  void bezahlen(double betrag) {
    protokollieren('Zahlung über $betrag € gestartet');
  }
}

class WarenkorbDienst with Protokollierer {
  void leeren() => protokollieren('Warenkorb geleert');
}

void main() {
  ZahlungsDienst().bezahlen(250); // [ZahlungsDienst] Zahlung über 250.0 € gestartet
  WarenkorbDienst().leeren();     // [WarenkorbDienst] Warenkorb geleert
}

Soll ein Mixin nur mit einem bestimmten Typ verwendet werden, schränken Sie es mit on ein. Dann kann das Mixin auf die Member dieses Typs zugreifen:

abstract class Tier {
  String get name;
}

mixin Schwimmer on Tier {
  void schwimmen() => print('$name schwimmt');
}

class Ente extends Tier with Schwimmer {
  @override
  String get name => 'Ente';
}

void main() {
  Ente().schwimmen(); // Ente schwimmt
}

Ein Muster aus älteren Tutorials funktioniert in Dart 3 nicht mehr: eine gewöhnliche class-Deklaration per with einzumischen. In Dart 3 lässt sich eine Klasse nur als Mixin verwenden, wenn sie als mixin oder mixin class deklariert ist. Eine mixin class ist sowohl als Klasse als auch als Mixin nutzbar; Flutters ChangeNotifier ist so deklariert, weshalb Sie sowohl extends ChangeNotifier als auch with ChangeNotifier schreiben können. In Ihrem eigenen Code ist es klarer, der Empfehlung von Effective Dart zu folgen und entweder ein reines mixin oder eine reine class zu deklarieren.

Interfaces in echten Projekten: testbarer Code

Der wertvollste Einsatz von Interfaces im Flutter-Alltag ist, eine Abhängigkeit austauschbar zu machen. Denken Sie an eine Schicht, die Benutzerdaten lädt. In der App holt sie die Daten vom Server, im Test soll sie feste Daten liefern, ohne den Server je zu berühren:

class Benutzer {
  final int id;
  final String name;
  const Benutzer(this.id, this.name);
}

abstract interface class BenutzerRepository {
  Future<Benutzer?> laden(int id);
}

class TestBenutzerRepository implements BenutzerRepository {
  @override
  Future<Benutzer?> laden(int id) async =>
      id == 1 ? const Benutzer(1, 'Ayse') : null;
}

class ProfilDienst {
  final BenutzerRepository repository; // Hängt vom Vertrag ab, nicht von einer konkreten Klasse

  ProfilDienst(this.repository);

  Future<String> titel(int id) async {
    final benutzer = await repository.laden(id);
    return benutzer?.name ?? 'Unbekannter Benutzer';
  }
}

Future<void> main() async {
  final dienst = ProfilDienst(TestBenutzerRepository());
  print(await dienst.titel(1)); // Ayse
  print(await dienst.titel(2)); // Unbekannter Benutzer
}

ProfilDienst weiß nicht, woher die Daten kommen; es kennt nur den Vertrag BenutzerRepository. In der App setzt eine Klasse ApiBenutzerRepository, die eine HTTP-Anfrage sendet, denselben Vertrag um, im Test kommt TestBenutzerRepository zum Einsatz. Hier arbeiten Composition und Interfaces zusammen: Der Dienst hat ein Repository, und welche Klasse das ist, wird von außen entschieden. Details zu async und await finden Sie im Beitrag try-catch und async/await.

Flutter selbst arbeitet nach dieser Idee. So implementieren ChangeNotifier, ValueNotifier und Animations-Controller alle den gemeinsamen Vertrag Listenable, weshalb sich jeder von ihnen demselben Widget übergeben lässt, etwa ListenableBuilder. Für Touch-Ereignisse müssen Sie auch kein eigenes „antippbar“-Interface schreiben; das übernimmt GestureDetector.

Hilfe vom Editor

Wenn Sie in VS Code oder Android Studio implements Essbar an eine Klasse schreiben, erscheint wegen der fehlenden Methoden eine Fehlermarkierung. Das Schnellkorrektur-Menü (Glühbirnen-Symbol) bietet an, die fehlenden Overrides zu erzeugen, und fügt alle Methodensignaturen für Sie ein. Danach füllen Sie nur noch die Rümpfe.

Häufige Fehler

  • Interface-Methoden mit {} definieren; die Methode ist dann nicht mehr abstrakt, und vergessene Implementierungen rutschen still durch.
  • Ein großes Interface entwerfen und die implementierenden Klassen mit leeren Methoden füllen.
  • implements nur zum Teilen von Code verwenden; brauchen Sie fertiges Verhalten, passen ein Mixin oder Composition besser.
  • In Dart 3 versuchen, eine gewöhnliche class mit with zu verwenden; nötig ist mixin oder mixin class.
  • Dienste an konkrete Klassen binden; Code, der von einem Vertrag abhängt, bleibt testbar.

Nächster Schritt

Wer Objekte entwerfen kann, kann sich den Wegen zuwenden, sie zusammenzuhalten: Dart-Collections: List, Set und Map. Dieselbe Unterscheidung gibt es in C#; das Gegenstück finden Sie im Beitrag Interface vs. abstrakte Klasse.

Ressourcen


Wenn Sie mit mir arbeiten möchten:

Häufig gestellte Fragen

Gibt es in Dart ein eigenes Schlüsselwort interface?

In Dart definiert jede Klasse implizit ein Interface, deshalb können Sie jede Klasse implementieren. Der mit Dart 3 eingeführte Modifikator interface verhindert, dass eine Klasse außerhalb ihrer eigenen Bibliothek erweitert wird, und erlaubt nur implements. Für einen reinen Vertrag schreiben Sie abstract interface class.

Kann eine Klasse extends und implements gleichzeitig verwenden?

Ja. Mit etwa class Katze extends Tier with Schwimmer implements Laufend, Rennend leiten Sie von einer Oberklasse ab, nutzen ein Mixin und implementieren gleichzeitig mehrere Interfaces. Die Reihenfolge ist immer extends, with, implements.

Darf eine abstrakte Klasse Methoden mit Rumpf enthalten?

Ja, eine abstrakte Klasse kann sowohl Methoden mit Rumpf als auch abstrakte Methoden ohne Rumpf enthalten. Klassen, die sie erweitern, bekommen die implementierten Methoden fertig mit; Klassen, die sie implementieren, müssen jede Methode neu schreiben, ob mit Rumpf oder ohne.

Was ist der Unterschied zwischen einem Mixin und implements?

implements übernimmt nur den Vertrag und verlangt, dass Sie jeden Rumpf selbst schreiben. Ein Mixin (with) fügt die Rümpfe fertig in die Klasse ein. Wollen Sie den Code eines Verhaltens teilen, nehmen Sie ein Mixin; wollen Sie garantieren, dass eine Klasse eine bestimmte Fähigkeit hat, ein Interface.

Kommentare