SOLID-Prinzipien

SOLID ist ein Akronym für die ersten fünf Prinzipien des objektorientierten Designs (OOD) von Robert C. Martin (auch bekannt als Uncle Bob).

Diese Prinzipien legen Praktiken für die Entwicklung von Software mit Überlegungen zur Wartung und Erweiterbarkeit fest, wenn das Projekt wächst. Die Übernahme dieser Prinzipien kann auch dazu beitragen, Code Smells zu vermeiden, Code zu refaktorisieren und agile oder adaptive Software zu entwickeln.

SOLID steht für:

S – Single-Responsibility-Prinzip

Die Methoden einer Klasse sollten auf einen einzigen Zweck ausgerichtet sein.

O – Open-Closed-Prinzip

Objekte sollten offen für Erweiterungen, aber geschlossen für Änderungen sein.

L – Liskovsches Substitutionsprinzip

Unterklassen sollten durch ihre Oberklassen substituierbar sein.

I – Interface-Segregation-Prinzip

Objekte sollten nicht von Methoden abzuhängen, die sie nicht verwenden.

D – Dependency-Inversion-Prinzip

Abstraktionen sollten nicht von Details abhängen.

Single-Responsibility-Prinzip

Das Single-Responsibility-Prinzip besagt, dass jede Klasse nur eine Aufgabe erfüllen soll:

Es sollte nie mehr als einen Grund geben, eine Klasse zu ändern.

Robert C. Martin: SRP: The Single Responsibility Principle

Nehmen wir z.B. eine Anwendung, die eine Sammlung von Formen – Kreise und Quadrate – nimmt und die Summe der Umfänge aller Formen der Sammlung berechnet.

Erstellt zunächst die Form-Klassen mit den notwendigen Parametern. Für Quadrate ist dies die Kantenlänge und für Kreise der Durchmesser:

class Form:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y


class Square(Form):
    def __init__(self, length=1, x=0, y=0):
        super().__init__(x, y)
        self.length = length


class Circle(Form):
    def __init__(self, diameter=1, x=0, y=0):
        super().__init__(x, y)
        self.diameter = diameter

Nun könnt ihr eine Klasse SquaresAndCircles erstellen mit der Logik zur Berechnung aller Umfänge von Quadraten und Kreisen:

import gc


class SquaresAndCircles:
    pi = 3.14159

    @classmethod
    def circumferences(cls):
        csum = 0
        for obj in gc.get_objects():
            if isinstance(obj, Square):
                csum = csum + 4 * obj.length
            if isinstance(obj, Circle):
                csum = csum + obj.diameter * cls.pi
        return csum

Die Klasse SquaresAndCircles übernimmt die Logik, die zur Berechnung aller Umfänge von Quadraten und Kreisen erforderlich ist. Damit ist das Prinzip der Einzelverantwortung erfüllt.

Open-Closed-Prinzip

Das OCP besagt:

Objekte oder Entitäten sollten offen für Erweiterungen, aber geschlossen für Änderungen sein.

Das bedeutet, dass eine Klasse erweiterbar sein sollte, ohne die Klasse selbst zu verändern.

Schauen wir uns die Klasse SquaresAndCircles an und konzentrieren uns auf die circumferences()-Methode. Stellt euch ein Szenario vor, in dem die Summe zusätzlicher Formen wie Dreiecke, Fünfecke, Sechsecke usw. berechnet werden sollen. Ihr müsstet diese Klasse ständig bearbeiten und weitere if-Blöcke hinzufügen. Das würde gegen das Open-Closed-Prinzip verstoßen. Eine Möglichkeit, diese Methode zu verbessern, besteht darin, die Logik zur Berechnung des Umfangs jeder Form aus der Klasse SquaresAndCircles zu entfernen und sie an die Klassen der speziellen Formen anzuhängen. Hier sind die Umfangsberechnungen in den Klassen Square und Circle definiert:

class Square(Form):
    def __init__(self, length=1, x=0, y=0):
        super().__init__(x, y)
        self.length = length

    def circumference(self):
        return 4 * self.length


class Circle(Form):
    pi = 3.14159

    def __init__(self, diameter=1, x=0, y=0):
        super().__init__(x, y)
        self.diameter = diameter

    def circumference(self):
        return self.diameter * Circle.pi

Die Summenmethode circumferences() in der Klasse CircumferenceFormInstances kann dann wie folgt umgeschrieben werden:

