paint-brush
So halten Sie Ihren Code SOLIDvon@oxymoron_31
3,997 Lesungen
3,997 Lesungen

So halten Sie Ihren Code SOLID

von Nagarakshitha Ramu6m2023/03/15
Read on Terminal Reader
Read this story w/o Javascript

Zu lang; Lesen

S.O.L.I.D-Prinzipien sind allgemeine Richtlinien zum Schreiben von sauberem Code in der objektorientierten Programmierung. Dazu gehören das Single-Responsibility-Prinzip, das Open-Closed-Prinzip, die Schnittstellentrennung, die Abhängigkeitsumkehr und das Substitutionsprinzip. Diese Prinzipien können auf eine Vielzahl von Programmiersprachen angewendet werden.
featured image - So halten Sie Ihren Code SOLID
Nagarakshitha Ramu HackerNoon profile picture
0-item

Im Großen und Ganzen können alle Programmiersprachen in zwei Paradigmen eingeteilt werden:

Imperative/ objektorientierte Programmierung – Befolgt die Reihenfolge der Anweisungen, die Zeile für Zeile ausgeführt werden.

Deklarative/ funktionale Programmierung – Nicht sequentiell, sondern eher auf den Zweck des Programms ausgerichtet. Das gesamte Programm ist wie eine Funktion, die darüber hinaus noch Unterfunktionen hat, von denen jede eine bestimmte Aufgabe ausführt.

Als Junior-Entwickler wird mir auf die harte Tour klar (sprich: Ich starrte nervös auf tausende Codezeilen), dass es nicht nur darum geht, funktionalen Code zu schreiben, sondern auch um semantisch einfachen und flexiblen Code.

Während es in beiden Paradigmen mehrere Best Practices zum Schreiben von sauberem Code gibt, werde ich über SOLID-Designprinzipien im Zusammenhang mit dem objektorientierten Paradigma der Programmierung sprechen.

Was ist SOLID?

S – Einzelverantwortung
O – Offen-Geschlossen-Prinzip
L – Liskov-Substitutionsprinzip
I – Schnittstellentrennung
D – Abhängigkeitsumkehr

Der Hauptgrund für die Schwierigkeit, diese Konzepte zu verstehen, liegt nicht darin, dass ihre technische Tiefe unergründlich ist, sondern darin, dass es sich um abstrakte Richtlinien handelt, die für das Schreiben von sauberem Code in der objektorientierten Programmierung verallgemeinert werden. Schauen wir uns einige hochrangige Klassendiagramme an, um diese Konzepte zu verdeutlichen.

Hierbei handelt es sich nicht um genaue Klassendiagramme, sondern um grundlegende Blaupausen, die dabei helfen, zu verstehen, welche Methoden in einer Klasse vorhanden sind.

Betrachten wir im gesamten Artikel ein Beispiel eines Cafés.

Prinzip der Einzelverantwortung

Eine Klasse sollte nur einen Grund haben, sich zu ändern

Betrachten Sie diese Klasse, die Online-Bestellungen verarbeitet, die beim Café eingehen.

Was stimmt damit nicht?
Diese einzelne Klasse ist für mehrere Funktionen verantwortlich. Was ist, wenn Sie andere Zahlungsmethoden hinzufügen müssen? Was ist, wenn Sie mehrere Möglichkeiten haben, eine Bestätigung zu senden? Die Änderung der Zahlungslogik in der Klasse, die für die Auftragsabwicklung verantwortlich ist, ist kein besonders gutes Design. Dies führt zu einem äußerst unflexiblen Code.

Eine bessere Möglichkeit wäre, diese spezifischen Funktionalitäten in konkrete Klassen zu unterteilen und eine Instanz davon aufzurufen, wie in der folgenden Abbildung dargestellt.

Offen-Geschlossen-Prinzip

Entitäten sollten für Erweiterungen geöffnet, für Änderungen jedoch geschlossen sein.

Im Café müssen Sie aus einer Liste von Optionen die Gewürze für Ihren Kaffee auswählen und es gibt einen Kurs, der sich darum kümmert.

