Die SOLID-Prinzipien der objektorientierten Programmierung werden in einfachem Englisch erklärt

Die SOLID-Prinzipien sind fünf Prinzipien des objektorientierten Klassendesigns. Sie sind eine Reihe von Regeln und Best Practices, die beim Entwerfen einer Klassenstruktur befolgt werden müssen.

Diese fünf Prinzipien helfen uns, die Notwendigkeit bestimmter Entwurfsmuster und Softwarearchitekturen im Allgemeinen zu verstehen. Daher glaube ich, dass es ein Thema ist, das jeder Entwickler lernen sollte.

In diesem Artikel erfahren Sie alles, was Sie wissen müssen, um SOLID-Prinzipien auf Ihre Projekte anzuwenden.

Wir werden zunächst einen Blick in die Geschichte dieses Begriffs werfen. Dann werden wir uns mit den Details befassen - dem Warum und Wie jedes Prinzips -, indem wir ein Klassendesign erstellen und es Schritt für Schritt verbessern.

Also schnapp dir eine Tasse Kaffee oder Tee und lass uns gleich loslegen!

Hintergrund

Die SOLID-Prinzipien wurden erstmals 2000 von dem berühmten Informatiker Robert J. Martin (alias Onkel Bob) in seiner Arbeit eingeführt. Das Akronym SOLID wurde jedoch später von Michael Feathers eingeführt.

Onkel Bob ist auch Autor der Bestseller Clean Code und Clean Architecture und einer der Teilnehmer der "Agile Alliance".

Daher ist es nicht verwunderlich, dass all diese Konzepte der sauberen Codierung, der objektorientierten Architektur und der Entwurfsmuster irgendwie miteinander verbunden und komplementär sind.

Sie alle dienen demselben Zweck:

"Um verständlichen, lesbaren und testbaren Code zu erstellen, an dem viele Entwickler gemeinsam arbeiten können."

Schauen wir uns jedes Prinzip einzeln an. Nach dem Akronym SOLID lauten sie:

  • Die S ingle Prinzip Verantwortung
  • Das O- Pen-Closed-Prinzip
  • Das L iskov-Substitutionsprinzip
  • Die I nterface Segregationsprinzip
  • Das D ependenzinversionsprinzip

Das Prinzip der Einzelverantwortung

Das Prinzip der Einzelverantwortung besagt, dass eine Klasse eine Sache tun sollte und daher nur einen einzigen Grund zur Änderung haben sollte .

Um dieses Prinzip technischer zu formulieren: Nur eine mögliche Änderung (Datenbanklogik, Protokollierungslogik usw.) in der Softwarespezifikation sollte die Spezifikation der Klasse beeinflussen können.

Dies bedeutet, dass eine Klasse, die ein Datencontainer wie eine Buchklasse oder eine Schülerklasse ist und einige Felder in Bezug auf diese Entität enthält, sich nur ändern sollte, wenn wir das Datenmodell ändern.

Es ist wichtig, das Prinzip der Einzelverantwortung zu befolgen. Da viele verschiedene Teams aus unterschiedlichen Gründen an demselben Projekt arbeiten und dieselbe Klasse bearbeiten können, kann dies zu inkompatiblen Modulen führen.

Zweitens erleichtert es die Versionskontrolle. Angenommen, wir haben eine Persistenzklasse, die Datenbankoperationen verarbeitet, und wir sehen eine Änderung in dieser Datei in den GitHub-Commits. Wenn Sie dem SRP folgen, wissen wir, dass es sich um Speicher oder datenbankbezogene Inhalte handelt.

Zusammenführungskonflikte sind ein weiteres Beispiel. Sie werden angezeigt, wenn verschiedene Teams dieselbe Datei ändern. Wenn jedoch die SRP befolgt wird, treten weniger Konflikte auf - Dateien haben nur einen Grund zur Änderung, und vorhandene Konflikte lassen sich leichter lösen.

Häufige Fallstricke und Anti-Muster

In diesem Abschnitt werden einige häufige Fehler behandelt, die gegen das Prinzip der Einzelverantwortung verstoßen. Dann werden wir über einige Möglichkeiten sprechen, sie zu beheben.