class CircumferenceFormInstances:
    def circumferences():
        csum = 0
        for obj in gc.get_objects():
            if isinstance(obj, Form) and hasattr(obj, "circumference"):
                csum = csum + obj.circumference()
        return csum

Damit ist das Open-Closed-Prinzip erfüllt.

Tipp

Wenn euer Code noch nicht offen für neue Anforderungen ist, solltet ihr zunächst den vorhandenen Code so umordnen (refaktorieren), dass er für die neue Funktion offen ist. Erst dann solltet ihr neuen Code hinzufügen.

Unter Refaktorierung versteht man den Prozess, ein Softwaresystem so zu verändern, dass das äußere Verhalten des Codes nicht verändert, aber seine innere Struktur verbessert wird.

Martin Fowler: Refactoring

Bemerkung

Sicheres Refactoring ist auf Tests angewiesen. Wenn ihr den Code wirklich umgestaltet, ohne das Verhalten zu ändern, sollten die vorhandenen Tests bei jedem Schritt weiterhin erfolgreich sein. Die Tests sind ein Sicherheitsnetz, das das Vertrauen in die neue Anordnung des Codes rechtfertigt. Wenn sie versagen,

  • habt ihr den Code versehentlich beschädigt,

  • oder die vorhandenen Tests sind fehlerhaft.

Liskovsches Substitutionsprinzip

Das Liskovsche Substitutionsprinzip besagt, dass ein Programm, das Objekte der Basisklasse verwendet, auch mit Objekten der Unterklasse korrekt funktionieren muss.

Erweitern wir die Klasse Form, so dass die daraus abgeleiteten Klassen in der x- und y-Richtung verschoben werden können:

class Form:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def move(self, delta_x, delta_y):
        self.x = self.x + delta_x
        self.y = self.y + delta_y

Anschließend könnt ihr sowohl Quadrate wie auch Kreise auf der x- und y-Achse verschieben:

>>> import forms
>>> s1 = forms.Square()
>>> c1 = forms.Circle()
>>> s1.x, s1.y, c1.x, c1.y
(0, 0, 0, 0)
>>> s1.move(4, 5)
>>> c1.move(2, 3)
>>> s1.x, s1.y, c1.x, c1.y
(4, 5, 2, 3)

Bemerkung

Das Liskovsche Substitutionsprinzip gilt auch für Duck-Typing: jedes Objekt, das behauptet, eine Ente zu sein, muss die API der Ente vollständig implementieren. Duck-Types sollten gegeneinander austauschbar sein. Die Logik über verschiedene Datentypen von Objekten hinweg anzuwenden, nennt sich Polymorphie.

Interface-Segregation-Prinzip

Das Interface-Segregation-Prinzip wendet das Single-Responsibility-Prinzip auf Schnittstellen (engl.: Interfaces) an um ein bestimmtes Verhalten zu isolieren. Wenn eine Änderung an einem Teil eures Codes erforderlich ist, eröffnet die Extraktion eines Objekts, das eine Rolle spielt, die Möglichkeit, das neue Verhalten zu unterstützen, ohne dass der bestehende Code geändert werden muss. Dies ist kodierten Konkretisierungen vorzuziehen.

So haben wir im vorigen Beispiel überprüft, ob unser Form-Objekt auch tatsächlich eine circumference()-Methode bereitstellt. Dies ist notwendig, wenn später Formen wie Point oder Line hinzukommen sollten, die keinen Umfang aufweisen.

Bemerkung

In diesem Zusammenhang ist auch das Gesetz von Demeter interessant, das besagt, dass Objekte nur mit Objekten in ihrer unmittelbaren Umgebung kommunizieren sollen. Damit wird die Liste der anderen Objekte wirksam eingeschränkt, an die ein Objekt eine Nachricht senden kann und die Kopplung zwischen Objekten verringert: ein Objekt kann nur mit seinen Nachbarn sprechen, nicht aber mit den Nachbarn seiner Nachbarn; Objekte können nur Nachrichten an direkt Beteiligte senden.

Dependency-Inversion-Prinzip

Das Dependency-Inversion-Prinzip kann definiert werden als

Abstraktionen sollten nicht von Details abhängen. Details sollten von Abstraktionen abhängen.

Robert C. Martin: The Dependency Inversion Principle

circumferences() sollte nicht bereits in der Form-Klasse definiert werden, da es auch Formen ohne Umfang gibt.