SOLID beschreibt ein Vorgehen in der Softwareentwicklung, um ein gutes und damit vor allem wartbares Softwaresystem zu bauen. Der Begriff steht folglich für ein bzw. mehrere Design Principles/Pattern. SOLID beschreibt konkret 5 Prinzipien, die dafür Sorge tragen ein gutes Software-Design bzw. eine “solide” Software-Architektur zu entwickeln. Es wurde insbesondere für die objektorientierte Programmierung entwickelt. Jeder Buchstabe steht für ein Prinzip. In den folgenden Absätzen erklären wir diese.
Frei ins deutsche übersetzt würde man von dem “Prinzip der eindeutigen Verantwortlichkeit” sprechen. Eine Klasse -- oder auch Modul oder Funktion -- sollte somit nur einen ganz bestimmten Zweck erfüllen. Es wäre also falsch eine Klasse zu implementieren, die diverse Anwendungsfälle für unterschiedliche Akteure implementiert. Eine Klasse sollte immer nur einem Akteur gegenüber verantwortlich sein. Anders ausgedrückt: “Es sollte nie mehr als einen Grund geben, eine Klasse zu ändern”.
SRP Positiv-Beispiel:
Die Unix-Philosophie mit ihren Command-Line-Tools arbeitet nach dem SR-Prinzip. Jedes Tool erfüllt nur einen ganz bestimmten Zweck (und den besonders gut):
Make each program do one thing well. By focusing on a single task, a program can eliminate much extraneous code that often results in excess overhead, unnecessary complexity, and a lack of flexibility.
— Mike GancarzThe UNIX Philosophy
SRP Negativ-Beispiel:
Folgende Klasse soll einen Job-Suchenden im Jobpushy-System abbilden. Sie enthält jedoch mit dem Attribute lastSentMessage und der Methode calcPrimePoints() zwei Eigenschaften, die nicht direkt den Job-Suchenden modellieren. Besser wäre es diese Dinge in einen MessageService und PrimePointService auszulagern, die sich jeweils ausschließlich um den einen Zweck kümmern.
class JobSeeker {
String email
String firstName
List<String> wished
boolean freelanced
boolean employed
EmailMessage lastSentMessage
int calcPrimePoints() {
// checks how many friends were invited successfully
}
}
Das O in SOLID steht für das Open-Closed-Prinzip. In deutscher Sprache: “Prinzip der Offen- und Verschlossenheit”. Es beschreibt wie eine gute Modularisierung und Kapselung in einem Softwaresystem aussehen sollten.
Open und Close scheinen sich zu widersprechen, machen aber in ihrer Definition Sinn:
“Modules should be both open (for extension) and closed (for modification).
– Bertrand Meyer: Object Oriented Software Construction
Mit anderen Worten: Module und Klassen sollten von außen erweiterbar sein, jedoch sollten innere Modifikationen verhindert werden.
Beispiel Vererbung:
Die Vererbung von Klassen ist eine bestimmte Form des OCP. Die eigentliche Klasse (z.B. Animal) erlaubt es abgeleitet zu werden (z.B. in GuineaPig). Das Meerschweinchen kann weitere Eigenschaften erhalten (z.B. Fellstruktur). Dadurch ist die Erweiterbarkeit gegeben. Die Innereien des Tieres können jedoch nicht überschrieben werden und bleiben verschlossen. (z.B. Berechnung des Alters anhand des Geburtsdatums in einer privaten Methode).
Beispiel Strategy Pattern:
Auch das Strategy Pattern ist eine Implementierung des Open-Closed-Prinzips. Eine Strategie wird durch ein Interface definiert:
public interface Strategy {
public void doSomething();
}
Die sogenannte Context-Klasse nutzt eine oder mehrere konkret ausprogrammierte Strategien:
public class Context() {
private Strategy strategy;
public Context(Strategy strategy) {
this.strategy = strategy;
}
public void executeTheStrategy() {
this.strategy.doSomething();
}
}
Die Implementierung durch die Context-Klasse ist offen (Open-Prinzip) für Änderungen. Es muss nur eine neue Strategie-Implementierung erstellt und genutzt werden. Ein einzelne Strategie hingegen ist geschlossen (Closed-Prinzip). Sie ist fix und kann nicht direkt geändert werden.
Lisko was ...? Der Name klingt komplizierter, als das Prinzip dahinter. ;) Es beschreibt, wie sich abgeleitete Klassen verhalten sollten. Es ist somit insbesondere auf die objektorientierte Programmierung und die Vererbung von Klassen anzuwenden.
Das Prinzip fordert, daß jede Operation der Subklasse sich genauso verhält, wie die der Superklasse. Somit kann ein Objekt der Unterklasse jederzeit anstelle eines Objekts ihrer Oberklasse eingesetzt werden (Substitutionsprinzip)..
Mathematisch hat es die Frau Liskov wie folgt ausgedrückt:
„Sei q(x) eine Eigenschaft des Objektes x Typ T, dann sollte q(y) für alle Objekte y des Typs S gelten, wobei ein Subtyp von T ist.“
– Barbara H. Liskov, Jeannette M. Wing: Behavioral Subtyping Using Invariants and Constraints
Negativ-Beispiel:
class BasisCustomer {
String id;
String fullName;
String uppercaseName() {
return fullName?.toUpperCase()
}
}
class SpecialCustomer extends BasisCustomer {
String uppercaseName() {
throw new IllegalArgumentException(“SpecialCustomer don’t like uppercase.”);
}
}
Im obigen Beispiel ist das Substitutionsprinzip verletzt. Deklariert man einen Kunden als BasisCustomer customer = new SpecialCustomer() und ruft customer.uppercaseName() auf, fliegt einem eine Exception um die Ohren. Das verhält sich komplett anders, als wenn der Kunde mit der Oberklasse instanziert wurde: BasisCustomer customer = new BasisCustomer()
Wörtlich übersetzt sprechen wir hier vom Schnittstellenaufteilungsprinzip. Es besagt, dass Schnittstellen (Interfaces) nicht zu groß werden dürfen. Clients sollen nicht gezwungen sein, Interface-Methoden zu implementieren, die sie gar nicht verwenden.
Stattdessen sollte eine gute Schnittstellen-Definition mehrere kleine Interfaces anbieten, die notwendigerweise zusammengehörige Funktionen definieren. Dadurch werden Module/Klassen entkoppelt, die Qualität des Software-Designs und letztendlich die Wartbarkeit steigt.
Negativ Beispiel:
Eine Klasse möchte eine Preisberechnung für Bananen anbieten. Sie implementiert daher folgendes Interface. Leider ist das Interface zu groß. Es muss zusätzlich die Erdbeeren abdecken und andere Eigenschaften. Das führt zu unnötiger Abhängigkeit, die sich bei Schnittstellenänderungen auch zukünftig immer wieder auf diese Klasse auswirkt.
public interface FruitThings {
double calcBananaPrice(int weight);
double calcStrawberryPrice(int weight);
FruitColor getBananaColor();
Country getBananaCountry();
}
Besser wäre es je ein Interface für Bananen-Preisberechnung, Erdbeeren-Preisberechung sowie Bananen-Eigenschaften zu definieren.
Das “Abhängigkeits-Umkehr-Prinzip” verhindert starke Kopplung und soll insbesondere zyklische Abhängigkeiten vermeiden. Somit hilft auch dieses Prinzip eine gut wartbare Software zu entwerfen.
Hierzu wurden zwei Regeln definiert:
Das klingt doch sehr theoretisch. Machen wir es anhand eines Beispiels deutlich. Stellen wir uns vor, wir entwickeln einen Tennis-Simulator, der die Matches der Profis nachbilden kann. Als spezifische Klassen “niedriger Ebene” existieren die Spieler Federer und Nadal. Diese werden von dem Modul auf höherer Ebene, der Klasse Tennismatch, genutzt:
class Federer {
Score oneHandedBackhand (Double receiveAngle, Double targetAngel)
{
// implementation
}
// … implementation of more tennis strokes ….
}
class Nadal {
Score doubleHandedBackhand (Double receiveAngle, Double targetAngel)
{
// implementation
}
// … implementation of more tennis strokes ….
}
class Tennismatch {
private def federer = new Federer()
private def nadal = new Nadal()
// … more TennisPlayer
Result play() {
//... implement more logic
Score backhandFederer = federer.oneHandedBackhand(in,out)
//... implement more logic
Score backhandNadal = nadal.doubleHandedBackhand(in,out)
//... implement more logic
}
}
Das obige Beispiel zeigt die ungünstige feste Kopplung mit direkter Abhängigkeit. Sollte Nadal seine doppelhändige Rückhand auf eine einhändige Rückhand umstellen, würde der Code im Tennismatch nicht mehr funktionieren. Wir müssten dort zunächst den entsprechenden Sourcecode auf oneHandedBackhand umstellen. Diese verletzt unsere erste Regel von oben: Das höhere Modul Tennismatch ist damit direkt abhängig von den verwendeten “niedrigen” Klassen.
Da wir in unserer play() Methode die konkrete Variante des Rückhandschlages aufrufen, wird das zweite Prinzip verletzt: Abstraktionen sollten nicht von Details abhängig sein. Stattdessen sollten wir auf dieser Ebene nur einen “Backhand”-Schlag aufrufen, ohne das Detail zu kennen, ob er einhändig oder doppelhändig ist.
Wenden wir das Dependency-Inversion-Prinzip an und drehen die Abhängigkeiten um.
Zunächst definieren die Abstraktion des Tennisplayers:
interface TennisPlayer {
Score backhandStroke(Double receiveAngle, Double targetAngel)
}
Die konkreten Spieler implementieren nun dieses Interface:
class Federer extends TennisPlayer {
@Override
Score backhandStroke(Double receiveAngle, Double targetAngel)
{
return oneHandedBackhand (receiveAngle, targetAngel)
}
private Score oneHandedBackhand (Double receiveAngle, Double targetAngel)
{
// implementation
}
}
Und unsere Service-Klasse Tennismatch nutzt nun die Abstraktionen und hat damit keine Abhängigkeit zu konkreten Spielern und ihren Details:
class Tennismatch {
Tennismatch (TennisPlayer player1, TennisPlayer player2)
{
// ...
}
Result play() {
//... implement more logic
Score backhandFederer = player1.backhandStroke(in,out)
//... implement more logic
Score backhandNadal = player2.backhandStroke(in,out)
//... implement more logic
}
}
Somit kann Nadal problemlos seinen Rückhandschlag ändern, ohne dass wir irgendetwas an unseres Tennismatch-Klasse ändern müssen. :)
Weitere Informationen zum SOLID-Pattern u.a. auf Wikipedia: https://de.wikipedia.org/wiki/Prinzipien_objektorientierten_Designs#SOLID-Prinzipien