Ein solider Leitfaden zu SOLID-Prinzipien

1. Einleitung

In diesem Tutorial werden wir die SOLID-Prinzipien des objektorientierten Designs diskutieren .

Zunächst untersuchen wir die Gründe, warum sie entstanden sind, und warum wir sie beim Entwerfen von Software berücksichtigen sollten . Anschließend werden wir jedes Prinzip zusammen mit einem Beispielcode skizzieren, um den Punkt hervorzuheben.

2. Der Grund für feste Prinzipien

Die SOLID-Prinzipien wurden erstmals von Robert C. Martin in seiner 2000 erschienenen Arbeit Design Principles and Design Patterns konzipiert. Diese Konzepte wurden später von Michael Feathers weiterentwickelt, der uns das Akronym SOLID vorstellte. Und in den letzten 20 Jahren haben diese 5 Prinzipien die Welt der objektorientierten Programmierung revolutioniert und die Art und Weise, wie wir Software schreiben, verändert.

Was ist SOLID und wie hilft es uns, besseren Code zu schreiben? Einfach ausgedrückt, ermutigen uns die Designprinzipien von Martin und Feathers, wartbarere, verständlichere und flexiblere Software zu entwickeln . Folglich können wir mit zunehmender Größe unserer Anwendungen ihre Komplexität reduzieren und uns später viele Kopfschmerzen ersparen!

Die folgenden 5 Konzepte bilden unsere SOLID-Prinzipien:

  1. S ingle Verantwortung
  2. O Stift / Geschlossen
  3. L iskov Substitution
  4. I nterface Segregations
  5. D ependency Inversion

Während einige dieser Wörter entmutigend klingen mögen, können sie mit einigen einfachen Codebeispielen leicht verstanden werden. In den folgenden Abschnitten werden wir uns eingehend mit der Bedeutung der einzelnen Prinzipien befassen und ein kurzes Java-Beispiel zur Veranschaulichung der einzelnen Prinzipien geben.

3. Einzelverantwortung

Beginnen wir mit dem Prinzip der Einzelverantwortung. Wie zu erwarten ist, besagt dieses Prinzip, dass eine Klasse nur eine Verantwortung haben sollte. Darüber hinaus sollte es nur einen Grund zur Änderung geben.

Wie hilft uns dieses Prinzip, bessere Software zu entwickeln? Lassen Sie uns einige seiner Vorteile sehen:

  1. Testen - Eine Klasse mit einer Verantwortung hat weit weniger Testfälle
  2. Geringere Kopplung - Weniger Funktionalität in einer einzelnen Klasse hat weniger Abhängigkeiten
  3. Organisation - Kleinere, gut organisierte Klassen sind einfacher zu suchen als monolithische

Nehmen Sie zum Beispiel eine Klasse, um ein einfaches Buch darzustellen:

