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

Dart: Vererbung und Polymorphie – die Eltern-Kind-Beziehung

Ahmet Balaman

Zuletzt aktualisiert:

8 Min. Lesezeit

FlutterDartOOPInheritancePolymorphism
Dart: Vererbung und Polymorphie – die Eltern-Kind-Beziehung

Vererbung ist das OOP-Werkzeug, in das sich Einsteiger am schnellsten verlieben und das sie am frühesten überstrapazieren. Ein Muster, das ich im Unterricht oft sehe: Eine Klasse wird von einer anderen abgeleitet, weil beide ein paar Felder gemeinsam haben, und wenige Wochen später steht eine fünfstufige Hierarchie, die niemand mehr anzufassen wagt. Dieser Beitrag behandelt alles, was Sie brauchen, um Vererbung an der richtigen Stelle einzusetzen: extends, super, @override, Polymorphie, abstrakte Klassen, sichere Typprüfungen und die mit Dart 3 eingeführten Klassenmodifikatoren wie sealed, final und base.

Im vorigen Beitrag haben wir mit Enums und Composition „Hat ein“-Beziehungen aufgebaut. Vererbung modelliert die „Ist ein“-Beziehung.

Live-Demo: Vererbung und Polymorphie

Probieren Sie Vererbung und Polymorphie interaktiv aus:

Was ist Vererbung?

Bei der Vererbung übernimmt eine Klasse die Felder und Methoden einer anderen. Die weitergebende Klasse heißt Oberklasse (Superclass, auch Elternklasse), die übernehmende Unterklasse (Subclass, auch Kindklasse).

Die Beziehung verläuft in eine Richtung: Die Unterklasse hat alles, was die Oberklasse hat, während die Oberklasse nichts von den Ergänzungen der Unterklasse weiß. In Dart kann eine Klasse nur von einer einzigen Klasse per extends erben; um Verhalten aus mehreren Quellen zu teilen, gibt es Mixins, die wir im Beitrag über Interfaces und implements behandeln.

Ein erstes Beispiel: Haus, Palast und Villa

class Haus {
  final int fensterAnzahl;

  const Haus(this.fensterAnzahl);

  String beschreiben() => 'ein Haus mit $fensterAnzahl Fenstern';
}

class Palast extends Haus {
  final int turmAnzahl;

  const Palast(super.fensterAnzahl, {required this.turmAnzahl});

  @override
  String beschreiben() => '${super.beschreiben()} und $turmAnzahl Türmen';
}

class Villa extends Haus {
  final bool hatGarage;

  const Villa(super.fensterAnzahl, {this.hatGarage = false});
}

void main() {
  const haus = Haus(4);
  const palast = Palast(50, turmAnzahl: 3);
  const villa = Villa(12, hatGarage: true);

  print(haus.beschreiben());   // ein Haus mit 4 Fenstern
  print(palast.beschreiben()); // ein Haus mit 50 Fenstern und 3 Türmen
  print(villa.beschreiben());  // ein Haus mit 12 Fenstern
  print(villa.hatGarage);      // true
}

Palast und Villa bekommen das Feld fensterAnzahl und die Methode beschreiben von Haus; jede Klasse ergänzt nur, was sie unterscheidet (Türme, Garage). Das Gemeinsame steht oben, das Veränderliche unten.

Mit super die Oberklasse erreichen

Das Schlüsselwort super begegnet Ihnen an zwei Stellen:

  • Im Konstruktor: Palast(super.fensterAnzahl, ...) reicht den Wert direkt an den Konstruktor der Oberklasse weiter. Das sind die „Super-Parameter“ aus Dart 2.17, eine Kurzform der Schreibweise : super(fensterAnzahl), die Sie in älteren Beispielen sehen.
  • In einer Methode: super.beschreiben() ruft die Methode der Oberklasse auf. Statt den Satz der Oberklasse neu zu schreiben, verwendet Palast.beschreiben ihn und hängt etwas an. Dieses Muster nutzen Sie, wenn Sie Verhalten erweitern statt komplett ersetzen wollen.

@override: das eigene Verhalten der Unterklasse

Eine Unterklasse kann eine geerbte Methode durch einen eigenen Rumpf ersetzen. Das nennt man Überschreiben (Override):

class Tier {
  void lautGeben() {
    print('Kein Laut');
  }
}

class Saeugetier extends Tier {}

class Katze extends Saeugetier {
  @override
  void lautGeben() => print('Miau');
}

