Die 5 SOLID Designprinzipien mit Python Beispielen

You are currently viewing Die 5 SOLID Designprinzipien mit Python Beispielen

Lesbare Funktionen, Methoden und Klassen schreiben zu können ist eine Sache. Aber wie bekommst du deinen Code wartbar, flexibel erweiterbar und testbar hin?

In diesem Beitrag geht es um die S.O.L.I.D Designprinzipien. Wobei SOLID für die Anfangsbuchstaben der folgenden beliebten Designprinzipien für objektorientierte Softwareentwicklung stehen:

1) Single Responsibility Principle
2) Open/Closed Principle
3) Liskov Substitution Principle
4) Interface Segregation Principle
5) Dependency Inversion Principle

Diese Prinzipien wurden übrigens von Robert C. Martin entworfen und in seinen Büchern vorgestellt:

  • "Clean Architecture" (978-0134494166)
  • "Agile Principles, Patterns, and Practices in C#" (978-0131857254)
  • "Agile Software Development" (978-0135974445)

Dabei haben alle 5 SOLID Prinzipien das Ziel den Anforderungen von ewig wachsenden Code zu genügen. Dementsprechend sollen sie einen Code mit geringer Komplexität gewährleisten. Folglich machen wir heute einen Abstecher in die Softwarearchitektur bzw. ins Softwaredesign.

Viel Spaß!

Single Responsibility Principle (SRP)

“There should never be more than one reason for a class to change.”
– Robert C. Martin: Agile Software Development: Principles, Patterns, and Practices

Das erste Prinzip heißt Single Responsibility (SRP), seltener auch Seperation of Concerns (SOC). Zu Deutsch: das Prinzip der eindeutigen Verantwortlichkeit. Die Idee ist relativ einfach. Eine Klasse sollte nur für eine einzige primäre Aufgabe verantwortlich sein. Anders formuliert darf sie keine Aufgaben übernehmen die auch in eine eigene Klasse geschrieben werden könnten.

Beispiel

Um das Ganze anfangs besser zu verstehen gucken wir uns folgende einfache Koffersimulation in Python an.

class Suitcase:
  """Beschreibt Kofferobjekte"""
  def __init__(self):
    self.items = []
    self.count = 0

  def add_item(self, item):
    """Fügt einen Gegenstand zum Koffer hinzu"""
    self.count += 1
    self.items.append(f"{self.count}: {item}")

  def __str__(self):
    """Gibt den Kofferinhalt als String aus"""
    return "\n".join(self.items)

Ausgeführt sieht das ganze so aus.

s1 = Suitcase()
s1.add_item("Toothbrush")
print(s1)
1: Toothbrush

Jetzt könnten allerdings neue Anforderungen an dieses Programm kommen. Etwa ein Persistenzmanagement. Also die Möglichkeit den Kofferinhalt in Dateien speichern und später laden zu können.

Der direkte Ansatz ist noch zwei weitere Methoden zu schreiben die diese Aufgaben übernehmen.

  def save(self, filename):
    """Speichert den Kofferinhalt in eine Datei"""
    file = open(filename, "w")
    file.write(str(self))
    file.close()

  def load(self, filename):
    """Fügt dem Kofferinhalt Einträge aus einer Datei hinzu"""
    file = open(filename, "r")
    for item in file.readlines():
      self.items.append(item)
    file.close()

Wie folgt, führt man das obige Script aus.

s1 = Suitcase()
s1.add_item("Toothbrush")
s1.add_item("Tshirt")
s1.save("suitcasefile")
s2 = Suitcase()
s2.load("suitcasefile")
print(s1)
print("\n")
print(s2)
1: Toothbrush
2: Tshirt

1: Toothbrush
2: Tshirt

Problem

Dieser direkte Ansatz ist schnell umgesetzt. Aber, wenn man mal darüber nachdenkt, hat das Handling von Dateien nicht wirklich etwas mit einem Koffer zu tun.

Nicht gut!

Warum ist das schlimm?

