SOLID-Designprinzipien sind die wichtigsten Designprinzipien, die Sie kennen müssen, um sauberen Code zu schreiben. Eine solide Beherrschung der SOLID-Prinzipien ist eine unverzichtbare Fähigkeit für jeden Programmierer. Sie bilden die Grundlage, auf der andere Designmuster entwickelt werden.
In diesem Artikel werden wir uns anhand einiger Beispiele aus der Praxis mit den SOLID-Designprinzipien befassen und ihre Bedeutung verstehen.
Zusammen mit Polymorphismus, Abstraktion und Vererbung sind die SOLID-Prinzipien wirklich wichtig, um gut in der objektiven Programmierung zu sein.
SOLID-Prinzipien sind aus mehreren Gründen wichtig:
Lassen Sie uns nun jedes der SOLID-Prinzipien im Detail anhand von Beispielen aus der Praxis untersuchen.
S in SOLID-Prinzipien steht für Single-Responsibility-Prinzip. Das Single-Responsibility-Prinzip besagt, dass eine Klasse nur einen einzigen Grund für eine Änderung haben sollte. Dies begrenzt die Anzahl der Stellen, an denen wir Änderungen vornehmen müssen, wenn wir zusätzliche Anforderungen in unser Projekt integrieren.
Jede Klasse sollte genau einen Grund zur Änderung haben.
Nehmen wir zum Beispiel an, wir entwerfen eine Bankanwendung in Java, in der wir über eine SavingsAccount
Klasse verfügen, die grundlegende Vorgänge wie Debit, Credit und sendUpdates
ermöglicht. Die sendUpdate
Methode nimmt eine Enumeration namens NotificationMedium
(wie E-Mail, SMS usw.) und sendet das Update mit dem entsprechenden Medium. Wir werden den Code dafür wie unten gezeigt schreiben.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } }
public enum NotificationMedium { SMS, EMAIL }
Wenn Sie sich nun die SavingsAccount
Klasse oben ansehen, kann sie sich aus mehreren Gründen ändern:
Wenn sich die Kernlogik der SavingsAccount
Klasse ändert (wie debit
, credit
usw.).
Wenn die Bank beschließt, ein neues Benachrichtigungsmedium einzuführen (sagen wir WhatsApp).
Dies stellt einen Verstoß gegen das Single-Responsibility-Prinzip der SOLID-Prinzipien dar. Um das Problem zu beheben, erstellen wir eine separate Klasse, die die Benachrichtigung sendet.
Lassen Sie uns den obigen Code gemäß den SOLID-Prinzipien umgestalten
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } }
public enum NotificationMedium { SMS, EMAIL }
public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } }
Da wir nun den Code umgestaltet haben, ändern wir bei jeder Änderung des NotificationMedium
oder des Formats die Sender
Klasse. Wenn sich jedoch die Kernlogik von SavingsAccount
ändert, wird es auch Änderungen in der SavingsAccount
Klasse geben.
Dadurch wird der Verstoß behoben, den wir im ersten Beispiel beobachtet haben.
Das Open-Close-Prinzip besagt, dass wir die Klassen so gestalten sollten, dass sie für Erweiterungen offen sind (im Falle des Hinzufügens zusätzlicher Funktionen), aber für Änderungen geschlossen sind. Die Schließung wegen Änderung hilft uns in zweierlei Hinsicht:
Um ein Beispiel für das Open-Close-Prinzip zu sehen, werfen wir einen Blick auf das Beispiel eines Warenkorbs (wie er beispielsweise auf E-Commerce-Websites implementiert ist).
Ich werde eine Klasse namens „ Cart
erstellen, die eine Liste von Item
enthält, die Sie hinzufügen können. Abhängig von der Art des Artikels und der darauf erhobenen Besteuerung möchten wir eine Methode erstellen, die den gesamten Warenkorbwert innerhalb der Cart
Klasse berechnet.
Der Unterricht sollte für Erweiterungen geöffnet und für Änderungen geschlossen sein
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } }
@Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } }
public enum ItemType { ELECTRONIC, GIFT }
Im obigen Beispiel berechnet die Methode calculateCartValue
den Warenkorbwert, indem sie alle Artikel im Warenkorb durchläuft und eine Logik basierend auf dem Artikeltyp aufruft.
Obwohl dieser Code korrekt aussieht, verstößt er gegen die SOLID-Prinzipien.
Nehmen wir an, wir müssen bei der Berechnung des Warenkorbwerts eine neue Regel für einen anderen Artikeltyp (z. B. Lebensmittel) hinzufügen. In diesem Fall müssten wir die ursprüngliche Klasse Cart
ändern und eine weitere else if
Bedingung darin schreiben, die nach Artikeln vom Typ Grocery
sucht.
Mit wenig Refactoring können wir jedoch dafür sorgen, dass der Code dem Öffnen/Schließen-Prinzip entspricht. Mal sehen, wie.
Zuerst werden wir die Item
Klasse abstrahieren und konkrete Klassen für verschiedene Arten von Item
erstellen, wie unten gezeigt.
public abstract class Item { protected double price; public double getValue() { return price; } }
public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } }
public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } }
public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } }
In jedem konkreten Item implementieren Klassen wie GroceryItem
, GiftItem
und ElectronicItem
die Methode getValue()
, die die Geschäftslogik für die Besteuerung und Wertberechnung enthält.
Jetzt machen wir die Cart
Klasse von der abstrakten Klasse Item
abhängig und rufen für jedes Element die Methode getValue()
auf, wie unten gezeigt.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } }
In diesem überarbeiteten Code bleibt die Cart
Klasse unverändert, auch wenn neue Item
eingeführt werden. Aufgrund des Polymorphismus wird unabhängig vom tatsächlichen Typ des Item
in der ArrayList
items
die Methode getValue()
dieser Klasse aufgerufen.
Das Substitutionsprinzip von Liskov besagt, dass in einem bestimmten Code der Code nicht beschädigt werden sollte, selbst wenn wir das Objekt einer Oberklasse durch ein Objekt der untergeordneten Klasse ersetzen. Mit anderen Worten: Wenn eine Unterklasse eine Oberklasse erbt und deren Methoden überschreibt, sollte sie mit dem Verhalten der Methode in der Oberklasse konsistent bleiben.
Wenn wir zum Beispiel die folgenden Klassen Vehicle
und zwei Klassen Car
“ und Bicycle
erstellen. Nehmen wir nun an, wir erstellen eine Methode namens startEngine()
innerhalb der Vehicle-Klasse. Sie kann in der Car
Klasse überschrieben werden, wird jedoch in der Bicycle
Klasse nicht unterstützt, da Bicycle
keine Engine hat (siehe Codebeispiel unten).
Die Unterklasse sollte beim Überschreiben von Methoden konsistent mit dem Verhalten der Oberklasse bleiben.
class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } }
Nehmen wir nun an, es gibt Code, der ein Objekt vom Typ Fahrzeug erwartet und auf die Methode startEngine()
angewiesen ist. Wenn wir beim Aufruf dieses Codeabschnitts statt eines Objekts vom Typ „ Vehicle
ein Bicycle
Objekt übergeben, würde dies zu Problemen im Code führen. Da die Methode der Klasse Bicycle
(s) eine Ausnahme auslöst, wenn die Methode startEngine()
aufgerufen wird. Dies wäre ein Verstoß gegen die SOLID-Prinzipien (Liskovs Substitutionsprinzip)
Um dieses Problem zu lösen, können wir zwei Klassen MotorizedVehicle
“ und NonMotorizedVehicle
erstellen und Car
von der Klasse „ MotorizedVehicle
“ und Bicycle
von NonMotorizedVehicle
erben lassen
class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } }
Das „I“ in SOLID Principles steht für das Interface Segregation Principle.
Das Prinzip der Schnittstellentrennung besagt, dass wir statt größerer Schnittstellen, die implementierende Klassen dazu zwingen, ungenutzte Methoden zu implementieren, kleinere Schnittstellen haben und Klassen implementieren sollten. Auf diese Weise implementieren Klassen nur die relevanten Methoden und bleiben sauber.
Teilen Sie Ihre Schnittstellen in mehrere kleinere Schnittstellen auf, anstatt in eine große Schnittstelle.
Schauen wir uns zum Beispiel das integrierte Collections-Framework in Java an. Neben anderen Datenstrukturen bietet Java auch die Datenstrukturen LinkedList
und ArrayList
.
ArrayList
Klasse implementiert die folgenden Schnittstellen: Serializable
, Cloneable
, Iterable
, Collection
, List
und RandomAccess
.
LinkedList
Klasse implementiert Serializable
, Cloneable
, Iterable
, Collection
, Deque
, List
und Queue
.
Das sind ziemlich viele Schnittstellen!
Anstatt so viele Schnittstellen zu haben, hätten Java-Entwickler Serializable
, Cloneable
, Iterable
, Collecton
, List
und RandomAccess
in einer Schnittstelle kombinieren können, sagen wir IList
-Schnittstelle. Nun hätten sowohl ArrayList
als auch LinkedList
Klasse diese neue IList
Schnittstelle implementieren können.
Da LinkedList
jedoch keinen Direktzugriff unterstützt, hätte es die Methoden in der RandomAccess
Schnittstelle implementieren und UnsupportedOperationException
auslösen können, wenn jemand versucht, es aufzurufen.
Dies wäre jedoch ein Verstoß gegen das Prinzip der Schnittstellentrennung in den SOLID-Prinzipien, da es die LinkedList-Klasse „zwingen“ würde, die Methoden innerhalb der RandomAccess
Schnittstelle zu implementieren, auch wenn dies nicht erforderlich ist.
Daher ist es besser, die Schnittstelle basierend auf dem gemeinsamen Verhalten aufzuteilen und jede Klasse viele Schnittstellen implementieren zu lassen, anstatt eine große Schnittstelle.
Das Abhängigkeitsinversionsprinzip besagt, dass Klassen auf der oberen Ebene nicht direkt von den Klassen auf der unteren Ebene abhängen sollten. Dies führt zu einer engen Kopplung zwischen den beiden Ebenen.
Stattdessen sollten niedrigere Klassen eine Schnittstelle bereitstellen, von der die oberen Klassen abhängig sein sollten.
Verlassen Sie sich eher auf Schnittstellen als auf Klassen
Fahren wir beispielsweise mit dem Cart
Beispiel fort, das wir oben gesehen haben, und erweitern es, um einige Zahlungsoptionen hinzuzufügen. Nehmen wir an, wir haben bei uns zwei Zahlungsmöglichkeiten DebitCard
und Paypal
. Nun möchten wir in der Klasse Cart
eine Methode zu placeOrder
hinzufügen, die den Warenkorbwert berechnet und die Zahlung basierend auf der bereitgestellten Zahlung initiiert. Methode.
Zu diesem Zweck hätten wir im obigen Cart
Beispiel eine Abhängigkeit hinzufügen können, indem wir die beiden Zahlungsoptionen als Felder innerhalb der Cart
Klasse hinzugefügt hätten. Dies würde jedoch die Cart
Klasse eng mit der DebitCard
und Paypal
Klasse koppeln.
Stattdessen würden wir eine Payment
erstellen und sowohl die DebitCard
als auch die Paypal
Klasse die Payment
implementieren lassen. Nun hängt die Cart
Klasse von der Payment
ab und nicht von den einzelnen Zahlungsarten. Dadurch bleiben die Klassen lose gekoppelt.
Siehe den Code unten.
public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } }
Wenn Sie daran interessiert sind, mehr über objektorientierte Programmierung, GoF-Entwurfsmuster und Low-Level-Design-Interviews zu erfahren, dann schauen Sie sich meinen hoch bewerteten Kurs „Objektorientierte Programmierung + Java-Entwurfsmuster“ an