Wir werden uns den Code für ein einfaches Rechnungsprogramm für Buchhandlungen als Beispiel ansehen. Beginnen wir mit der Definition einer Buchklasse, die in unserer Rechnung verwendet werden soll.

class Book { String name; String authorName; int year; int price; String isbn; public Book(String name, String authorName, int year, int price, String isbn) { this.name = name; this.authorName = authorName; this.year = year; this.price = price; this.isbn = isbn; } } 

Dies ist eine einfache Buchklasse mit einigen Feldern. Nichts Besonderes. Ich mache Felder nicht privat, damit wir uns nicht mit Gettern und Setzern befassen müssen und uns stattdessen auf die Logik konzentrieren können.

Erstellen wir nun die Rechnungsklasse, die die Logik zum Erstellen der Rechnung und zum Berechnen des Gesamtpreises enthält. Nehmen wir vorerst an, dass unsere Buchhandlung nur Bücher verkauft und sonst nichts.

public class Invoice { private Book book; private int quantity; private double discountRate; private double taxRate; private double total; public Invoice(Book book, int quantity, double discountRate, double taxRate) { this.book = book; this.quantity = quantity; this.discountRate = discountRate; this.taxRate = taxRate; this.total = this.calculateTotal(); } public double calculateTotal() { double price = ((book.price - book.price * discountRate) * this.quantity); double priceWithTaxes = price * (1 + taxRate); return priceWithTaxes; } public void printInvoice() { System.out.println(quantity + "x " + book.name + " " + book.price + "$"); System.out.println("Discount Rate: " + discountRate); System.out.println("Tax Rate: " + taxRate); System.out.println("Total: " + total); } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Hier ist unsere Rechnungsklasse. Es enthält auch einige Felder zur Rechnungsstellung und 3 Methoden:

  • berechneGesamtmethode , die den Gesamtpreis berechnet,
  • printInvoice- Methode, mit der die Rechnung an die Konsole gedruckt werden soll, und
  • saveToFile- Methode, die für das Schreiben der Rechnung in eine Datei verantwortlich ist.

Sie sollten sich eine Sekunde Zeit nehmen, um darüber nachzudenken, was mit diesem Klassendesign nicht stimmt, bevor Sie den nächsten Absatz lesen.

Ok, was ist hier los? Unsere Klasse verstößt in mehrfacher Hinsicht gegen das Prinzip der Einzelverantwortung.

Die erste Verletzung ist die printInvoice- Methode, die unsere Drucklogik enthält. Die SRP besagt, dass unsere Klasse nur einen einzigen Grund für eine Änderung haben sollte, und dieser Grund sollte eine Änderung in der Rechnungsberechnung für unsere Klasse sein.

Wenn wir in dieser Architektur das Druckformat ändern möchten, müssen wir die Klasse ändern. Aus diesem Grund sollten wir keine Drucklogik mit Geschäftslogik in derselben Klasse mischen.

Es gibt eine andere Methode, die gegen die SRP in unserer Klasse verstößt: die saveToFile- Methode. Es ist auch ein äußerst häufiger Fehler, Persistenzlogik mit Geschäftslogik zu mischen.

Denken Sie nicht nur an das Schreiben in eine Datei - es kann sich um das Speichern in einer Datenbank, einen API-Aufruf oder andere Dinge handeln, die mit der Persistenz zusammenhängen.

Wie können wir diese Druckfunktion beheben?

Wir können neue Klassen für unsere Druck- und Persistenzlogik erstellen, sodass wir die Rechnungsklasse für diese Zwecke nicht mehr ändern müssen.

Wir erstellen zwei Klassen, InvoicePrinter und InvoicePersistence, und verschieben die Methoden.

public class InvoicePrinter { private Invoice invoice; public InvoicePrinter(Invoice invoice) { this.invoice = invoice; } public void print() { System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $"); System.out.println("Discount Rate: " + invoice.discountRate); System.out.println("Tax Rate: " + invoice.taxRate); System.out.println("Total: " + invoice.total + " $"); } }
public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Jetzt folgt unsere Klassenstruktur dem Prinzip der Einzelverantwortung und jede Klasse ist für einen Aspekt unserer Anwendung verantwortlich. Toll!

Offen-Geschlossen-Prinzip

Das Open-Closed-Prinzip verlangt, dass Klassen für Erweiterungen geöffnet und für Änderungen geschlossen sind.

Änderung bedeutet, den Code einer vorhandenen Klasse zu ändern, und Erweiterung bedeutet, neue Funktionen hinzuzufügen.

Dieses Prinzip möchte also sagen: Wir sollten in der Lage sein, neue Funktionen hinzuzufügen, ohne den vorhandenen Code für die Klasse zu berühren. Dies liegt daran, dass wir bei jeder Änderung des vorhandenen Codes das Risiko eingehen, potenzielle Fehler zu verursachen. Wir sollten daher nach Möglichkeit vermeiden, den getesteten und zuverlässigen (meistens) Produktionscode zu berühren.

Aber wie können wir neue Funktionen hinzufügen, ohne die Klasse zu berühren? Dies geschieht normalerweise mit Hilfe von Schnittstellen und abstrakten Klassen.

Nachdem wir die Grundlagen des Prinzips behandelt haben, wenden wir es auf unsere Rechnungsanwendung an.

Nehmen wir an, unser Chef ist zu uns gekommen und hat gesagt, dass Rechnungen in einer Datenbank gespeichert werden sollen, damit wir sie einfach durchsuchen können. Wir denken okay, das ist einfach peasy Chef, gib mir nur eine Sekunde!

Wir erstellen die Datenbank, stellen eine Verbindung dazu her und fügen unserer InvoicePersistence- Klasse eine Speichermethode hinzu :

public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } public void saveToDatabase() { // Saves the invoice to database } }

Leider haben wir als fauler Entwickler des Buchladens die Klassen nicht so gestaltet, dass sie in Zukunft leicht erweiterbar sind. Um diese Funktion hinzuzufügen, haben wir die InvoicePersistence- Klasse geändert .

Wenn unser Klassendesign dem Open-Closed-Prinzip entsprechen würde, müssten wir diese Klasse nicht ändern.

Als fauler, aber kluger Entwickler für den Buchladen sehen wir das Designproblem und beschließen, den Code umzugestalten, um dem Prinzip zu folgen.

interface InvoicePersistence { public void save(Invoice invoice); }

Wir ändern den Typ von InvoicePersistence in Interface und fügen eine Speichermethode hinzu. Jede Persistenzklasse implementiert diese Speichermethode.

public class DatabasePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to DB } }
public class FilePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to file } }