Die SOLID Designprinzipien gehen von einer großen Codebasis aus. Deswegen ist es wahrscheinlich dass du neben der Suitcase Klasse noch weitere klassen hast. Diese werden früher oder später auch eine load/save klasse brauchen. Anschließend müsstest du dann in all diesen Klassen einzeln implementieren.

Das Schlimmste daran: Möglicherweise willst du nicht immer alles in Dateien Speichern. Dinge in Datenbanken zu speichern ist ebenfalls eine gute Idee. Bei einem Wechsel müsstest du die Änderung dann in allen load und save Methoden umsetzen. Dieses Vorgehen ist mit Aufwand verbunden. Somit führt dieser Ansatz zu einem mühsam wartbaren Code.

Lösung

Eine bessere Lösung wäre es eine separate Klasse für das Persistenzhandling bzw. dem Speichern und Laden zu erstellen.

class PersistenceManager:
  @staticmethod
  def save(obj, filename):
    """Speichert den Objektinhalt in eine Datei"""
    file = open(filename, "w")
    file.write(str(obj))
    file.close()

  @staticmethod
  def load(obj, filename):
    """Fügt dem Objektinhalt Einträge aus einer Datei hinzu"""
    file = open(filename, "r")
    for item in file.readlines():
      obj.items.append(item)
    file.close()

Diesen Persistenzmanager benutzt man wie folgt.

s1 = Suitcase()
s1.add_item("Toothbrush")
s1.add_item("Tshirt")

PersistenceManager.save(s1,"suitcasefile")
s2 = Suitcase()
PersistenceManager.load(s2, "suitcasefile")
print(s1)
print("\n")
print(s2)
1: Toothbrush
2: Tshirt

1: Toothbrush
2: Tshirt

Du musst nur darauf achten, dass die Klassen vom Persistenzmanager gespeichert und geladen werden können.

Wir halten fest: Beim Prinzip der eindeutigen Verantwortung versucht man den einzelnen Klassen möglichst wenig Verantwortungsbereiche zu geben.

Im Gegenzug dazu gibt es auch Anti-Pattern namens God-Object. Dies ist ein typischer Programmieranfängerfehler, bei dem einfach alle Methoden einfach in dieselbe Klasse gelegt wird. Diese wird dann am Ende undurchschaubar groß und schwer zu warten.

Laut dem Single Reponsibility Prinzip sollte es nur einen Grund geben eine Klasse zu ändern. Nämlich, wenn diese Änderung in direkter Relation zur Primärverantwortlichkeit der jeweiligen Klasse ist.

Open/Closed Principle (OCP)

“Modules should be both open (for extension) and closed (for modification).”
– Bertrand Meyer: Object Oriented Software Construction

In diesem Prinzip geht es um die Erweiterung der Funktionalität eines Moduls. Dabei steht Open für "Open for extension" und Closed für "Closed for modification". Also offen für Erweiterungen, geschlossen für Modifikationen.

Nachdem du eine Klasse geschrieben und getestet hast solltet du sie nicht mehr Verändern müssen, um neue Funktionalität hinzuzufügen. Stattdessen solltest du sie erweitern.

Beispiel

Nehmen wir an du hast eine Klasse die Meerschweinchen beschreibt. Diese Meerschweinchen können Eigenschaften haben:

  • einen Namen
  • eine Farbe
  • eine Größe

Darüber hinaus gibt es eine Klasse, mit der du Meerschweinchen die eine bestimmten Farbe haben aus einer Liste herausfiltern kannst.

from enum import Enum

class Color(Enum):
  ORANGE= 1
  RED = 2
  WHITE = 3

class Size(Enum):
  SMALL = 1
  MEDIUM = 2
  LARGE = 3

class Guinea_Pig:
  def __init__(self,name, color, size):
    self.name = name
    self.color = color
    self.size = size

class Guinea_Pig_Filter:
  def filter_by_color(self, guinea_pigs, color):
    for guinea_pig in guinea_pigs:
      if guinea_pig.color == color: yield guinea_pig

Das filtern läuft dann wie folgt ab.