class Hund extends Saeugetier {
  @override
  void lautGeben() => print('Wau wau');
}

class Fuchs extends Saeugetier {
  // lautGeben nicht geschrieben: der Rumpf aus Tier wird verwendet
}

void main() {
  Katze().lautGeben(); // Miau
  Hund().lautGeben();  // Wau wau
  Fuchs().lautGeben(); // Kein Laut
  Tier().lautGeben();  // Kein Laut
}

Welcher Rumpf läuft, wird ausgehend von der tatsächlichen Klasse des Objekts entschieden: Hat Fuchs kein lautGeben, schaut Dart in Saeugetier, und wenn es dort auch fehlt, in Tier. Ob die Methode überhaupt existiert, wird zur Kompilierzeit geprüft; rufen Sie eine Methode auf, die nirgends in der Kette vorkommt, kompiliert der Code nicht.

Die Annotation @override ist optional, aber schreiben Sie sie immer. Hat die Oberklasse keine Methode dieses Namens (etwa weil Sie lautgeben ohne Großbuchstaben getippt haben), warnt Sie der Analyzer. Ohne Annotation definiert dieser Tippfehler still eine ganz neue Methode.

Abstrakte Klassen: ein Vertrag statt „Kein Laut“

Das Beispiel oben hat ein Designproblem: Wir haben vergessen, Fuchs einen Laut zu geben, und das Programm hat es mit „Kein Laut“ überspielt. Ein bedeutungsloser Standardrumpf in der Oberklasse versteckt vergessene Overrides. Besser ist es, Tier abstrakt zu machen und die Methode ohne Rumpf zu lassen:

abstract class Tier {
  String get name;
  String lautGeben(); // Kein Rumpf: jede Unterklasse muss ihn liefern

  void vorstellen() => print('$name: ${lautGeben()}');
}

class Katze extends Tier {
  @override
  String get name => 'Katze';

  @override
  String lautGeben() => 'Miau';
}

class Fuchs extends Tier {
  @override
  String get name => 'Fuchs';

  @override
  String lautGeben() => 'Kek kek';
  // Ohne diese Methode würde der Code nicht kompilieren.
}

void main() {
  // Tier(); // Fehler: abstrakte Klassen lassen sich nicht instanziieren
  Katze().vorstellen(); // Katze: Miau
  Fuchs().vorstellen(); // Fuchs: Kek kek
}

Eine abstrakte Klasse kann sowohl einen Vertrag (das rumpflose lautGeben) als auch gemeinsames Verhalten (vorstellen) enthalten. Diese Mischung unterscheidet sie von Interfaces, die nur den Vertrag enthalten.

Polymorphie: gleicher Aufruf, anderes Verhalten

Polymorphie bedeutet: Selbst wenn der Typ einer Variable die Oberklasse ist, führt ein Aufruf den Rumpf aus der tatsächlichen Klasse des Objekts aus.

Eine als Hayvan deklarierte Variable gibt bei einem Kedi Miyav und bei einem Kopek Hav aus; der Aufruf ist gleich, der Rumpf unterschiedlich

Das Diagramm verwendet die türkischen Namen aus dem ursprünglichen Beispiel: Hayvan ist Tier, Kedi ist Katze, Kopek ist Hund und sesCikar ist lautGeben.

Tier t = Katze();
print(t.lautGeben()); // Miau

Die Variable t hat den Typ Tier, das Objekt darin ist aber eine Katze. Der Compiler lässt Sie über t nur Member aufrufen, die in Tier definiert sind; zur Laufzeit läuft der Rumpf von Katze.

Die eigentliche Stärke liegt darin, verschiedene Unterklassen über einen einzigen Typ zu verwalten, etwa in einer List oder als Funktionsparameter:

void alleVorstellen(List<Tier> tiere) {
  for (final tier in tiere) {
    tier.vorstellen();
  }
}

void main() {
  alleVorstellen([Katze(), Fuchs()]);
  // Katze: Miau
  // Fuchs: Kek kek
}

alleVorstellen weiß nicht, welche Tiere es gibt. Wenn Sie morgen eine Klasse Hund ergänzen, müssen Sie keine einzige Zeile dieser Funktion ändern. Das ist das Kennzeichen gut entworfener Polymorphie: Ein neuer Typ erfordert keine Änderung am bestehenden Code.

Typprüfung: is, as und Patterns