public class Book { private String name; private String author; private String text; //constructor, getters and setters }

In diesem Code speichern wir den Namen, den Autor und den Text, die einer Instanz eines Buches zugeordnet sind .

Fügen wir nun einige Methoden hinzu, um den Text abzufragen:

public class Book { private String name; private String author; private String text; //constructor, getters and setters // methods that directly relate to the book properties public String replaceWordInText(String word){ return text.replaceAll(word, text); } public boolean isWordInText(String word){ return text.contains(word); } }

Jetzt funktioniert unsere Buchklasse gut und wir können so viele Bücher in unserer Anwendung speichern, wie wir möchten. Aber was nützt es, die Informationen zu speichern, wenn wir den Text nicht auf unserer Konsole ausgeben und lesen können?

Lassen Sie uns Vorsicht walten lassen und eine Druckmethode hinzufügen:

public class Book { //... void printTextToConsole(){ // our code for formatting and printing the text } }

Dieser Kodex verstößt jedoch gegen das zuvor beschriebene Prinzip der Einzelverantwortung. Um unser Durcheinander zu beheben, sollten wir eine separate Klasse implementieren, die sich nur mit dem Drucken unserer Texte befasst:

public class BookPrinter { // methods for outputting text void printTextToConsole(String text){ //our code for formatting and printing the text } void printTextToAnotherMedium(String text){ // code for writing to any other location.. } }

Genial. Wir haben nicht nur eine Klasse entwickelt, die das Buch von seinen Druckaufgaben entlastet, sondern können auch unsere BookPrinter- Klasse nutzen, um unseren Text an andere Medien zu senden.

Egal, ob es sich um E-Mail, Protokollierung oder etwas anderes handelt, wir haben eine separate Klasse, die sich diesem einen Anliegen widmet.

4. Zur Erweiterung geöffnet, zur Änderung geschlossen

Jetzt Zeit für das 'O' - formeller bekannt als das Open-Closed-Prinzip . Einfach ausgedrückt, Klassen sollten zur Erweiterung geöffnet, aber zur Änderung geschlossen sein. Auf diese Weise hindern wir uns daran, vorhandenen Code zu ändern und potenzielle neue Fehler in einer ansonsten zufriedenstellenden Anwendung zu verursachen.

Die einzige Ausnahme von der Regel ist natürlich das Beheben von Fehlern in vorhandenem Code.

Lassen Sie uns das Konzept anhand eines kurzen Codebeispiels weiter untersuchen. Stellen Sie sich vor, wir haben im Rahmen eines neuen Projekts eine Gitarrenklasse implementiert .

Es ist vollwertig und hat sogar einen Lautstärkeregler:

public class Guitar { private String make; private String model; private int volume; //Constructors, getters & setters }

Wir starten die Anwendung und jeder liebt sie. Nach ein paar Monaten entscheiden wir jedoch, dass die Gitarre ein bisschen langweilig ist und ein fantastisches Flammenmuster verträgt, damit sie ein bisschen mehr Rock'n'Roll aussieht.

An diesem Punkt könnte es verlockend sein, einfach die Gitarrenklasse zu öffnen und ein Flammenmuster hinzuzufügen - aber wer weiß, welche Fehler in unserer Anwendung auftreten können.

Lasst uns stattdessen auf das offene geschlossene Prinzip halten und einfach unsere erweitern Gitarrenklasse :

public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters + setters }

Durch die Erweiterung der Gitarrenklasse können wir sicher sein, dass unsere vorhandene Anwendung nicht betroffen ist.

5. Liskov-Substitution

Als nächstes steht die Liskov-Substitution auf unserer Liste, die wohl das komplexeste der fünf Prinzipien ist. Einfach ausgedrückt, wenn Klasse A ein Subtyp von Klasse B ist , sollten wir in der Lage sein, B durch A zu ersetzen, ohne das Verhalten unseres Programms zu stören.

Lassen Sie uns einfach direkt zum Code springen, um uns mit diesem Konzept vertraut zu machen:

public interface Car { void turnOnEngine(); void accelerate(); }

Oben definieren wir eine einfache Autoschnittstelle mit einigen Methoden, die alle Autos erfüllen sollten - Motor einschalten und vorwärts beschleunigen.

Lassen Sie uns unsere Schnittstelle implementieren und Code für die Methoden bereitstellen:

public class MotorCar implements Car { private Engine engine; //Constructors, getters + setters public void turnOnEngine() { //turn on the engine! engine.on(); } public void accelerate() { //move forward! engine.powerOn(1000); } }

Wie unser Code beschreibt, haben wir einen Motor, den wir einschalten und die Leistung erhöhen können. Aber warten Sie, es ist 2019, und Elon Musk war ein vielbeschäftigter Mann.

We are now living in the era of electric cars:

public class ElectricCar implements Car { public void turnOnEngine() { throw new AssertionError("I don't have an engine!"); } public void accelerate() { //this acceleration is crazy! } }

By throwing a car without an engine into the mix, we are inherently changing the behavior of our program. This is a blatant violation of Liskov substitution and is a bit harder to fix than our previous 2 principles.

One possible solution would be to rework our model into interfaces that take into account the engine-less state of our Car.

6. Interface Segregation

The ‘I ‘ in SOLID stands for interface segregation, and it simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

For this example, we're going to try our hands as zookeepers. And more specifically, we'll be working in the bear enclosure.

Let's start with an interface that outlines our roles as a bear keeper:

public interface BearKeeper { void washTheBear(); void feedTheBear(); void petTheBear(); }

As avid zookeepers, we're more than happy to wash and feed our beloved bears. However, we're all too aware of the dangers of petting them. Unfortunately, our interface is rather large, and we have no choice than to implement the code to pet the bear.

Let's fix this by splitting our large interface into 3 separate ones:

public interface BearCleaner { void washTheBear(); } public interface BearFeeder { void feedTheBear(); } public interface BearPetter { void petTheBear(); }

Now, thanks to interface segregation, we're free to implement only the methods that matter to us:

public class BearCarer implements BearCleaner, BearFeeder { public void washTheBear() { //I think we missed a spot... } public void feedTheBear() { //Tuna Tuesdays... } }

And finally, we can leave the dangerous stuff to the crazy people:

public class CrazyPerson implements BearPetter { public void petTheBear() { //Good luck with that! } }

Going further, we could even split our BookPrinter class from our example earlier to use interface segregation in the same way. By implementing a Printer interface with a single print method, we could instantiate separate ConsoleBookPrinter and OtherMediaBookPrinter classes.

7. Dependency Inversion

The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.

To demonstrate this, let's go old-school and bring to life a Windows 98 computer with code:

public class Windows98Machine {}

But what good is a computer without a monitor and keyboard? Let's add one of each to our constructor so that every Windows98Computer we instantiate comes pre-packed with a Monitor and a StandardKeyboard:

public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }

This code will work, and we'll be able to use the StandardKeyboard and Monitor freely within our Windows98Computer class. Problem solved? Not quite. By declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these 3 classes together.

Not only does this make our Windows98Computer hard to test, but we've also lost the ability to switch out our StandardKeyboard class with a different one should the need arise. And we're stuck with our Monitor class, too.

Let's decouple our machine from the StandardKeyboard by adding a more general Keyboard interface and using this in our class:

public interface Keyboard { }
public class Windows98Machine{ private final Keyboard keyboard; private final Monitor monitor; public Windows98Machine(Keyboard keyboard, Monitor monitor) { this.keyboard = keyboard; this.monitor = monitor; } }

Here, we're using the dependency injection pattern here to facilitate adding the Keyboard dependency into the Windows98Machine class.

Let's also modify our StandardKeyboard class to implement the Keyboard interface so that it's suitable for injecting into the Windows98Machine class:

public class StandardKeyboard implements Keyboard { }

Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.

Excellent! We've decoupled the dependencies and are free to test our Windows98Machine with whichever testing framework we choose.

8. Conclusion

In this tutorial, we've taken a deep dive into the SOLID principles of object-oriented design.

Wir haben mit einem kurzen Einblick in die SOLID-Geschichte begonnen und die Gründe, warum diese Prinzipien existieren.

Buchstabe für Buchstabe haben wir die Bedeutung jedes Prinzips anhand eines kurzen Codebeispiels aufgeschlüsselt, das dagegen verstößt. Wir haben dann gesehen, wie wir unseren Code reparieren und ihn an die SOLID-Prinzipien anpassen können.

Wie immer ist der Code auf GitHub verfügbar.