Das Café beschloss, ein neues Gewürz hinzuzufügen: Butter. Beachten Sie, wie sich der Preis je nach ausgewähltem Gewürz ändert und die Logik zur Preisberechnung in der Klasse „Kaffee“ erfolgt. Wir müssen nicht nur jedes Mal eine neue Condiment-Klasse hinzufügen, was zu möglichen Codeänderungen in der Hauptklasse führt, sondern auch jedes Mal anders mit der Logik umgehen.

Ein besserer Weg wäre, eine Condiments-Schnittstelle zu erstellen, die wiederum untergeordnete Klassen haben kann, die ihre Methoden überschreiben. Und die Hauptklasse kann einfach die Condiments-Schnittstelle verwenden, um die Parameter zu übergeben und die Menge und den Preis für jede Bestellung abzurufen.

Dies hat zwei Vorteile:

1. Sie können Ihre Bestellung dynamisch ändern, um unterschiedliche oder sogar mehrere Gewürze zu erhalten (Kaffee mit Mokka und Schokolade klingt himmlisch).

2. Die Klasse „Condiments“ wird eine „hat-a“-Beziehung zur Klasse „Kaffee“ haben und nicht eine „is-a“. Ihr Kaffee kann also aus Mokka/Butter/Milch bestehen und nicht aus Mokka/Butter/Milch.

Liskov-Substitutionsprinzip

Jede Unterklasse oder abgeleitete Klasse sollte für ihre Basis- oder Elternklasse ersetzbar sein.

Dies bedeutet, dass die Unterklasse die übergeordnete Klasse direkt ersetzen kann. es muss die gleiche Funktionalität haben. Es fiel mir schwer, diese Frage zu verstehen, weil sie wie eine komplexe mathematische Formel klingt. Aber ich werde versuchen, es in diesem Artikel klarzustellen.

Denken Sie an das Personal im Café. Es gibt Baristas, Manager und Kellner. Sie alle haben eine ähnliche Funktionalität.

Daher können wir eine Basis-Staff-Klasse mit Name, Position, getName, getPostion, takeOrder() und Serve() erstellen.

Jede der konkreten Klassen Kellner, Barista und Manager kann daraus abgeleitet werden und die gleichen Methoden überschreiben, um sie je nach Bedarf für die Position zu implementieren.

In diesem Beispiel wird das Liskov-Substitutionsprinzip (LSP) verwendet, um sicherzustellen, dass jede abgeleitete Klasse von Staff austauschbar mit der Basisklasse Staff verwendet werden kann, ohne die Richtigkeit des Codes zu beeinträchtigen.

Beispielsweise erweitert die Klasse „Waiter“ die Klasse „Staff“ und überschreibt die Methoden „takeOrder“ und „serveOrder“, um zusätzliche Funktionen einzuschließen, die für die Rolle eines Kellners spezifisch sind. Noch wichtiger ist jedoch, dass trotz der Unterschiede in der Funktionalität jeder Code, der ein Objekt der Staff-Klasse erwartet, auch korrekt mit einem Objekt der Waiter-Klasse funktionieren kann.

Sehen wir uns ein Beispiel an, um dies zu verstehen

 public class Cafe {    public void serveCustomer (Staff staff) { staff.takeOrder(); staff.serveOrder(); } } public class Main {    public static void main (String[] args) { Cafe cafe = new Cafe(); Staff staff1 = new Staff( "John" , "Staff" ); Waiter waiter1 = new Waiter( "Jane" ); restaurant.serveCustomer(staff1); // Works correctly with Staff object
 restaurant.serveCustomer(waiter1); // Works correctly with Waiter object
 } }

Hier verwendet die Methode ServeCustomer() in der Klasse Cafe ein Staff-Objekt als Parameter. Die Methode „serveCustomer()“ ruft die Methoden „takeOrder()“ und „serveOrder()“ des Staff-Objekts auf, um den Kunden zu bedienen.