Ein Objekt einer Unterklasse lässt sich immer dem Typ der Oberklasse zuweisen (Upcasting); das geschieht automatisch und ist sicher. Die Gegenrichtung (Downcasting) ist riskant:

void main() {
  Haus haus = const Palast(50, turmAnzahl: 3); // Upcasting: automatisch

  // Villa villa = haus as Villa; // TypeError: dieses Objekt ist keine Villa

  if (haus is Palast) {
    // Im Block ist haus automatisch auf Palast eingegrenzt
    print('Türme: ${haus.turmAnzahl}');
  }
}

Eine frühere Fassung dieses Beitrags zeigte Haus(10) as Villa als „erzwungene Umwandlung“. Dieser Code kompiliert, wirft aber zur Laufzeit einen TypeError: as verwandelt ein Objekt nicht in eine andere Klasse, sondern behauptet nur: „Das ist bereits eine Villa.“ Die allgemeine Logik von as haben wir im Beitrag über Typumwandlungen behandelt. Die Regel ist einfach: Erst mit is prüfen; nach der Prüfung brauchen Sie kein as mehr.

Patterns aus Dart 3 erledigen dieselbe Prüfung samt Feldern:

String zusammenfassen(Haus haus) => switch (haus) {
      Palast(:final turmAnzahl) => 'Palast mit $turmAnzahl Türmen',
      Villa(hatGarage: true) => 'Villa mit Garage',
      Villa() => 'Villa ohne Garage',
      _ => 'Gewöhnliches Haus',
    };

void main() {
  print(zusammenfassen(const Villa(12, hatGarage: true))); // Villa mit Garage
  print(zusammenfassen(const Haus(4)));                    // Gewöhnliches Haus
}

Palast(:final turmAnzahl) prüft den Typ und legt das Feld turmAnzahl in einer gleichnamigen Variable ab. Villa(hatGarage: true) passt nur auf Villen mit Garage. Die Fälle werden von oben nach unten geprüft, deshalb steht der spezielle Fall vor dem allgemeinen.

Klassenmodifikatoren in Dart 3

Mit Dart 3 kamen Modifikatoren, die steuern, wie eine Klasse erweitert oder implementiert werden darf. Ein wichtiges Detail: Diese Einschränkungen gelten außerhalb der Bibliothek (meist der Datei), in der die Klasse definiert ist. Innerhalb derselben Datei können Sie die Klasse weiterhin erweitern.

sealed: geschlossene Hierarchie und vollständiger switch

Im Alltag einer App werden Sie sealed am häufigsten brauchen. Im Enum-Beitrag haben wir Bildschirmzustände mit einem Enum modelliert und sind an eine Grenze gestoßen: Enum-Werte können keine eigenen Daten tragen. Sealed-Klassen heben diese Grenze auf:

sealed class LadeZustand {}

class Laedt extends LadeZustand {}

class Erfolg extends LadeZustand {
  final List<String> eintraege;
  Erfolg(this.eintraege);
}

class Fehler extends LadeZustand {
  final String meldung;
  Fehler(this.meldung);
}

String zusammenfassung(LadeZustand zustand) => switch (zustand) {
      Laedt() => 'Lädt...',
      Erfolg(:final eintraege) => '${eintraege.length} Einträge empfangen',
      Fehler(:final meldung) => 'Fehler: $meldung',
    };

void main() {
  print(zusammenfassung(Erfolg(['a', 'b'])));        // 2 Einträge empfangen
  print(zusammenfassung(Fehler('Keine Verbindung'))); // Fehler: Keine Verbindung
}

Eine Sealed-Klasse ist implizit abstrakt und lässt sich nicht direkt instanziieren. Ihre direkten Unterklassen müssen in derselben Bibliothek definiert sein, der Compiler kennt also alle. Das Ergebnis: Vergessen Sie im switch den Fall Fehler, kompiliert der Code nicht, genau wie bei einem Enum. Zusätzlich trägt jeder Zustand seine eigenen Daten (eintraege, meldung).

Die übrigen Modifikatoren

  • abstract: Nicht instanziierbar, darf rumpflose Methoden enthalten. Überall erweiterbar und implementierbar.
  • base: Außerhalb der eigenen Bibliothek nur per extends erweiterbar, nicht per implements implementierbar. So sind Konstruktor und private Member der Oberklasse in jedem Untertyp garantiert.
  • interface: Außerhalb der eigenen Bibliothek nur per implements implementierbar, nicht per extends erweiterbar. Details im Interface-Beitrag.
  • final: Außerhalb der eigenen Bibliothek weder erweiterbar noch implementierbar. Schließt die Hierarchie vollständig.
  • sealed: Nach außen geschlossen wie final und ermöglicht zusätzlich die Vollständigkeitsprüfung über die Untertypen.

