Warum moderne Sprachen (Go, Rust) Komposition der Vererbung vorziehen
Olivia Novak
Dev Intern · Leapcell

Was treibt neue Sprachen wie Rust und Go dazu, Komposition zu bevorzugen und Vererbung aufzugeben?
Java hat eine Richtlinie: “Verwende Vererbung nur, wenn du einen guten Grund dafür hast.” Aber Java schränkt die Vererbung nicht strikt ein – du kannst sie immer noch frei verwenden. Go ist jedoch anders: es erlaubt nur Komposition.
Warum fördern moderne Sprachen also Komposition? Tatsächlich sind es nicht nur neue Sprachen – selbst der Veteran Java erwähnt in Item 16 von Effective Java: “Bevorzuge Komposition vor Vererbung.” Vererbung ist eine mächtige Möglichkeit, Code wiederzuverwenden, aber es ist möglicherweise nicht die beste Methode.
Es lohnt sich also, dies sorgfältig zu durchdenken. Ohne in spezifischen Code einzutauchen, lass uns über die Konzepte sprechen. Ich glaube, wir können dieses Thema aus den folgenden Perspektiven betrachten:
- Die Eigenschaften von Vererbung und Komposition
- Welche Probleme verursacht Vererbung in der praktischen Anwendung?
- Wie löst Komposition diese Probleme?
Lass uns ein reales Beispiel untersuchen und unser Design schrittweise optimieren, indem wir die Vererbung aufgeben und Schnittstellen und Komposition einsetzen. Nach dem Lesen dieses Artikels wirst du wahrscheinlich ein neues Verständnis von Vererbung und Komposition haben.
Eigenschaften von Vererbung und Komposition
Wir werden hier nicht auf die Definitionen eingehen. Vererbung und Komposition sind zwei gängige Techniken zur Wiederverwendung von Code in der objektorientierten Programmierung. Sie ermöglichen beide die Wiederverwendung, aber jede hat ihre eigenen Vor- und Nachteile. Lass uns beide kurz besprechen.
Vererbung
Vorteile:
- Code-Wiederverwendung: Eigenschaften und Methoden, die in der Elternklasse definiert sind, können direkt in der Unterklasse verwendet werden.
- Erweiterbarkeit über Vererbungsketten: Unterklassen können alle Eigenschaften und Methoden von ihren Vorfahren erben, was die Skalierbarkeit und Wartbarkeit verbessert.
- Sowohl Vererbung als auch Komposition können Polymorphismus unterstützen, wobei sich dieselbe Methode in verschiedenen Unterklassen unterschiedlich verhält.
Nachteile:
- Änderungen in der Elternklasse wirken sich auf Unterklassen aus: Wenn sich die Elternklasse ändert, müssen möglicherweise alle ihre Unterklassen entsprechend geändert werden, was die Wartungskosten erhöht.
- Enge Kopplung: Die Unterklasse ist eng mit der Elternklasse gekoppelt, was die Codeflexibilität und Portabilität verringert.
Lass uns nun die Komposition betrachten. Im Vergleich zur Vererbung hat die Komposition die folgenden Eigenschaften:
Komposition
Vorteile:
- Reduzierte Kopplung: Beziehungen zwischen Objekten sind locker; das Ändern eines Objekts wirkt sich nicht auf andere aus.
- Flexibles Design: Du kannst verschiedene Objekte nach Bedarf kombinieren.
- Schnittstellensegregation: Komposition ermöglicht die Trennung von Belangen, sodass verschiedene Funktionsmodule unabhängig voneinander implementiert werden können, was die Wiederverwendbarkeit des Codes verbessert.
Nachteile:
- Erhöhtes Codevolumen: Im Vergleich zur Vererbung erfordert Komposition möglicherweise mehr Code, um verschiedene Kombinationen zu implementieren.
- Komplexere Interaktionen: Unter Komposition benötigen Objektinteraktionen möglicherweise komplexere Schnittstellendefinitionen und -implementierungen, was die Komplexität erhöht.
Welche Probleme verursacht Vererbung in der Praxis, und wie können wir sie optimieren?
1. Anfängliche Probleme
Nehmen wir an, wir möchten eine Klasse für ein Fahrzeug entwerfen. Gemäß dem objektorientierten Denken abstrahieren wir ein allgemeines Konzept eines "Fahrzeugs" in eine BaseCar
-Klasse, die ein Standardverhalten run()
hat. Alle Arten von Fahrzeugen, wie Autos und Lastwagen, können von dieser abstrakten Klasse erben.
public class BaseCar { //... ausgelassene andere Eigenschaften und Methoden... public void run() { /*...*/ } } // Auto public class Car extends AbstractCar { }
Basierend auf unserem Verständnis und unseren Anforderungen an das Objekt "Fahrzeug" sollte ein Fahrzeug jedoch nicht nur fahren, sondern auch seine Reifen und seinen Motor reparieren können. Also wird das AbstractCar
so etwas wie das hier:
public class BaseCar { //... ausgelassene andere Eigenschaften und Methoden... public void run() { /* läuft... */ } public void repaireTire() { /* repariert Reifen... */ } public void repaireEngine() { /* repariert Motor... */ } }
Nun müssen wir eine Fahrradklasse implementieren. Aber ein Fahrrad hat keinen Motor – wie gehen wir also damit um?
public class Bicycle extends BaseCar { //... ausgelassene andere Eigenschaften und Methoden... public void repaireEngine() { throw new UnSupportedMethodException("Ich habe keinen Motor!"); } }
Auf den ersten Blick scheint die obige Logik das Problem zu lösen, aber tatsächlich kann sie zu einem riesigen Durcheinander führen. Es gibt drei Hauptprobleme mit diesem Design:
Erstens, wenn wir weiterhin alle möglichen Verhaltensweisen zur Basisklasse hinzufügen – zum Beispiel autonomes Fahren, Panorama-Sonnendach, Sonnendachfunktion – müssten wir alles in die Basisklasse stopfen. Dies verbessert zwar die Wiederverwendung, verändert aber auch die Funktionalität jeder Unterklasse, was die Komplexität erhöht – etwas, das wir vermeiden wollen.
Zweitens ist es problematisch, irrelevante Funktionalitäten für nicht verwandte Objekte (wie Motorreparatur für Fahrräder) freizugeben. Die Fahrradklasse sollte sich überhaupt nicht mit Motorreparaturmethoden befassen müssen.
Drittens, was ist mit zukünftiger Erweiterbarkeit? Was ist, wenn wir eine Person oder ein Flugzeug modellieren wollen, die beide auch "laufen" können (im gewissen Sinne)? Dieses Design skaliert nicht gut und ist wenig flexibel.
Wie gehen wir also die oben genannten Probleme an? Du hast es vielleicht erraten – Schnittstellen. Schnittstellen konzentrieren sich mehr auf die Definition von Verhalten, während abstrakte Klassen typischerweise ein gemeinsames Basisverhalten für einen Typ definieren. Die Verwendung von abstrakten Klassen hat hier tatsächlich die Komplexität erhöht.
2. Optimierung mit Schnittstellen
Um die oben genannten Probleme zu lösen, lass uns spezifische Objekte ignorieren und uns stattdessen rein auf Verhaltensweisen konzentrieren: Laufen, Reparieren des Motors und Reparieren von Reifen. Wir können diese Verhaltensweisen als Schnittstellen definieren: IRun
, IEngine
und ITire
.
public interface IRun { void run(); } public interface IEngine { void repaireEngine(); } public interface ITire { void repaireTire(); }
Wenn wir nun die Car
-Klasse implementieren, implementieren wir alle drei Schnittstellen: IRun
, IEngine
und ITire
. Für ein Bicycle
implementieren wir nur IRun
und ITire
. Für eine Person
müssen wir nur IRun
implementieren.
public class Car implements IRun, IEngine, ITire { //... ausgelassene andere Eigenschaften und Methoden... @Override public void run() { /* läuft... */ } @Override public void repaireEngine() { /* repariert Motor... */ } @Override public void repaireTire() { /* repariert Reifen... */ } } public class Bicycle implements IRun, ITire { //... ausgelassene andere Eigenschaften und Methoden... @Override public void run() { /* läuft... */ } @Override public void repaireTire() { /* repariert Reifen... */ } } public class Person implements IRun { //... ausgelassene andere Eigenschaften und Methoden... @Override public void run() { /* läuft... */ } }
Macht das die Dinge nicht viel flexibler? Mittlerweile solltest du anfangen zu verstehen, warum moderne Sprachen wie Go und Rust Vererbung und abstrakte Klassen aufgegeben haben und stattdessen Schnittstellen zur Verhaltensabstraktion beibehalten haben.
Es gibt jedoch noch ein Problem: Es scheint, als ob jedes Objekt immer noch manuell run()
, repaireEngine()
, repaireTire()
usw. implementieren muss. Ist das nicht lästig? Was ist mit der Wiederverwendung von Code?
Warte – die Komposition steht kurz vor dem Auftritt.
3. Optimierung mit Komposition
Um das oben genannte Problem zu lösen, können wir zuerst die Schnittstellen implementieren und dann Komposition und Delegation verwenden, um die Wiederverwendung zu erreichen. So könnte der Code aussehen:
public class CarRunEnable implements IRun { @Override public void run() { /* Auto fährt... */ } } public class PersonRunEnable implements IRun { @Override public void run() { /* Person läuft... */ } } // Andere Implementierungen ausgelassen: EngineEnable / TireEnable
Dann definieren wir unsere Objektklassen mithilfe von Komposition – jede Klasse enthält Verhaltensklassen als Felder und delegiert die Schnittstellenmethodenaufrufe an diese Felder.
public class Car implements IRun, IEngine, ITire { private CarRunEnable runEnable = new CarRunEnable(); // Komposition private EngineEnable engineEnable = new EngineEnable(); // Komposition private TireEnable tireEnable = new TireEnable(); // Komposition //... ausgelassene andere Eigenschaften und Methoden... @Override public void run() { runEnable.run(); } @Override public void repaireEngine() { engineEnable.repaireEngine(); } @Override public void repaireTire() { tireEnable.repaireTire(); } }
Schauen wir uns die Klassen Bicycle
und Person
an:
public class Bicycle implements IRun, ITire { private CarRunEnable runEnable = new CarRunEnable(); // Komposition private TireEnable tireEnable = new TireEnable(); // Komposition //... ausgelassene andere Eigenschaften und Methoden... @Override public void run() { runEnable.run(); } @Override public void repaireTire() { tireEnable.repaireTire(); } } public class Person implements IRun { private PersonRunEnable runEnable = new PersonRunEnable(); // Komposition //... ausgelassene andere Eigenschaften und Methoden... @Override public void run() { runEnable.run(); } }
Findet ihr die Logik im obigen Code nicht viel sauberer und befriedigender?
Möchtest du neue Funktionen hinzufügen? Nur zu – es wird keine anderen Klassen beeinträchtigen.
Die Kopplung wurde deutlich reduziert, und der Zusammenhalt ist nicht schlechter als bei der Vererbung. Das ist es, was die Leute als hoher Zusammenhalt, niedrige Kopplung bezeichnen.
Der einzige Nachteil ist, dass das gesamte Codevolumen zugenommen hat.
Kommen wir also zur ursprünglichen Frage zurück:
Was veranlasst neue Sprachen wie Rust und Go, Komposition zu favorisieren und Vererbung aufzugeben?
Inzwischen hast du wahrscheinlich deine Antwort.
Wir sind Leapcell, deine erste Wahl für das Hosten von Rust-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Multi-Sprachen-Unterstützung
- Entwickle mit Node.js, Python, Go oder Rust.
Stelle unbegrenzt viele Projekte kostenlos bereit
- Zahle nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 $ unterstützen 6,94 Mio. Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeit-Metriken und -Protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Auto-Skalierung zur einfachen Bewältigung hoher Parallelität.
- Kein Betriebsaufwand – konzentriere dich einfach auf den Aufbau.
Erfahre mehr in der Dokumentation!
Folge uns auf X: @LeapcellHQ