guinea_pig_1 = Guinea_Pig("Lissie", Color.ORANGE, Size.SMALL)
guinea_pig_2 = Guinea_Pig("Gustav", Color.WHITE, Size.MEDIUM)
guinea_pig_3 = Guinea_Pig("Maxi", Color.ORANGE, Size.LARGE)

guinea_pig_list = [guinea_pig_1, guinea_pig_2, guinea_pig_3]

print("All guinea pigs:")
for pig in guinea_pig_list:
    print(pig.name)
orange_pig_pist = Guinea_Pig_Filter.filter_by_color(
    guinea_pigs=guinea_pig_list, color=Color.ORANGE
)

print("\nOrange guinea pigs:")
for pig in orange_pig_pist:
    print(pig.name)
All guinnea pigs:
Lissie
Gustav
Maxi

Orange guinnea pigs:
Lissie
Maxi

Jetzt könnte eine neue Anforderung dazu kommen: Nach Größe filtern. Ein denkbarer Ansatz wäre eine zweite Filtermethode in die Filterklasse zu schreiben.

  def filter_by_size(self, guinea_pigs, size):
    for guinea_pig in guinea_pigs:
      if guinea_pig.size == size: yield guinea_pig

Zum einen wiederholt sich ein großer Teil des Filtercodes. Zum anderen haben wir durch diesen Ansatz das Open/Closed Prinzip verletzt.

Closed for modification bedeutet, dass nachdem man eine Klasse nicht mehr verändern sollte, nachdem man sie geschrieben hat.

Nicht gut!

Problem

Was als Nächstes passieren könnte wäre, dass du noch eine Funktion brauchst die gleichzeitig nach Farbe und nach Größe filtert. Zack, schon hast du 3 Methoden nur zum Filtern dieser zwei Eigenschaften.

Leider haben Meerschweinchen in der Echten Welt noch mehr Eigenschaften. Eines davon wäre das Lieblingsfutter. Damit hättest du bei 3 Eigenschaften bereits 7 mögliche Filterkombinationen die alle eine eigene Methode bräuchten. Wenn du es noch weiter denkt, hast du bei 8 Eigenschaften schon 255 Filtermethoden. Diese Methoden müsstest du natürlich alle von Hand implementieren.

Der Informatiker spricht hier von einer state space explosion.

Lösung

Die Lösung zu diesem Problem ist das Spezifikationsmuster oder Specification Pattern. Dieses Pattern entscheidet ob ein Objekt ein bestimmtes Kriterium erfüllt.

In Python erstellst du dazu einfach zwei weitere Klassen. Eine Specification und eine Filterklasse.

from abc import ABC

class Specification(ABC):
  def is_satisfied(self, item):
    pass

class Filter(ABC):
  def filter(self, items, spec):
    pass

Was wir damit erreichen wollen, ist, dass der Code mit Anzahl der Attribute steigt. Nicht mit der Anzahl der Attributkombinationen. Dazu schreiben wir nun für jede der Attribute, nach denen wir filtern wollen, eine weitere Klasse.

Diese Klassen können von der oben definierten Specification Klasse erben.

Um auch Kombinationen verwenden zu können habe ich noch eine UND Spezifikation hinzugefügt. Entsprechend dazu wäre theoretisch auch eine ODER Verknüpfung oder ähnliches denkbar.

class ColorSpecification(Specification):
  def __init__(self, color):
    self.color = color
  def is_specified(self, item):
    return item.color == self.color

class SizeSpecification(Specification):
  def __init__(self, size):
    self.size = size
  def is_specified(self, item):
    return item.size == self.size

class AndSpecification(Specification):
  def __init__(self, *args):
    self.args = args
  def is_satisfied(self,item):
    return all(map(lambda spec: spec.is_satisfied(item), self.args))

class BetterFilter(Filter):
  def filter(self, items, spec):
    for item in items:
      if spec.is_satisfied(item):
        yield item

Wie benutzt du dieses kryptische Gebilde? Ganz einfach! Du schreibst wie gehabt deine Filterklasse. Dieses Mal verwendet sie allerdings die Spezifikationen.

