1. Zwei Seiten einer Medaille
Wir sind schon ziemlich tief in das Konzept der Abstraktion eingetaucht und haben zwei ihrer mächtigsten Werkzeuge in C# betrachtet – abstrakte Klassen und Interfaces. Du hast wahrscheinlich schon gemerkt, dass sie sich ähneln, denn beide erlauben uns, das "Skelett" eines Verhaltens zu definieren, das dann von konkreten Klassen umgesetzt werden muss. Aber glaub mir, das ist wie Hammer und Schraubenzieher zu vergleichen: beides Werkzeuge, aber für unterschiedliche Aufgaben.
Fangen wir mit dem Wichtigsten an: Warum brauchen wir zwei Werkzeuge für eine Aufgabe? In der Programmierung, wie auch im Leben, gibt es selten etwas "einfach so". Wenn es zwei ähnliche Tools gibt, dann haben sie ihre eigenen, einzigartigen Stärken und Einsatzgebiete.
Lass uns die beiden gleich mal in einer kurzen Vergleichstabelle gegenüberstellen, damit du das große Ganze siehst. Das ist so eine Art Spickzettel, der dir hilft, die wichtigsten Punkte zu erfassen.
| Eigenschaft | Abstrakte Klasse | Interface |
|---|---|---|
| Instanziierung | Kann nicht direkt erstellt werden (). |
Kann nicht direkt erstellt werden (). |
| Implementierung von Mitgliedern | Kann haben: – Vollständig implementierte Methoden/Properties. – Abstrakte Methoden/Properties (ohne Implementierung). – Felder, Konstruktoren, statische Methoden. |
Kann Methoden mit Default-Implementierung, statische abstrakte und statische nicht-abstrakte Mitglieder haben. Kann keine Instanzfelder und Konstruktoren haben. |
| Zugriffsmodifikatoren | Kann alle haben (public, protected, internal, private). | Alle Interface-Mitglieder sind standardmäßig public. Zugriffsmodifikatoren werden nicht angegeben (Ausnahmen: Methoden mit Default-Implementierung und statische Mitglieder). |
| Vererbung | Eine Klasse kann nur von einer abstrakten Klasse erben. | Eine Klasse kann beliebig viele Interfaces implementieren. |
| Beziehungstyp | "Ist ein" (is-a). Definiert den Basistyp und den gemeinsamen Teil der Hierarchie. | "Kann" (has-a oder can-do). Definiert einen Verhaltensvertrag/Fähigkeit. |
| Zustand | Kann Zustand speichern (Instanzfelder). | Kann statische Felder haben, aber keine Instanzfelder. |
| Erweiterung | Man kann neue implementierte Methoden hinzufügen, ohne die Ableitungen zu ändern. | Man kann Methoden mit Default-Implementierung hinzufügen, ohne die Ableitungen zu zerstören. |
Beeindruckend, oder? Lass uns diese Punkte genauer anschauen.
2. Mehrfachvererbung
Das ist wohl der grundlegendste und am leichtesten zu merkende Unterschied. In C#, wie in vielen anderen Sprachen (z.B. Java), kann eine Klasse nur von einer Elternklasse erben. Egal ob normale oder abstrakte Klasse – nur eine! Das ist so, um das berühmte "Diamond Problem" zu vermeiden, bei dem bei Mehrfachvererbung Unklarheit entsteht, welche der geerbten Methoden verwendet werden soll, wenn sie denselben Namen haben.
Aber Interfaces kann eine Klasse beliebig viele implementieren! Stell dir vor, deine Klasse ist ein Mensch. Er kann "Student" sein (erbt von Student), aber gleichzeitig "kann er kochen" (ICookable), "kann fahren" (IDriveable) und "kann singen" (ISingable). Das ist super flexibel!
abstract class Animal
{
public string Name;
public abstract void MakeSound();
public void Eat() => Console.WriteLine($"{Name} isst.");
}
interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }
class Duck : Animal, IFlyable, ISwimable
{
public double MaxFlyingAltitude => 1000;
public override void MakeSound() => Console.WriteLine($"{Name} quakt!");
public void Fly() => Console.WriteLine($"{Name} fliegt!");
public void Swim() => Console.WriteLine($"{Name} schwimmt!");
}
class Program
{
static void Main()
{
var duck = new Duck { Name = "Donald" };
duck.MakeSound();
duck.Eat();
duck.Fly();
duck.Swim();
// Arbeiten mit dem Objekt über verschiedene Typen:
Animal a = duck; a.Eat();
IFlyable f = duck; f.Fly();
ISwimable s = duck; s.Swim();
}
}
Wenn deine Klasse Teil einer Hierarchie sein muss (z.B. Dog ist ein Animal), dann nutze Vererbung von einer Klasse (abstrakt oder normal). Wenn die Klasse eine Fähigkeit haben soll (z.B. Dog kann laufen, Cat kann laufen), diese Fähigkeiten aber nicht an eine feste Hierarchie gebunden sind, dann nutze Interfaces. Das ist der Hauptvorteil von Interfaces – sie erlauben es, Verträge für sehr unterschiedliche, nicht verwandte Klassen zu erstellen.
3. Wo leben Daten, wo gibt es nur Versprechen?
Abstrakte Klassen können alles Mögliche enthalten:
- Normale (nicht-abstrakte) Methoden und Properties mit kompletter Implementierung.
- Abstrakte Methoden und Properties ohne Implementierung (die werden überschrieben).
- Felder (Instanzvariablen), die den Zustand des Objekts speichern.
- Konstruktoren, die zur Initialisierung dieses Zustands dienen.
- Sogar statische Methoden und Properties.
- Und natürlich können sie alle Zugriffsmodifikatoren haben: public, protected, private usw.
Das macht abstrakte Klassen zu einem mächtigen Tool, um teilweise implementiertes Verhalten und gemeinsamen Zustand für alle Ableitungen zu definieren.
abstract class Employee
{
public string FirstName, LastName;
public decimal Salary { get; protected set; }
public Employee(string first, string last) { FirstName = first; LastName = last; }
public void GetPaid(decimal sum)
{
Salary += sum;
Console.WriteLine($"{FirstName} {LastName} hat {sum:C} bekommen. Gehalt: {Salary:C}");
}
public abstract void PerformWork();
public abstract void TakeBreak();
}
class Developer : Employee
{
public Developer(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} schreibt Code.");
public override void TakeBreak() => Console.WriteLine($"{FirstName} trinkt Kaffee.");
}
class Tester : Employee
{
public Tester(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} sucht Bugs.");
public override void TakeBreak() => Console.WriteLine($"{FirstName} spielt Fußball.");
}
class Program
{
static void Main()
{
Employee[] team = {
new Developer("Ivan", "Petrov"),
new Tester("Maria", "Sidorova")
};
foreach (var emp in team)
{
Console.WriteLine($"\n--- Arbeitstag für {emp.FirstName} {emp.LastName} ---");
emp.PerformWork();
emp.GetPaid(2000);
emp.TakeBreak();
}
}
}
Interfaces – das ist eine ganz andere Geschichte. Bis C# 8 konnten sie nur Deklarationen von Methoden, Properties, Indexern und Events enthalten. Keine Felder, keine Konstruktoren, keine Methodenimplementierung! Einfach nur die "Signatur" der Methode ohne Body. Und alle Mitglieder waren standardmäßig public (auch wenn du public nicht explizit geschrieben hast). Das garantierte, dass ein Interface ein reiner Vertrag ist, ohne Implementierungsdetails oder versteckten Zustand.
Seit C# 8 sind Interfaces etwas "dicker" geworden und können Methoden mit Default-Implementierung (Default Interface Methods) und statische Mitglieder haben. Das wurde eingeführt, damit man neue Methoden zu bestehenden Interfaces hinzufügen kann, ohne Millionen Zeilen Code zu zerstören, die sie implementieren. Aber auch mit diesen Neuerungen können Interfaces immer noch keine Instanzfelder und Konstruktoren haben. Das bleibt das wichtigste Limit, damit Interfaces "Verträge für Verhalten" bleiben und keine "Datenspeicher".
interface ISaveable
{
void Save(string file);
bool IsDirty { get; }
}
interface ILoadable
{
void Load(string file);
}
class GameProgress : ISaveable, ILoadable
{
public int Level { get; set; }
public string PlayerName { get; set; }
bool _isDirty = true;
public bool IsDirty => _isDirty;
public GameProgress(string name, int level)
{
PlayerName = name; Level = level;
}
public void Save(string file)
{
Console.WriteLine($"Speichern: {PlayerName}, Level {Level} -> {file}");
_isDirty = false;
}
public void Load(string file)
{
Console.WriteLine($"Laden aus {file}");
PlayerName = "Neuer Spieler"; Level = 5; _isDirty = true;
}
public void UpdateProgress(int newLevel)
{
Level = newLevel; _isDirty = true;
Console.WriteLine($"Aktualisiert auf {Level}.");
}
}
class Program
{
static void Main()
{
var game = new GameProgress("Held", 1);
game.UpdateProgress(3);
ISaveable saver = game;
if (saver.IsDirty) saver.Save("save.dat");
ILoadable loader = game;
loader.Load("save.dat");
Console.WriteLine($"Nach dem Laden: {game.PlayerName}, {game.Level}");
}
}
Fazit: Wenn du eine Basisklasse brauchst, die nicht nur einen Vertrag, sondern auch schon implementierten Code (gemeinsame Logik) oder gemeinsamen Zustand bereitstellt, nimm eine abstrakte Klasse. Wenn du einfach nur eine "Checkliste" oder einen "Vertrag" für Verhalten brauchst, ohne Implementierung oder Zustand, dann ist ein Interface dein Freund.
4. Wann sollte man Interfaces verwenden?
Interfaces sind super, wenn du eine Fähigkeit oder ein Verhalten definieren willst, das ganz verschiedene, nicht verwandte Objekte haben können. Zum Beispiel:
- IDisposable: jedes Objekt, das korrekt "freigegeben" werden muss (Datei, Netzwerkverbindung, Datenbank).
- IEnumerable<T>: jedes Objekt, das man in einer foreach-Schleife durchlaufen kann.
- IComparable<T>: jedes Objekt, das mit einem anderen Objekt desselben Typs verglichen werden kann.
Wichtig ist, dass FileStream und SqlConnection IDisposable implementieren können, und List<T> und Dictionary<TKey, TValue> IEnumerable<T>. Diese Klassen gehören zu völlig unterschiedlichen Hierarchien, haben aber eine gemeinsame Fähigkeit, die durch das Interface definiert wird.
Beispiel: Stell dir ein System vor, in dem du Auto und Flugzeug hast. Beide können Transportmittel sein (vielleicht eine abstrakte Klasse). Aber Auto, Flugzeug und sogar Boot (wenn du es hinzufügst) – können sich bewegen. Diese Fähigkeit "sich bewegen" ist ein perfekter Kandidat für das Interface IMovable.
interface IMovable
{
void Move(int distance);
}
class Car : IMovable
{
public string Brand;
public Car(string brand) => Brand = brand;
public void Move(int d) => Console.WriteLine($"{Brand} fährt {d} km.");
}
class Airplane : IMovable
{
public string Model;
public Airplane(string model) => Model = model;
public void Move(int d) => Console.WriteLine($"{Model} fliegt {d} km.");
}
class Human : IMovable
{
public string Name;
public Human(string name) => Name = name;
public void Move(int d) => Console.WriteLine($"{Name} geht {d} m.");
}
class Program
{
static void Main()
{
IMovable[] movers = { new Car("Toyota"), new Airplane("Boeing 747"), new Human("Artur") };
foreach (var item in movers)
item.Move(100);
}
}
Siehst du? Wir können eine Liste von IMovable erstellen und die Methode Move() für jedes Element aufrufen, ohne zu wissen, ob es ein Car, Airplane oder Human ist. Das ist die Power von Polymorphie durch Interfaces.
5. Wann sollte man abstrakte Klassen verwenden?
Abstrakte Klassen sind ideal, wenn du gemeinsame Basisfunktionalität für eine Gruppe eng verwandter Klassen definieren willst, die Varianten von etwas Gemeinsamen sind. Sie liefern fertigen Code, den alle Ableitungen gleich brauchen, und zwingen die Ableitungen, spezifische Teile selbst zu implementieren.
Stell dir vor, du hast verschiedene Arten von Bankkonten: SavingAccount (Sparkonto), CheckingAccount (Girokonto), CreditAccount (Kreditkonto). Sie alle sind BankAccount. Alle haben ein Guthaben (Balance) und können Geld einzahlen (Deposit). Aber die Regeln fürs Abheben (Withdraw) sind unterschiedlich. Hier hilft die abstrakte Klasse BankAccount!
abstract class BankAccount
{
public string AccountNumber { get; }
public decimal Balance { get; protected set; }
public BankAccount(string acc) { AccountNumber = acc; }
public void Deposit(decimal sum)
{
if (sum > 0)
{
Balance += sum;
Console.WriteLine($"{AccountNumber}: +{sum:C}, Guthaben: {Balance:C}");
}
}
public abstract bool Withdraw(decimal sum);
}
class CheckingAccount : BankAccount
{
public CheckingAccount(string acc) : base(acc) { }
public override bool Withdraw(decimal sum)
{
if (Balance >= sum)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber}: -{sum:C}, Guthaben: {Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber}: Nicht genug Geld");
return false;
}
}
class CreditAccount : BankAccount
{
public decimal CreditLimit { get; }
public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
public override bool Withdraw(decimal sum)
{
if (Balance - sum >= -CreditLimit)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber}: -{sum:C}, Guthaben: {Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber}: Limit überschritten");
return false;
}
}
class Program
{
static void Main()
{
var checking = new CheckingAccount("12345");
checking.Deposit(1000);
checking.Withdraw(300);
checking.Withdraw(800);
Console.WriteLine("\n--- Kreditkonto ---");
var credit = new CreditAccount("67890", 500);
credit.Deposit(200);
credit.Withdraw(400);
credit.Withdraw(400);
}
}
Hier gibt BankAccount allen Konten die gemeinsame Logik fürs Einzahlen (Deposit) und verwaltet Kontonummer und Guthaben. Aber die Logik fürs Abheben (Withdraw) ist unterschiedlich, deshalb ist sie abstrakt.
6. Wenn sie zusammenarbeiten: das perfekte Paar
Das eleganteste und mächtigste Design kombiniert oft abstrakte Klassen und Interfaces. Eine abstrakte Klasse kann selbst ein oder mehrere Interfaces implementieren!
Stell dir vor: du hast ein abstract Animal, das Grundlegendes definiert. Aber manche Animal können IMovable, ICarnivore, IPredator usw. sein. Dein Animal kann sogar eine Basisimplementierung für IMovable liefern (z.B. Methode Move(int speed)), aber dann überschreiben konkrete Klassen wie Lion oder Fish diese Implementierung, um sich auf ihre eigene Art zu bewegen.
public interface ISaveable
{
void SaveState(string path);
bool HasChanges { get; }
}
public class Vector3
{
public float X, Y, Z;
public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
public override string ToString() => $"({X}, {Y}, {Z})";
}
public abstract class GameObject
{
public string Id { get; }
public Vector3 Position { get; }
protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
public abstract void Update();
public void Destroy() => Console.WriteLine($"{Id} zerstört");
}
public class Player : GameObject, ISaveable
{
public int Health { get; private set; }
private bool _hasChanges = true;
public bool HasChanges => _hasChanges;
public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;
public override void Update() =>
Console.WriteLine($"{Id} wird aktualisiert, HP: {Health}, Pos: {Position}");
public void TakeDamage(int dmg)
{
Health -= dmg;
_hasChanges = true;
Console.WriteLine($"{Id} hat {dmg} Schaden bekommen. HP: {Health}");
}
public void SaveState(string path)
{
Console.WriteLine($"Speichern {Id} (HP:{Health}) in {path}");
_hasChanges = false;
}
}
class GameEngine
{
static void Main()
{
var player = new Player("P1", new Vector3(0, 0, 0), 100);
player.Update();
player.TakeDamage(20);
ISaveable saver = player;
if (saver.HasChanges) saver.SaveState("save.json");
GameObject obj = player;
obj.Update();
player.Destroy();
}
}
In diesem Beispiel Player ist ein GameObject (weil er davon erbt und die gemeinsamen Felder Id, Position und die Methode Destroy nutzt). Und gleichzeitig kann Player gespeichert werden (weil er das Interface ISaveable implementiert). Das ist ein sehr flexibler und mächtiger Ansatz!
7. Feinheiten und typische Fehler
Versuch, eine Instanz einer abstrakten Klasse zu erstellen:
Animal myAnimal = new Animal(); – das ist ein häufiger Anfängerfehler. Denk dran: Eine abstrakte Klasse ist eine Vorlage, kein fertiges Objekt. Der Compiler sagt dir das sofort.
Vergessen, abstrakte Methoden zu implementieren: Wenn du von einer abstrakten Klasse erbst und deine Klasse nicht abstrakt ist, musst du alle abstrakten Methoden der Basisklasse überschreiben (override). Sonst meckert der Compiler.
Versuch, Felder in ein Interface einzufügen: Das ist eines dieser "geht nicht", die Interfaces klar unterscheiden. Seit C# 8+ gibt es statische Felder, aber Instanzfelder (also die, die zu einem konkreten Objekt gehören) sind immer noch verboten. Ein Interface ist ein Verhaltensvertrag, kein Datenspeicher.
Verwechslung von virtual und abstract:
- virtual-Methoden/Properties haben eine Default-Implementierung und können in Ableitungen überschrieben werden.
- abstract-Methoden/Properties haben keine Implementierung und müssen in der ersten nicht-abstrakten Ableitung überschrieben werden.
- Wenn man virtual dort verwendet, wo die Logik immer unterschiedlich ist, muss man leere Implementierungen schreiben und sie dann überschreiben. Das ist ein weniger klarer Vertrag als abstract.
Diese Unterschiede zu verstehen und die richtige Wahl zwischen abstrakter Klasse und Interface zu treffen, ist eine Schlüsselkompetenz für jeden C#-Entwickler. Es geht nicht nur um Syntax, sondern um Architekturdenken. Wenn du ein System entwirfst, frag dich: "Haben diese Klassen eine gemeinsame 'ist ein'-Hierarchie?" und "Haben diese Klassen eine gemeinsame 'Fähigkeit', die auch für nicht verwandte Typen gebraucht wird?". Die Antworten helfen dir, die richtige Entscheidung zu treffen.
In den nächsten Vorlesungen tauchen wir noch tiefer in die Welt der Interfaces ein und schauen uns fortgeschrittene Features an, die in den letzten C#-Versionen dazugekommen sind. Bleib dran!
GO TO FULL VERSION