In der Main-Klasse erstellen wir ein Staff-Objekt und ein Waiter-Objekt. Anschließend rufen wir die Methode „serveCustomer()“ der Klasse „Cafe“ zweimal auf – einmal mit dem Staff-Objekt und einmal mit dem Waiter-Objekt.

Da die Waiter-Klasse von der Staff-Klasse abgeleitet ist, kann jeder Code, der ein Objekt der Staff-Klasse erwartet, auch korrekt mit einem Objekt der Waiter-Klasse funktionieren. In diesem Fall funktioniert die Methode „serveCustomer()“ der Klasse „Cafe“ ordnungsgemäß sowohl mit dem Staff-Objekt als auch mit dem Waiter-Objekt, obwohl das Waiter-Objekt über zusätzliche Funktionen verfügt, die für die Rolle eines Kellners spezifisch sind.

Schnittstellentrennung

Klassen sollten nicht gezwungen werden, sich auf Methoden zu verlassen, die sie nicht verwenden.

Deshalb gibt es im Café diesen sehr vielseitigen Verkaufsautomaten, der Kaffee, Tee, Snacks und Limonade ausgeben kann.

Was ist daran falsch? Technisch gesehen nichts. Wenn Sie die Schnittstelle für Funktionen wie die Ausgabe von Kaffee implementieren müssen, müssen Sie auch andere Methoden implementieren, die für Tee, Limonade und Snacks gedacht sind. Dies ist unnötig und diese Funktionen stehen in keinem Zusammenhang miteinander. Jede dieser Funktionen weist eine sehr geringe Kohäsion zwischen ihnen auf.

Was ist Zusammenhalt? Es ist ein Faktor, der bestimmt, wie stark die Methoden in einer Schnittstelle miteinander in Beziehung stehen.

Und im Fall des Verkaufsautomaten sind die Methoden kaum voneinander abhängig. Wir können die Methoden trennen, da sie eine sehr geringe Kohäsion aufweisen.

Nun muss jede Schnittstelle, die eine Sache implementieren soll, nur takeMoney() implementieren, was für alle Funktionen gleich ist. Dies trennt die nicht verwandten Funktionen in einer Schnittstelle und vermeidet so die erzwungene Implementierung nicht verwandter Funktionen in einer Schnittstelle.

Abhängigkeitsumkehr

High-Level-Module sollten nicht von Low-Level-Modulen abhängig sein. Details müssen von Abstraktionen abhängen.

Denken Sie an die Klimaanlage (Kühler) im Café. Und wenn Sie wie ich sind, ist es dort immer eiskalt. Schauen wir uns die Fernbedienung und die AC-Klassen an.

Hier ist remoteControl das High-Level-Modul, das von AC, der Low-Level-Komponente, abhängt. Wenn ich eine Stimme bekomme, würde ich mir auch eine Heizung wünschen :P Um also eine generische Temperaturregelung statt eines Kühlers zu haben, lasst uns Fernbedienung und Temperaturregelung entkoppeln. Aber die remoteControl-Klasse ist eng mit AC gekoppelt, was eine konkrete Implementierung darstellt. Um die Abhängigkeit zu entkoppeln, können wir eine Schnittstelle erstellen, die nur die Funktionen „erhöhungTemp()“ und „reduzierteTemp()“ in einem Bereich von beispielsweise 45–65 °F hat.

Abschließende Gedanken

Wie Sie sehen, hängen sowohl die High-Level- als auch die Low-Level-Module von einer Schnittstelle ab, die die Funktionalität der Temperaturerhöhung oder -senkung abstrahiert.

Die konkrete Klasse AC implementiert die Methoden mit dem anwendbaren Temperaturbereich.

Jetzt kann ich wahrscheinlich die Heizung bekommen, die ich möchte, indem ich verschiedene Temperaturbereiche in einer anderen Betonklasse namens Heizung umsetze.

Das High-Level-Modul remoteControl muss sich nur darum kümmern, während der Laufzeit die richtige Methode aufzurufen.