Unsere Klassenstruktur sieht jetzt so aus:

Jetzt ist unsere Persistenzlogik leicht erweiterbar. Wenn unser Chef uns auffordert, eine weitere Datenbank hinzuzufügen und zwei verschiedene Arten von Datenbanken wie MySQL und MongoDB zu haben, können wir das problemlos tun.

Sie könnten denken, wir könnten einfach mehrere Klassen ohne Schnittstelle erstellen und allen eine Speichermethode hinzufügen.

Angenommen , wir erweitern unsere App und verfügen über mehrere Persistenzklassen wie InvoicePersistence , BookPersistence. Außerdem erstellen wir eine PersistenceManager- Klasse, die alle Persistenzklassen verwaltet:

public class PersistenceManager { InvoicePersistence invoicePersistence; BookPersistence bookPersistence; public PersistenceManager(InvoicePersistence invoicePersistence, BookPersistence bookPersistence) { this.invoicePersistence = invoicePersistence; this.bookPersistence = bookPersistence; } }

Wir können jetzt jede Klasse, die die InvoicePersistence- Schnittstelle implementiert, mit Hilfe des Polymorphismus an diese Klasse übergeben. Dies ist die Flexibilität, die Schnittstellen bieten.

Liskov-Substitutionsprinzip

The Liskov Substitution Principle states that subclasses should be substitutable for their base classes.

This means that, given that class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A and the method should not give any weird output in that case.

This is the expected behavior, because when we use inheritance we assume that the child class inherits everything that the superclass has. The child class extends the behavior but never narrows it down.

Therefore, when a class does not obey this principle, it leads to some nasty bugs that are hard to detect.

Liskov's principle is easy to understand but hard to detect in code. So let's look at an example.

class Rectangle { protected int width, height; public Rectangle() { } public Rectangle(int width, int height) { this.width = width; this.height = height; } public int getWidth() { return width; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } }

We have a simple Rectangle class, and a getArea function which returns the area of the rectangle.

Now we decide to create another class for Squares. As you might know, a square is just a special type of rectangle where the width is equal to the height.

class Square extends Rectangle { public Square() {} public Square(int size) { width = height = size; } @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); } @Override public void setHeight(int height) { super.setHeight(height); super.setWidth(height); } }

Our Square class extends the Rectangle class. We set height and width to the same value in the constructor, but we do not want any client (someone who uses our class in their code) to change height or weight in a way that can violate the square property.

Therefore we override the setters to set both properties whenever one of them is changed. But by doing that we have just violated the Liskov substitution principle.

Let's create a main class to perform tests on the getArea function.

class Test { static void getAreaTest(Rectangle r) { int width = r.getWidth(); r.setHeight(10); System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea()); } public static void main(String[] args) { Rectangle rc = new Rectangle(2, 3); getAreaTest(rc); Rectangle sq = new Square(); sq.setWidth(5); getAreaTest(sq); } }

Your team's tester just came up with the testing function getAreaTest and tells you that your getArea function fails to pass the test for square objects.

In the first test, we create a rectangle where the width is 2 and the height is 3 and call getAreaTest. The output is 20 as expected, but things go wrong when we pass in the square. This is because the call to setHeight function in the test is setting the width as well and results in an unexpected output.

Interface Segregation Principle

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

The principle states that many client-specific interfaces are better than one general-purpose interface. Clients should not be forced to implement a function they do no need.

This is a simple principle to understand and apply, so let's see an example.

public interface ParkingLot { void parkCar(); // Decrease empty spot count by 1 void unparkCar(); // Increase empty spots by 1 void getCapacity(); // Returns car capacity double calculateFee(Car car); // Returns the price based on number of hours void doPayment(Car car); } class Car { }

We modeled a very simplified parking lot. It is the type of parking lot where you pay an hourly fee. Now consider that we want to implement a parking lot that is free.

public class FreeParking implements ParkingLot { @Override public void parkCar() { } @Override public void unparkCar() { } @Override public void getCapacity() { } @Override public double calculateFee(Car car) { return 0; } @Override public void doPayment(Car car) { throw new Exception("Parking lot is free"); } }

Our parking lot interface was composed of 2 things: Parking related logic (park car, unpark car, get capacity) and payment related logic.

But it is too specific. Because of that, our FreeParking class was forced to implement payment-related methods that are irrelevant. Let's separate or segregate the interfaces.

Wir haben jetzt den Parkplatz getrennt. Mit diesem neuen Modell können wir sogar noch weiter gehen und den PaidParkingLot aufteilen , um verschiedene Zahlungsarten zu unterstützen.

Jetzt ist unser Modell viel flexibler und erweiterbarer, und die Kunden müssen keine irrelevante Logik implementieren, da wir nur parkbezogene Funktionen in der Parkplatzschnittstelle bereitstellen.

Prinzip der Abhängigkeitsinversion

Das Prinzip der Abhängigkeitsinversion besagt, dass unsere Klassen von Schnittstellen oder abstrakten Klassen anstelle konkreter Klassen und Funktionen abhängen sollten.

In seinem Artikel (2000) fasst Onkel Bob dieses Prinzip wie folgt zusammen:

"Wenn das OCP das Ziel der OO-Architektur angibt, gibt das DIP den primären Mechanismus an."

These two principles are indeed related and we have applied this pattern before while we were discussing the Open-Closed Principle.

We want our classes to be open to extension, so we have reorganized our dependencies to depend on interfaces instead of concrete classes. Our PersistenceManager class depends on InvoicePersistence instead of the classes that implement that interface.

Conclusion

In this article, we started with the history of SOLID principles, and then we tried to acquire a clear understanding of the why's and how's of each principle. We even refactored a simple Invoice application to obey SOLID principles.

I want to thank you for taking the time to read the whole article and I hope that the above concepts are clear.

I suggest keeping these principles in mind while designing, writing, and refactoring your code so that your code will be much more clean, extendable, and testable.

If you are interested in reading more articles like this, you can subscribe to my blog's mailing list to get notified when I publish a new article.