Einleitung: Die Falle, in die viele getappt sind
\nStellen Sie sich vor: Sie schreiben eine Anwendung für eine Online-Bibliothek. Sie benötigen eine eigene Klasse Library, die Bücher speichert, sortieren, suchen und Statistiken ausgeben kann. Das Erste, was einem in den Sinn kommt, ist, von List<Book> zu erben. Schließlich hat es bereits alles: Add, Remove, Indizierung, Suche. Logisch? Scheint so.
Aber erfahrene C#-Entwickler werden Ihnen sagen: „Erben Sie niemals von List<T>“. Diese Frage auf Stack Overflow hat tausende Aufrufe, und das nicht ohne Grund. Hinter der scheinbaren Einfachheit verbergen sich ernsthafte Probleme mit der Architektur, der Leistung und, am wichtigsten, mit der Verletzung der Prinzipien des objektorientierten Designs.
\nIn diesem Artikel werfen wir einen Blick unter die Haube von List<T>, verstehen, warum das Erben davon ein Anti-Pattern ist, und was stattdessen verwendet werden sollte. Bereit? Los geht's!
Was ist aus Design-Sicht falsch an List<T>?
\nBeginnen wir mit dem Wesentlichen: List<T> wurde nicht als Basis für eine Hierarchie entworfen, sondern als konkrete, hochperformante Implementierung eines dynamischen Arrays. Seine öffentliche API ist eine Sammlung von Methoden zur Arbeit mit der Collection, kein Vertrag zur Erweiterung.
Betrachten Sie seine Deklaration in den .NET-Quellen (vereinfacht):
\npublic class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable\n{\n private T[] _items;\n private int _size;\n \n public void Add(T item) { /* ... */ }\n public bool Remove(T item) { /* ... */ }\n // ... Dutzende anderer Methoden\n}\nBeachten Sie: List<T> ist nicht als abstract markiert. Es bietet keine virtuellen Methoden zum Überschreiben (mit Ausnahme einiger weniger, auf die wir später eingehen). Das bedeutet, dass Sie sein Verhalten nicht sicher ändern können. Wenn Sie Add überschreiben, riskieren Sie, die interne Logik zu zerstören, die von dieser Methode abhängt.
Problem Nr. 1: Verletzung des Liskovschen Substitutionsprinzips (LSP)
\nDas Liskovsche Substitutionsprinzip besagt: „Eine abgeleitete Klasse sollte das Verhalten der Basisklasse ergänzen, nicht ersetzen.“ Wenn Sie von List<T> erben, versprechen Sie, dass Ihre Klasse überall dort verwendet werden kann, wo ein List<T> erwartet wird. Dies wird jedoch selten eingehalten.
Beispiel: Sie erstellen eine Klasse ReadOnlyLibrary : List<Book> und überschreiben Add, sodass es eine Ausnahme auslöst:
public class ReadOnlyLibrary : List<Book>\n{\n public new void Add(Book book)\n {\n throw new InvalidOperationException("Sammlung ist schreibgeschützt!");\n }\n}\n\n// Irgendwo im Code:\nList<Book> list = new ReadOnlyLibrary();\nlist.Add(new Book()); // Hoppla! Es wird keine Ausnahme ausgelöst!\n// Weil 'new' die Methode nicht überschreibt, sondern verbirgt.\n// Es wird List.Add aufgerufen, nicht unsere.\nSelbst wenn Sie override verwenden würden (und List.Add ist nicht virtuell), könnten Sie das Hinzufügen nicht verbieten – das Interface IList sieht dies nicht vor. Sie würden einfach den Vertrag brechen.
Problem Nr. 2: Verletzung der Kapselung
\nIndem Sie von List<T> erben, legen Sie dessen gesamte öffentliche API offen. Ihre Klasse Library erhält die Methoden Sort, BinarySearch, Reverse, Insert usw. Aber was ist, wenn Sie möchten, dass die Bücher immer nach Autor sortiert sind? Der Benutzer Ihrer Klasse kann library.Reverse() aufrufen und die Invariante zerstören.
Sie können dies nicht verhindern, ohne alle Methoden neu zu schreiben – und es sind Dutzende. Dies ist eine Verletzung der Kapselung: Sie legen die interne Implementierung offen, die Sie nicht kontrollieren können.
\n\nEin Blick unter die Haube: Wie List<T> intern aufgebaut ist
\nUm zu verstehen