guinea_pig_1 = Guinea_Pig("Lissie", Color.ORANGE, Size.SMALL)
guinea_pig_2 = Guinea_Pig("Gustav", Color.WHITE,Size.MEDIUM)
guinea_pig_3 = Guinea_Pig("Maxi", Color.ORANGE, Size.LARGE)

guinea_pig_list = [guinea_pig_1, guinea_pig_2, guinea_pig_3]
bf = BetterFilter()

orange = ColorSpecification(Color.ORANGE)
small = ColorSpecification(Size.SMALL)
small_orange = AndSpecification(small, orange)

print("Orangene Meerschweinchen:")
for p in bf.filter(guinea_pig_list, orange):
  print(f" - {p.name} ist Orange")

print("Kleine Meerschweinchen:")
for p in bf.filter(guinea_pig_list, small):
  print(f" - {p.name} ist klein")

print ("Kleine, Orangene Meerschweinchen:")
for p in bf.filter(guinea_pig_list, small_orange):
  print(f" - {p.name} ist klein und Orange")
All guinnea pigs:
Lissie
Gustav
Maxi

Orange guinnea pigs:
Lissie
Maxi

Fazit: Beim Open/Closed Prinzip fügt man Funktionalität durch Erweitern hinzu, nicht durch Modifizieren.

Liskov Substitution Principle (LSP)

“Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S where S is a subtype of T.”
– Barbara H. Liskov, Jeannette M. Wing: Behavioral Subtyping Using Invariants and Constraints

Die Idee des Liskovschen Substitutionsprinzip ist wie folgt: Wenn du ein Interface hast das du mit einer Basisklasse benötigt, solltest du dieses Interface auch mit einer von dieser Basisklasse abgeleiteten Klasse benutzen können.

Beispiel

In diesem Kapitel zeige ich einen Verstoß gegen dieses Prinzip. Dazu verwende ich eine Klasse, die ein Rechteck beschreibt. Dann erstelle ich eine Quadratklasse, indem ich von der Rechteckklasse erbe und sie so verändere, dass sie sich wie ein Quadrat verhält.

Anschließend sieht man, dass nun Aufrufe, die bei der Rechteckklasse noch funktioniert, haben nicht mehr mit der Quadratklasse funktionieren, obwohl sie von ersterer abgeleitet wurde.

Soweit so gut. Zunächst definiere ich die Rechteckklasse mit getter und setter für die Attribute sowie die area Property für die Flächenberechnung.

class Rechteck
  def __init__(self, width, height):
    self._width = width
    self._height = height

  @property
  def area(self):
    return self._width * self._height

  def __str__(self):
    return f"Width: {self._width}, Height:{self._height}"

  @property
  def width(self):
    return self._width

  @width.setter
  def width(self, value):
    self._width = value

  @property
  def height(self):
    return self._height

  @height.setter
  def height(self, value):
    self._height = value

So benutzt man dieses Programm.

def test(rc):
  """This function sets the height to 10, multiplies the width by 10 and checks the resulting area for correctness."""
  w = rc.width
  rc.height = 10
  expected = int(w*10)
  print(f"Expected an area of {expected}, got {rc.area}")

rc = Rectangle(2,3)
test(rc)
Expected an area of 20, got 20

Als Nächstes kommt die problematische Klasse für Quadrate hinzu. Diese erbt von Rectangle.

class Square(Rectangle):
  def __init__(self,size):
    Rectangle.__init__(self, size, size)

  @Rectangle.width.setter
  def width(self, value):
    self._width = self._height = value

  @Rectangle.height.setter
  def height(self, value):
    self._width = self._height = value

Was passiert, wenn man jetzt genau dieselbe Funktion wie oben verwendet? Seht her.

def test(rc):
  w = rc.width
  rc.height = 10
  expected = int(w*10)
  print(f"Expected an area of {expected}, got {rc.area}")

rc = Rectangle(2,3)
test(rc)