Untertypen einer base- oder final-Klasse in derselben Bibliothek müssen selbst als base, final oder sealed markiert sein, damit die Einschränkung weiter unten in der Hierarchie nicht umgangen werden kann.

Arbeiten Sie innerhalb einer einzelnen App, brauchen Sie die Modifikatoren außer sealed selten. Ihr eigentlicher Wert zeigt sich, wenn Sie ein Paket oder ein teamübergreifend genutztes Modul schreiben: Sie legen ausdrücklich fest, wie eine Klasse von außen verwendet werden darf, und begrenzen, wessen Code bricht, wenn Sie die Klasse später ändern.

Vererbung in Flutter

In Flutter beginnt jedes Widget mit Vererbung:

import 'package:flutter/material.dart';

class Begruessung extends StatelessWidget {
  const Begruessung({super.key, required this.name});

  final String name;

  @override
  Widget build(BuildContext context) {
    return Text('Hallo $name');
  }
}

StatelessWidget ist die Oberklasse, Begruessung die Unterklasse. super.key ist der Super-Parameter von oben, und die Methode build ist ein Override. Dieses Muster bauen Sie jedes Mal auf, wenn Sie ein eigenes Widget erstellen.

Beachten Sie: Flutter nutzt Vererbung nur für diesen Vertrag. Text oder Container erweitern Sie nicht, um sie anzupassen, sondern kombinieren sie in build. Widgets selbst entstehen also durch Vererbung, ihr Inhalt durch Composition.

Häufige Fehler

  • Vererbung nur wegen ein paar gemeinsamer Felder einsetzen; liest sich die Beziehung nicht als „ist ein“, wählen Sie Composition.
  • Tiefe Hierarchien bauen; Ketten mit mehr als zwei, drei Ebenen erschweren Änderungen.
  • Bedeutungslose Standardrümpfe wie „Kein Laut“ in die Oberklasse schreiben; eine abstrakte Methode fängt ein vergessenes Override zur Kompilierzeit ab.
  • Mit as downcasten, ohne vorher mit is zu prüfen.
  • @override weglassen; ein Tippfehler erzeugt still eine neue Methode.
  • Bei Zustandsmodellen auf switch mit default-Zweig setzen statt auf sealed.

Nächster Schritt

Um die Grenze „nur eine Oberklasse“ zu überwinden und Klassen allein über einen Vertrag zu verbinden, geht es weiter mit Interfaces und implements. Dort behandeln wir auch abstract interface class und Mixins.

Ressourcen


Für Fragen und Anregungen:

Häufig gestellte Fragen

Was ist der Unterschied zwischen extends und implements?

extends übernimmt sowohl die Felder als auch die Methodenrümpfe der Elternklasse, und Sie können nur von einer einzigen Klasse erben. implements übernimmt nur den Vertrag; Sie schreiben jedes Mitglied selbst und können mehrere Interfaces gleichzeitig implementieren.

Was passiert, wenn ich @override vergesse?

Der Code kompiliert trotzdem und die Methode wird überschrieben; die Annotation ist nicht Pflicht. Der Analyzer warnt Sie aber, und er fängt Tippfehler ab, wenn es in der Elternklasse keine Methode mit diesem Namen gibt – deshalb sollten Sie sie immer setzen.

Wie vermeide ich Fehler beim Downcasting?

Prüfen Sie mit is, bevor Sie as verwenden. Innerhalb eines if (haus is Villa)-Blocks grenzt Dart die lokale Variable automatisch auf Villa ein (Type Promotion), sodass Sie as meist gar nicht brauchen. Bei mehreren Untertypen ist ein switch mit Patterns besser lesbar.

Wofür ist eine sealed class gut?

Alle direkten Untertypen einer sealed-Klasse stehen in derselben Bibliothek, der Compiler kennt sie also vollständig. Ein switch über einen solchen Typ muss vollständig sein; vergessen Sie einen Untertyp, kompiliert der Code nicht. Ideal, um Zustände mit eigenen Daten zu modellieren (lädt, Erfolg, Fehler).

Kommentare