C# Vererbung und Polymorphie: virtual, override und new
6 Min. Lesezeit

Vererbung bedeutet, dass eine Klasse die Member einer anderen Klasse übernimmt und eigenes Verhalten ergänzt. Polymorphie heißt, dass derselbe Methodenaufruf je nach tatsächlichem Typ des Objekts unterschiedlichen Code ausführt. Zusammen lösen beide ein konkretes Problem: Auch bei zehn Arten von Mitarbeitenden schreiben Sie die Gehaltsschleife nur einmal und fassen sie nicht mehr an, wenn eine neue Art hinzukommt. Dieser Leitfaden behandelt base, Konstruktorverkettung, virtual/override gegenüber new, sealed, Typumwandlungen und den Klausurklassiker „Was gibt dieser Code aus?“.
Die Grundlage: Gemeinsames nach oben ziehen
Im Beitrag über Zugriffsmodifizierer kam bei protected schon ein kleines Vererbungsbeispiel vor. Jetzt bauen wir das Thema sauber auf. Stellen Sie sich eine Gehaltsabrechnung vor: Alle Mitarbeitenden haben einen Namen und ein Grundgehalt, aber die Berechnung hängt von der Rolle ab.
public class Employee
{
public string Name { get; }
protected decimal BaseSalary { get; }
public Employee(string name, decimal baseSalary)
{
Name = name;
BaseSalary = baseSalary;
}
public virtual decimal CalculateSalary() => BaseSalary;
public override string ToString() => $"{Name}: {CalculateSalary():N0}";
}
public class Manager : Employee
{
public decimal Bonus { get; }
public Manager(string name, decimal baseSalary, decimal bonus)
: base(name, baseSalary)
{
Bonus = bonus;
}
public override decimal CalculateSalary() => base.CalculateSalary() + Bonus;
}Manager : Employee liest sich als „ein Manager ist ein Mitarbeiter“. Manager erhält Name und BaseSalary, ohne sie neu zu schreiben. Weil BaseSalary als protected deklariert ist, sieht die abgeleitete Klasse den Wert, Code von außen dagegen nicht.
base und Konstruktorverkettung
Das Schlüsselwort base hat zwei Einsatzorte. Im Konstruktor ruft : base(name, baseSalary) den Konstruktor der Basisklasse auf. Innerhalb einer Methode führt base.CalculateSalary() die Version der Basisklasse aus, sodass Sie diesen Code erweitern, statt ihn zu kopieren.
Klausuren fragen besonders gern nach der Reihenfolge. Beim Erzeugen eines Objekts läuft zuerst der Konstruktor der Basisklasse, danach der der abgeleiteten Klasse:
public class Vehicle
{
public Vehicle() => Console.WriteLine("1) Vehicle-Konstruktor");
}
public class Car : Vehicle
{
public Car() => Console.WriteLine("2) Car-Konstruktor");
}
var car = new Car();
// 1) Vehicle-Konstruktor
// 2) Car-KonstruktorDie Logik dahinter ist einfach: Die abgeleitete Klasse verlässt sich auf die Felder der Basisklasse, also müssen diese zuerst bereitstehen. Lassen Sie : base(...) weg, versucht der Compiler, den parameterlosen Konstruktor der Basisklasse aufzurufen. Die Syntax entspricht dem : this(...) aus dem Beitrag über Konstruktoren; das eine führt zu einem Konstruktor derselben Klasse, das andere zur Basisklasse.
Diese Reihenfolge hat eine praktische Konsequenz: Rufen Sie im Konstruktor der Basisklasse keine virtuelle Methode auf. Der Aufruf landet beim override der abgeleiteten Klasse, doch deren Konstruktorrumpf ist zu diesem Zeitpunkt noch nicht gelaufen; die Methode sieht die dort zugewiesenen Werte im Standardzustand (null, 0). Der Compiler verhindert das nicht, der Fehler zeigt sich still zur Laufzeit.
virtual und override: echte Polymorphie
Damit eine Methode in einer abgeleiteten Klasse ersetzt werden kann, muss sie in der Basisklasse als virtual (oder abstract) markiert und in der abgeleiteten Klasse mit override geschrieben sein. Dann entscheidet der tatsächliche Typ des Objekts, welcher Code läuft, und nicht der Typ der Variablen:
Employee employee = new Manager("Emma", 60000m, 15000m);
Console.WriteLine(employee.CalculateSalary()); // 75000Die Variable ist vom Typ Employee, das Objekt im Speicher aber ein Manager. Der Aufruf wird zur Laufzeit aufgelöst, also läuft Manager.CalculateSalary. Der Aufruf von CalculateSalary() innerhalb von ToString folgt derselben Regel; dass er in der Basisklasse steht, ändert nichts.
Methoden verdecken mit new und die klassische Klausurfrage
Deklariert die abgeleitete Klasse eine gleichnamige Methode mit new statt mit override, ersetzt sie die Basismethode nicht, sondern verdeckt sie nur. Welche Methode läuft, wird dann zur Kompilierzeit anhand des Variablentyps entschieden. Hier die Frage, die in fast jeder OOP-Klausur auftaucht:
public class A
{
public virtual void Print() => Console.WriteLine("A.Print");
public void Show() => Console.WriteLine("A.Show");
}
public class B : A
{
public override void Print() => Console.WriteLine("B.Print");
public new void Show() => Console.WriteLine("B.Show");
}
A obj = new B();
obj.Print();
obj.Show();
((B)obj).Show();Was wird ausgegeben? Die Antwort:
B.Print
A.Show
B.ShowPrint ist virtuell, also entscheidet der tatsächliche Typ des Objekts (B). Show ist nicht virtuell; die Variable hat den Typ A, deshalb läuft A.Show. Wandeln Sie dasselbe Objekt in B um, sieht der Compiler B.Show. Die Merkregel: override schaut auf das Objekt, new schaut auf die Variable.
Es gibt auch eine schwierigere Variante:
public class A2 { public virtual void F() => Console.WriteLine("A"); }
public class B2 : A2 { public override void F() => Console.WriteLine("B"); }
public class C2 : B2 { public new virtual void F() => Console.WriteLine("C"); }
public class D2 : C2 { public override void F() => Console.WriteLine("D"); }
A2 a = new D2();
a.F(); // B
C2 c = new D2();
c.F(); // DDas new virtual in C2 kappt die Override-Kette und beginnt eine neue. Eine Variable vom Typ A2 sieht die alte Kette, deren letztes Glied B2 ist. Eine Variable vom Typ C2 sieht die neue Kette und landet bei D2. Schreiben Sie so etwas nicht in echten Projekten, aber auf Papier sollten Sie es nachvollziehen können. Diese Fähigkeit hilft in Algorithmen-Klausuren genauso; die Einfüge- und Traversierungsaufgaben im Beitrag Prüfungsfragen zum binären Suchbaum sind eine gute Übung.
sealed: Vererbung stoppen
Steht sealed an einer Klasse, kann niemand von ihr ableiten. Steht es an einer override-Methode, dürfen tiefer liegende Klassen diese Methode nicht erneut überschreiben:
public sealed class Intern : Employee
{
public Intern(string name) : base(name, 20000m) { }
public override decimal CalculateSalary() => BaseSalary * 0.8m;
}
// public class SeniorIntern : Intern { }
// CS0509: 'SeniorIntern': cannot derive from sealed type 'Intern'Auch die Klasse string in .NET ist sealed. Klassen zu versiegeln, von denen niemand ableiten soll, macht Ihre Absicht deutlich.
Upcasting, Downcasting, is und as
Die Umwandlung vom abgeleiteten Typ zum Basistyp (Upcasting) ist immer sicher und geschieht implizit. Die Gegenrichtung (Downcasting) ist riskant, denn nicht jeder Mitarbeiter ist ein Manager:
Employee employee = new Manager("Emma", 60000m, 15000m); // Upcasting
Employee intern = new Intern("Noah");
// Manager oops = (Manager)intern; // InvalidCastException zur Laufzeit
Manager? maybe = intern as Manager; // Wirft nicht, liefert null
if (employee is Manager manager) // Prüfung + Umwandlung in einem Schritt
Console.WriteLine($"{manager.Name} Bonus: {manager.Bonus:N0}");In modernem C# ist Pattern Matching der bevorzugte Weg. Ein switch-Ausdruck prüft Typ und Eigenschaften gemeinsam:
static string Title(Employee e) => e switch
{
Manager { Bonus: > 10000m } => "Leitender Manager",
Manager => "Manager",
Intern => "Praktikant",
_ => "Mitarbeiter"
};Eine Warnung: Wenn Ihr Code überall Typen prüft, deutet das meist auf eine fehlende virtual-Methode hin. Das Verhalten in die Klasse zu verlagern ist in der Regel sauberer.
Mini-Szenario: die Gehaltsliste
Setzen wir die Teile zusammen. Der Code, der am Monatsende die Gehälter des ganzen Teams berechnet:
List<Employee> team =
[
new Employee("Liam", 40000m),
new Manager("Emma", 60000m, 15000m),
new Intern("Noah")
];
decimal total = 0m;
foreach (Employee person in team)
{
Console.WriteLine(person);
total += person.CalculateSalary();
}
Console.WriteLine($"Gesamte Lohnsumme: {total:N0}");Ausgabe:
Liam: 40.000
Emma: 75.000
Noah: 16.000
Gesamte Lohnsumme: 131.000In der Schleife steht kein einziges if. Jedes Objekt weiß selbst, wie es sein Gehalt berechnet. Kommt morgen eine Klasse Consultant hinzu, schreiben Sie nur diese Klasse; Schleife, Bericht und Summe bleiben unverändert.
Wann verwenden – und wann nicht?
Vererbung ist das richtige Werkzeug, wenn zwischen zwei Klassen eine echte „ist ein“-Beziehung (is-a) besteht und die abgeleitete Klasse überall dort funktioniert, wo die Basisklasse verwendet wird. Vererbung nur aufzubauen, um doppelten Code zu vermeiden, ist der häufigste Entwurfsfehler: Jede Änderung an der Basisklasse trifft alle abgeleiteten Klassen, und bei Hierarchien mit drei oder vier Ebenen wird schwer nachvollziehbar, wer was überschreibt.
Die Alternative heißt Komposition, also eine „hat ein“-Beziehung. Daher stammt der Rat „Komposition vor Vererbung“. Hängt die Bonusberechnung nicht von der Rolle, sondern von der Firmenrichtlinie ab, geben Sie sie von außen hinein, statt sie in die Klassenhierarchie einzubauen:
public interface IBonusPolicy
{
decimal Calculate(decimal baseSalary);
}
public class FixedBonus(decimal amount) : IBonusPolicy
{
public decimal Calculate(decimal baseSalary) => amount;
}
public class Payroll(IBonusPolicy bonusPolicy)
{
public decimal Calculate(decimal baseSalary) =>
baseSalary + bonusPolicy.Calculate(baseSalary);
}Payroll hat eine Bonusrichtlinie; ändert sich die Richtlinie, übergeben Sie ein neues Richtlinienobjekt und keine neue Unterklasse. Für eine Fähigkeit, die nicht verwandte Typen teilen, brauchen Sie ebenfalls ein Interface statt Vererbung; den Unterschied erkläre ich in Interface vs. abstrakte Klasse.
Häufige Fehler
1. override vergessen
Symptom: kein Fehler, sondern die Warnung CS0108 bzw. bei virtuellen Methoden CS0114 („hides inherited member“); das Programm kompiliert, doch über eine Variable vom Typ Employee läuft die Basismethode und die Gehälter stimmen nicht. Lösung: Wenn Sie die Methode ersetzen wollen, schreiben Sie override. Ignorieren Sie Warnungen nicht.
2. Eine Methode überschreiben, die nicht virtual ist
Symptom: CS0506: 'Manager.CalculateSalary()': cannot override inherited member 'Employee.CalculateSalary()' because it is not marked virtual, abstract, or override. Lösung: Ergänzen Sie virtual an der Methode der Basisklasse.
3. Den Konstruktor der Basisklasse vergessen
public class Consultant : Employee
{
public Consultant(string name) { }
// CS7036: There is no argument given that corresponds to the required parameter 'name' of 'Employee.Employee(string, decimal)'
}
public class Consultant : Employee
{
public Consultant(string name) : base(name, 50000m) { } // Richtig
}Hat die Basisklasse keinen parameterlosen Konstruktor, ist : base(...) Pflicht.
4. Ungeprüftes Downcasting
Symptom: System.InvalidCastException zur Laufzeit. Lösung: Verwenden Sie if (employee is Manager m) statt (Manager)employee; wenn Sie as nutzen, prüfen Sie das Ergebnis auf null.
Häufig gestellte Fragen
Kann eine C#-Klasse von mehreren Klassen erben?
Nein, eine Klasse kann nur eine Basisklasse haben. Um ihr mehrere Fähigkeiten zu geben, implementieren Sie mehrere Interfaces.
Was ist der Unterschied zwischen override und new in einem Satz?
override ersetzt die virtuelle Methode der Basisklasse, und der Aufruf wird über den tatsächlichen Typ des Objekts aufgelöst. new deklariert lediglich eine eigene Methode mit demselben Namen, und der Aufruf wird über den Typ der Variablen aufgelöst.
Werden private Member vererbt?
Sie sind Teil des Objekts, aber der Code der abgeleiteten Klasse kann nicht auf sie zugreifen. Braucht die abgeleitete Klasse Zugriff, machen Sie den Member protected oder stellen Sie ihn über eine Property oder Methode bereit.
Sollte ich jede Methode virtual machen?
Nein. virtual ist das Versprechen, dass das Verhalten ersetzt werden darf, und erschwert spätere Änderungen an dieser Methode. Machen Sie nur Methoden virtuell, die abgeleitete Klassen wirklich variieren müssen.
Verwandte Artikel
C# Interface vs. abstrakte Klasse: Unterschiede und Einsatz
C# Interface vs. abstrakte Klasse: was beide enthalten dürfen, mehrere Interfaces, Entscheidungshilfe, Zahlungsbeispiel und typische Prüfungsfragen.
C# Zugriffsmodifizierer: Türen abschließen oder offen lassen?
Was bedeuten public, private, protected und internal? Wie Getter- und Setter-Methoden Daten sichern, am Beispiel eines Bankkontos.
Einführung in OOP: Warum prozedurale Programmierung endet
Der Weg von prozeduraler zu objektorientierter Programmierung: raus aus dem Spaghetticode, hin zu sauberem und wartbarem Code.