sq = Square(5)
test(sq)
Expected an area of 20, got 20
Expected an area of 50, got 100

Nicht gut!

Problem

Was ist falsch gelaufen? Das Ändern der Höhe hat beim Quadrat den Seiteneffekt, dass gleichzeitig auch die Breite verändert wird. Ob das gewollt ist, sei mal dahin gestellt.

In jedem Fall weiß unsere test() Funktion nichts davon. Diese wundert sich nur warum auf einmal Käse rauskommt, obwohl Square eigentlich ein Rectangle sein sollte. Hier wurde gegen das Lizkovsche Substitutionsprinzip verstoßen.

Lösung

Wie macht man es besser? Ehrlich gesagt braucht man hier gar keine Quadratklasse, weil ein Quadrat nur eine spezielle Form eines Rechtecks ist.

Falls man unbedingt möchte, kann man natürlich eine Quadrat-Flag setzen, sobald die Höhe und die Breite gleich sind. Zum Erstellen eines Quadrats könnte man eine eigene Quadrat-Factory-Methode verwenden die ein Rechteck mit gleichen Seitenlängen zurückgibt.

Auf jeden Fall sollte man beim Erben vermeiden die Setter von Rectangle zu überschreiben. Genau das ist der Grund, der am Ende zu dieser Problematik führt.

Interface Segregation Principle (ISP)

“Clients should not be forced to depend upon interfaces that they do not use.”
– Robert C. Martin: The Interface Segregation Principle

Das vierte Prinzip ist das Interfacetrennungsprinzip. Dabei ist die Idee, dass du nicht zu viele Elemente in ein Interface packst.

Beispiel

In diesem Beispiel planen wir eine Beschreibung für Geräte die Faxen, Drucken und Scannen können sollen. Eine Idee wäre alle drei Funktionen in ein und demselben Interface zu halten. Dann können die Clienten dieses Interface so implementieren wie sie es brauchen.

from abc import ABC

class Machine(ABC):
  @abstractmethod
  def print(self, document):
    pass
  @abstractmethod
  def fax(self, document):
    pass
  @abstractmethod
  def scan(self, document):
    pass

Für Multifunktionsdrucker ist dieses Interface Ideal.

class MultiFunctionPrinter(Machine):
  def print(self, document):
    print("Printing . . .")
  def fax(self, document):
    print("Faxing . . .")
  def scan(self, document):
    print("Scanning . . .")

Problem

Für alte Drucker die nicht scannen und faxen können ist die Lösung nicht so ideal.

class OldFashionedPrinter(Machine):
  def print(self, document):
    print("Printing . . .")
  def fax(self, document):
    raise NotImplementedError("Printer can not fax!")
  def scan(self, document):
    raise NotImplementedError("Printer can not scan!")

Falls man das so umsetzt, wird jeder der die OldFashionedPrinter Klasse sieht auch die fax und scan Funktionen sehen. Demzufolge werden zukünftige Entwickler früher oder später in den NotImplemented Error laufen.

Nicht gut!

Lösung

Wie macht man das besser? Ganz einfach: Beim Interfacetrennungsprinzip trennst du die abstrakten Methoden des Interfaces einfach auf eigene Klassen auf.

from abc import ABC

class Printer(ABC):
  @abstractmethod
  def print(self, document):
    pass

class Scanner(ABC):
  @abstractmethod
  def scan(self, document):
    pass

class Faxmachine(ABC):
  @abstractmethod
  def fax(self, document):
    pass

Damit kannst du jetzt alle Funktionen einzeln erben.

class OldFashionedPrinter(Printer):
  def print(self, document):
    print("Printing . . .")

