1. Polymorphismus ist nicht immer Magie
Wenn man sich die ersten Beispiele anschaut, sieht Polymorphismus in C# wie der absolute Sieg des gesunden Menschenverstands aus: erben, überschreiben, alles über den Basistyp aufrufen – und alles läuft. Aber in der Praxis gibt's ein paar Feinheiten. Lass uns die mal genauer anschauen.
Methoden verstecken und das Schlüsselwort new
Stell dir vor, wir haben eine Basisklasse und eine abgeleitete Klasse, in beiden gibt es eine Methode mit demselben Namen, aber ohne das Schlüsselwort override. Wenn du in der abgeleiteten Klasse die Methode neu definierst, aber ohne override, dann versteckst du die Implementierung der Basismethode, überschreibst sie aber nicht. Der Compiler, wie ein fürsorglicher Elternteil, meckert sofort und schlägt vor, new explizit anzugeben:
class Animal
{
public void Speak()
{
Console.WriteLine("Tier macht ein Geräusch.");
}
}
class Cat : Animal
{
public new void Speak()
{
Console.WriteLine("Miau!");
}
}
// Verwendung
Animal animal = new Cat();
animal.Speak(); // Gibt aus: "Tier macht ein Geräusch."
Wow! Selbst wenn wir ein Objekt vom Typ Cat erstellen und es in eine Variable vom Typ Animal legen, wird die Originalmethode der Basisklasse aufgerufen. Warum? Weil die Methode nicht als virtuell deklariert wurde! Falle Nummer eins: Wenn du Polymorphismus nutzen willst, vergiss nicht die Schlüsselwörter virtual und override. Benutze new nur, wenn du wirklich bewusst eine Methode verstecken willst (das braucht man übrigens sehr selten und nur mit guten Gründen).
Konstruktoraufrufe und Polymorphismus
Noch so ein nicht offensichtlicher Trick: Konstruktoren sind nicht virtuell. Wenn du im Basisklasse einen Konstruktor deklarierst und in der abgeleiteten Klasse einen eigenen, dann sind die nicht polymorph. Hier ein Beispiel:
class Animal
{
public Animal()
{
Console.WriteLine("Konstruktor Animal");
}
}
class Cat : Animal
{
public Cat()
{
Console.WriteLine("Konstruktor Cat");
}
}
// Verwendung
Animal animal = new Cat();
// Gibt aus:
// Konstruktor Animal
// Konstruktor Cat
Aber wenn du Methoden aus dem Konstruktor der Basisklasse aufrufst, die in der abgeleiteten Klasse überschrieben werden können, kann das Ergebnis unerwartet sein – die virtuelle Methode wird vor der Initialisierung des Nachfahren aufgerufen! Deshalb: Ruf keine virtuellen/abstrakten Methoden in Konstruktoren auf.
Problem der "kaputten" Kapselung bei override
Virtuelle Methoden sind cool, aber wenn du in der Basisklasse davon ausgehst, dass eine bestimmte Methode sich ganz genau so verhält, und dann wird sie in der Kindklasse überschrieben und bricht die Logik – dann gibt's plötzlich fiese Bugs.
class Animal
{
public virtual void Eat()
{
Console.WriteLine("Tier isst.");
}
public void Live()
{
Eat(); // Kann jede überschriebene Version aufrufen!
}
}
class Cat : Animal
{
public override void Eat()
{
Console.WriteLine("Katze isst Fisch.");
}
}
Animal a = new Cat();
a.Live(); // Gibt aus "Katze isst Fisch."
Wenn in der Basisklasse Animal die Methode Eat() "Tier isst" ausgibt, und dann in der abgeleiteten Klasse in der überschriebenen Eat() etwas Gefährliches passiert, kann das die ganze Klasse kaputt machen. Das nennt man Verletzung des Liskov Substitutionsprinzip (Liskov Substitution Principle, LSP). Überleg beim Design immer, ob das Verhalten der Kindklassen logisch zum Basistyp passt.
Casten und Probleme mit Typumwandlungen
Polymorphismus erlaubt es, verschiedene Objekte in einen "Haufen" zu packen: zum Beispiel eine Liste vom Basistyp, in der Hunde und Katzen (beide erben Animal) stecken. Aber wenn du was Spezielles aufrufen willst:
List<Animal> pets = new List<Animal> { new Cat(), new Dog() };
foreach (var pet in pets)
{
if (pet is Cat cat)
{
cat.Purr();
}
}
Wenn du die Typprüfung vergisst und unvorsichtig castest, bekommst du eine fiese InvalidCastException. Manchmal führt das zu zu vielen Typprüfungen, macht den Code komplizierter und zeigt, dass dein Design vielleicht überarbeitet werden sollte.
2. Probleme mit Abstraktion
Abstraktion ist ein super Werkzeug, um die Arbeit mit Objekten zu vereinfachen und den Zugriff auf den internen Zustand zu beschränken. Aber auch hier gibt's Fallstricke!
Übertreibung mit Abstraktionsebenen (Over-Abstraction)
Manche Anfänger (und nicht nur die) sind so begeistert vom "richtigen OOP", dass sie ganze Schichttorten aus Basisklassen, Interfaces und abstrakten Layern bauen. Am Ende blickt nicht mal der Autor selbst durch.
interface IAnimal
{
void Speak();
}
abstract class Feline : IAnimal
{
public abstract void Speak();
}
class Cat : Feline
{
public override void Speak()
{
Console.WriteLine("Miau!");
}
}
Warum eigentlich eine abstrakte Zwischenklasse, wenn sie nichts hinzufügt? Abstraktion um der Abstraktion willen macht Wartung schwer und die Architektur undurchsichtig.
Unüberlegte Hierarchie
Überleg mal, was passiert, wenn du eine Aktion ganz nach oben in die Hierarchie schiebst, die eigentlich nicht für alle passt:
abstract class Animal
{
public abstract void Fly();
}
class Cat : Animal
{
public override void Fly()
{
throw new NotImplementedException("Katzen fliegen nicht!");
}
}
Jetzt musst du entweder in den Kindklassen Platzhalter mit Exceptions einbauen oder damit leben, dass dein Interface nicht die Realität abbildet. Das ist ein typisches Anti-Pattern: "falsche Hierarchie". In solchen Fällen besser Methoden in eigene Interfaces auslagern (zum Beispiel IFlyable).
Tipp: Versuch nicht, eine Abstraktion für alle Fälle zu bauen.
Probleme mit abstrakten Klassen und API-Änderungen
Sobald eine abstrakte Klasse im Produktivcode landet und davon geerbt wird, werden Änderungen riskant. Einen neuen abstrakten Methode hinzufügen heißt, alle Nachfahren müssen sie implementieren – sonst kompiliert der Code nicht mehr. Das macht die Pflege von Bibliotheken und öffentlichen APIs echt schwierig.
Genau dafür gibt's Interfaces mit Default-Implementierung (Default Interface Methods – siehe Vorlesung 116): Damit kannst du Interfaces erweitern, ohne sofort den ganzen existierenden Code ändern zu müssen.
Kapselungsverletzung bei Abstraktion
Wenn du eine Klasse abstrakt machst, musst du oft ihre Mitglieder als protected deklarieren, damit abgeleitete Klassen darauf zugreifen können. Das führt oft dazu, dass interne Logik nach außen dringt, die besser versteckt geblieben wäre. Am Ende haben Nachfahren Zugriff auf Daten und Operationen, deren Manipulation die innere Konsistenz der Basisklasse zerstören kann.
3. Praktische Fehler-Szenarien
Damit du nicht denkst, das passiert nur in Hausaufgaben, schauen wir uns echte Beispiele aus dem Alltag an – manchmal treten selbst erfahrene Entwickler auf diese Fallen.
Beispiel mit Methoden, die nicht als virtual deklariert sind
Angenommen, wir erweitern unsere Lern-App zum Logging (siehe Tag 24). Wir haben einen Basis-Logger:
class BaseLogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
class FileLogger : BaseLogger
{
public void Log(string message)
{
// In Datei schreiben
Console.WriteLine("In Datei: " + message);
}
}
// Verwendung:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Erwartung: "In Datei: Hello!", Realität: "Hello!"
Der Entwickler von FileLogger dachte, er hätte die Methode überschrieben, hat aber override vergessen und die Basismethode nicht als virtuell markiert. Ergebnis: Es wird die Basismethode aufgerufen.
Empfehlung: Markiere Methoden, die überschrieben werden sollen, immer als virtual in der Basisklasse und override in den abgeleiteten Klassen.
Beispiel für falsche Abstraktion: "Flexible" Tiere
Weiter geht's mit Tieren! Wir führen das Interface IFlyable ein, damit nicht alle Tiere die Methode Fly implementieren müssen:
interface IFlyable
{
void Fly();
}
class Bird : IFlyable
{
public void Fly() => Console.WriteLine("Vogel fliegt!");
}
class Cat
{
// Katze implementiert IFlyable nicht
}
Jetzt können wir eine Funktion schreiben, die mit "Fliegern" arbeitet, ohne Katzen zu betreffen:
void MakeItFly(object creature)
{
if (creature is IFlyable flyingThing)
{
flyingThing.Fly();
}
else
{
Console.WriteLine("Dieses Tier kann nicht fliegen.");
}
}
So bleibt die Architektur sauber und wir brauchen keine Fake-Methoden in abstrakten Klassen.
Probleme mit "harten" abstrakten Klassen bei Erweiterbarkeit
Stell dir vor, du veröffentlichst eine Bibliothek mit so einer abstrakten Klasse:
public abstract class Creature
{
public abstract void DoAction();
}
Die Nutzer deiner Bibliothek fangen an, eigene Klassen davon abzuleiten. Ein Jahr später willst du das API erweitern und fügst hinzu:
public abstract class Creature
{
public abstract void DoAction();
public abstract void Sleep(); // Neue Methode!
}
Jetzt kompilieren alle User-Klassen nicht mehr, weil sie die neue abstrakte Methode implementieren müssen. Also: Sei beim Design von Abstraktionen sehr vorsichtig und setze, wenn möglich, auf Interfaces mit Default-Methoden.
4. Tipps, um typische Stolperfallen zu vermeiden
Mach deine Apps so flexibel wie ein Turner, aber brich dir nicht die Beine!
- Missbrauche Vererbung nicht: Wenn du mit Komposition (Einbetten eines Objekts in ein anderes) auskommst, mach das.
- Mach Methoden nur dann virtuell, wenn du sicher bist, dass sie überschrieben werden sollen.
- Erfinde keine sinnlosen abstrakten Klassen und bau keine Hierarchien "auf Vorrat".
- Prüfe die Logik beim Überschreiben von Methoden: Brich keine Invarianten (Regeln) der Basisklasse.
- Füge nach der Veröffentlichung keine neuen abstrakten Methoden zu öffentlichen Basisklassen und Interfaces hinzu.
- Nutze Interfaces für lose Kopplung zwischen Programmteilen.
- Für API-Erweiterungen nutze Default Interface Methods.
GO TO FULL VERSION