class Photocopier(Printer, Scanner):
  def print(self, document):
    print("Printing . . .")
  def scan(self, document):
    print("Scanning . . ."

class MultiFunctionPrinter(Printer, Faxmachine, Scanner):
  def print(self, document):
    print("Printing . . .")
  def fax(self, document):
    print("Faxing . . .")
  def scan(self, document):
    print("Scanning . . .")

Zusammenfassend besagt das Interface Segregation Principle einfach nur, dass du die Interfaces so klein wie möglich und so groß wie nötig halten solltest.

Dependency Inversion Principle (DIP)

“A. High-level modules should not depend on low level modules. Both should depend on abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.”
– Robert C. Martin: The Dependency Inversion Principle

Das Prinzip der Abhängigkeitsinversion besagt, dass high-Level Module nicht von los-Level Modulen abhängen sollten. Stattdessen sollten beide Modultypen von denselben Abstraktionen abhängen.

Beispiel

Als Beispiel nutze ich hier ein kleines Programm das die Beziehung zwischen Eltern und Kindern speichern kann.

from enum import Enum

class Relationship(Enum):
  PARENT = 0
  CHILD = 1
  SIBLING = 2

class Person:
  def __init__(self, name):
  self.name = name

class Relationships:
  def __init__(self):
    self.relations = []
  def add_parrent_and_child(self, parent, child):
    self.relations.append(
      (parent, Relationship.PARENT, child)
    )
    self.relations.append(
      (child, Relationship.CHILD, parent)
    )

Dies soll unser low-Level Modul sein.

Jetzt kommt noch ein high-Level Modul zum Suchen dazu.

class Research:
  def __init__(self,relationships):
    relations = relationships.relations
    for r in relations:
      if r[0].name == "John" and r[1] == Relationship.PARENT:
        print(f"John has a child called {r[2].name}.")

Dieses Gebilde kann wie folgt genutzt werden.

parent = Person("John")
child1 = Person("Chris")
child2 = Person("Matt")

relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)

Research(relationships)
John has a child called Chris.
John has a child called Matt.

Nicht gut!

Problem

Warum soll das schlecht sein?

Was hier passiert ist das ein high-Level Modul auf ein low-Level Modul – nämlich den Speichermechanismus – zugreift.

Sobald sich jetzt irgendetwas an dem low-Level Modul ändert, würde das high-Level Modul nicht mehr funktionieren. Zum Beispiel könnte man sich Entscheiden die Relationships zukünftig in einem Dictionary, anstatt in einer Liste zu speichern. Dann funktioniert das high-Level Modul nicht mehr da es eine Liste erwartet.

Lösung

Die Klasse Research sollte nicht von der konkreten Implementation abhängig sein. Idealerweise hängt es auf eine Abstraktion derselben ab. Dazu definieren wir jetzt eine neue RelationshipBrowser Klasse das die Funktion der früheren Research Klasse übernimmt. Anschließend verändern wir die Relationships Klasse, sodass diese vom RelationshipBrowser erbt.

from abc import ABC

class RelationshipBrowser(ABC):
  @abstractmethod
  def find_all_children_of(self, name):
    for r in self.relations:
      if r[0].name == name and r[1] == Relationship.PARRENT:
        yield r[2].name

class Relationships(RelationshipBrowser): #low-level
  def __init__(self):
    self.relations = []

Der Rest bleibt wie oben. Nur die high-Level Research Klasse muss noch angepasst werden.

class Research: #high-level
  def __init__(self, browser):
    for p in browser.find_all_children_of("John"):
      print(f"John has a child called {p}.")

parent = Person("John")
child1 = Person("Chris")
child2 = Person("Matt")

relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
John has a child called Chris.
John has a child called Matt.

Warum ist das besser?

Jetzt können wir die low-Level Methoden und Klassen nach Belieben verändern. Zum Beispiel könnten wir die Speicherung nicht nur in einer Liste, sondern sogar in einer SQL Datenbank umsetzen. Das high-Level Modul würde davon nichts mitbekommen und weiterhin funktionieren, solange die low-Level Module der Interfacedefinition treu bleibt.

Die Benutzung bleibt identisch.

  • High-Level Module benutzen Funktionalitäten von anderen Klassen und sind Hardware fern
  • Low-Level Modul verwalten Dinge wie den Speicher oder andere Hardwarenahe Themen

Dieser Beitrag hat 2 Kommentare

Schreibe einen Kommentar