IEC 61131-3: SOLID – Das Single Responsibility Principle

Das Single Responsibility Principle (SRP) ist eines der wichtigsten unter den SOLID-Prinzipien. Es ist für die Zerlegung von Modulen zuständig und verdeutlicht, warum eine Codeeinheit nur für eine einzig klar definierte Aufgabe verantwortlich sein sollte: Software bleibt langfristig erweiterbar und kann deutlich einfacher gepflegt werden.

Um das Single Responsibility Principle näher zu bringen, werde ich auf das Beispiel vom letzten Post (IEC 61131-3: SOLID – Das Dependency Inversion Principle) aufsetzen. Dort wurde gezeigt, wie es möglich ist mit Hilfe des Dependency Inversion Principle (DIP) feste Abhängigkeiten aufzulösen.

Ausgangssituation

Für drei verschiedene Lampentypen stehen jeweils entsprechende Funktionsblöcke (FB_LampOnOff, FB_LampSetDirect und FB_LampUpDown) zur Verfügung. Jeder Lampentyp besitzt seine eigene Funktionsweise und bietet entsprechende Methoden an, um den Ausgangswert zu verändern.

Ein übergeordneter Controller (FB_Controller) stellt eine einheitliche Schnittstelle (API) zur Verfügung, um auf diese drei Typen zuzugreifen. Hierbei wird das Dependency Inversion Principle (DIP) angewendet, um eine feste Kopplung zwischen dem Controller und den Lampentypen zu vermeiden. Durch I_Lamp wird diese einheitliche API definiert. Der abstrakte Funktionsblock FB_Lamp implementiert die Schnittstelle I_Lamp. Des Weiteren enthält FB_Lamp Programmcode, der bei allen Lampentypen gleich ist. Dadurch das alle Lampentypen von FB_Lamp abgeleitet sind, werden Controller und Lampen voneinander entkoppelt. Statt Instanzen von konkreten Lampentypen anzulegen, verwaltet der Controller nur noch eine Referenz auf FB_Lamp.

Analyse der Implementierung

Für eine weitere Beurteilung der Implementierung soll der Funktionsblock FB_LampUpDown dienen. Ganz zu Beginn der Serie enthielt dieser nur die drei Methoden OneStepDown(), OneStepUp() und OnOff() um den Ausgangswert zu verändern.

Punkt 1: mehrere Rollen

Durch die Anwendung des Dependency Inversion Principle (DIP) sind die Methoden DimDown(), DimUp(), Off() und On() über den abstrakten Funktionsblock FB_Lamp und der Schnittstelle I_Lamp hinzugekommen. Diese vier Methoden stellen eine Art ‚Adapter‘ zwischen FB_Controller und der eigenen Implementierung von FB_LampUpDown dar.

Das folgende UML-Diagramm zeigt nochmal die beiden Rollen, die der Baustein FB_LampUpDown aktuell besitzt. Blau markiert sind die Methoden, die durch die Vererbung von FB_Lamp hinzugekommen sind (Rolle als Adapter zu FB_Controller). Der grün markierte Bereich kennzeichnet die eigentliche Rolle des Funktionsbaustein (Rolle als FB_LampUpDown).

(abstrakte Elemente werden in kursiver Schriftart dargestellt)

An dieser Stelle könnte die Überlegung gemacht werden, die Methoden OneStepDown(), OneStepUp() und OnOff() auf PRIVATE zu setzen. Allerdings ist dieses nur dann möglich, wenn FB_LampUpDown bisher in keinem anderen Zusammenhang verwendet wurde. Ist dieses nicht der Fall, so muss jede Erweiterung die Abwärtskompatibel des Funktionsblocks sicherstellen.

Optimierung der Implementierung

So wie auch bei der Vorstellung des Dependency Inversion Principle (DIP), ist das Programm in seinem aktuellen Umfang sehr gut wartbar. Doch was ist, wenn zusätzliche Rollen hinzukommen? So könnte in einem weiteren Entwicklungszyklus es notwendig sein, weitere Adapter zu implementieren. Die eigentliche Logik von FB_LampUpDown würde in den Implementierungen der jeweiligen Adapter untergehen.

Adapter erstellen

Wir brauchen also ein Werkzeug, um die einzelnen Rollen zu separieren. Im besten Fall so, dass die ursprüngliche Implementierung von FB_LampUpDown unverändert bleibt. Dieses kann auch notwendig sein, z.B. dann, wenn sich FB_LampUpDown in einer SPS-Bibliothek befindet und somit nicht im Einflussbereich des Entwicklers liegt.

Ansatz 1: Vererbung

Ein möglicher Lösungsansatz könnte darin bestehen, mit Vererbung zu arbeiten. Der neue Adapter-Funktionsblock (FB_LampUpDownAdapter) erbt von FB_LampUpDown. Zusätzlich müsste dieser ebenfalls von FB_Lamp erben. Da Mehrfachvererbung aber nicht möglich ist, könnte FB_LampUpDownAdapter aber die Schnittstelle I_Lamp implementieren. Der abstrakte Funktionsblock FB_Lamp würde entfallen.

Durch das Erben von FB_LampUpDown stellt der Adapter aber auch die Methoden nach Außen zur Verfügung, die für die Interaktion mit dem Controller nicht benötigt werden. FB_LampUpDownAdapter gibt somit durch diesen Lösungsansatz Implementierungsdetails von FB_LampUpDown weiter.

Ansatz 2: Adapter Pattern

Hierbei enthält der Adapter intern eine Instanz von FB_LampUpDown. Die Methoden für die Funktion des Adapters werden intern einfach an FB_LampUpDown weitergeleitet. Alle Details von FB_LampUpDown werden somit nicht mehr nach Außen bekanntgegeben.

(abstrakte Elemente werden in kursiver Schriftart dargestellt)

Mit diesem Ansatz haben wir unser Ziel erreicht: Die Rolle des Adapters und die Logik der Lampe sind klar voneinander getrennt. Die Implementierung der Lampe musste hierzu nicht verändert werden.

Beispiel 1 (TwinCAT 3.1.4024) auf GitHub

Analyse der Optimierung

Schauen wir uns das Programm nach der Umsetzung des Single Responsibility Principle (SRP) nochmal genauer an.

(abstrakte Elemente werden in kursiver Schriftart dargestellt)

Die Zuständigkeiten sind jetzt klar voneinander getrennt. Soll der Programmcode erweitert werden, so ist sehr schnell klar, in welchem Funktionsblock dieses zu erfolgen hat.

Auch wenn die Anwendung um weitere Adapter ergänzt wird, so muss die Implementierung der schon existierenden Funktionsblöcke für die Lampen nicht erweitert werden. Es besteht nicht die Gefahr, dass sich diese im Laufe der einzelnen Entwicklungszyklen immer weiter aufblähen.

Die Wartbarkeit eines Programms verbessert sich, wenn unabhängige Aufgaben (Rollen) in einzelne, unabhängige Codeeinheiten (Funktionsblöcke) aufgeteilt werden. Dadurch erhalten wir aber auch mehr Funktionsblöcke, wodurch die Übersicht des Projektes leidet. Aus diesem Grund sollte nicht versucht werden, die Anzahl der Funktionsblöcke unnötig zu erhöhen. Nicht immer ist es sinnvoll einzelne Funktionsblöcke für einzelne Aufgaben anzulegen.

Da ein Programm in seinem Funktionsumfang kontinuierlich erweitert wird, sollten Funktionsblöcke ab einen bestimmten Umfang aufgeteilt werden. Hilfestellung für die Umsetzung geben die SOLID-Prinzipien. Bleibt aber noch die Frage offen, ab wann eine Codeeinheit eine ‚kritische‘ Größe erreicht hat.

Class Responsibility Collaboration (CRC)

Die Anzahl der Codezeilen heranzuziehen, um die Komplexität der Codeeinheit zu beurteilen, ist deutlich zu kurz gegriffen. Auch wenn solche Code-Metriken sinnvolle Hilfsmittel darstellen (das wäre einen eigenen Post wert), so will ich hier ein Verfahren vorstellen, das über die Anforderungen einer Codeeinheit die Komplexität ermittelt.

Ich habe hier bewusst ‚Codeeinheit‘ geschrieben und nicht ‚Funktionsblock‘. Mit diesem Verfahren kann auch eine Systemarchitektur beurteilt werden. Die ‚Codeeinheiten‘ wären dann z.B. einzelne Services. Es muss also nicht immer um die Beurteilung von reinem Quellcode gehen.

Die hier vorgestellte CRC-Technik steht für Class Responsibility Collaboration. Der Name beschreibt schon recht gut das Prinzip dieser Technik:

  • Es werden alle Funktionsblöcke (Class) aufgelistet.
  • Zu jedem Funktionsblock wird die Aufgabe bzw. Zuständigkeit (Responsibility) aufgeschrieben.
  • Außerdem wird bei jedem Funktionsblock notiert, mit welchen anderen Funktionsblöcken dieser zusammenarbeitet (Collaboration).

Die CRC-Technik zeigt sehr deutlich, ob sich in einem Softwaresystem ein Ungleichgewicht befindet. Die Zuständigkeiten und die Abhängigkeiten sollten sich gleichmäßig über alle Funktionsblöcke verteilen.

Für das Erstellen der CRC-Karten verwende ich das Tool SimpleCrcTool, welches auf GitHub (https://github.com/guidolx/simple-crc-app) zu finden ist und direkt im Browser ausgeführt werden kann: https://guidolx.github.io/simple-crc-app.

Um die Übersicht zu erhöhen, wird bei der folgenden Betrachtung der Funktionsblock FB_AnalogValue nicht weiter berücksichtigt. Dieser dient in allen Varianten des Beispielprogramms in gleicher Weise zum Austausch der Ausgangsgröße zwischen den jeweiligen Lampentypen und dem Controller.

Schritt 1: Ausgangssituation

Zu Beginn soll das Programm in seiner Ausgangsform betrachtet werden. Also bevor die erste Optimierung durchgeführt wurde (siehe IEC 61131-3: SOLID – Das Dependency Inversion Principle).

Es ist gut zu erkennen, dass der Controller sehr viele Aufgaben übernimmt, während der Umfang der jeweiligen Lampentypen sehr übersichtlich ist. Ähnlich sieht es bei den Abhängigkeiten aus. Der Controller greift auf jeden Lampentyp direkt zu.

Schritt 2: Anwendung des Dependency Inversion Principle (DIP)

Durch die Anwendung des Dependency Inversion Principle wurden die festen Abhängigkeiten zwischen dem Controller und den Lampentypen aufgelöst. Der Controller greift nur noch auf den abstrakten Funktionsblock FB_Lamp zu und nicht mehr auf die jeweiligen spezialisierten Lampentypen.

Jetzt besteht allerdings der Nachteil, dass jeder Lampentyp mehrere Rollen bedient. Zum einen die Logik des Lampentyps und zum anderen das Mapping zu der abstrakten Lampe.

Schritt 3: Anwendung des Single Responsibility Principle (SRP)

Um die Verletzung des Single Responsibility Principle an dieser Stelle aufzulösen, wurden das Adapter Pattern angewendet. Jeder Lampentyp besitzt jetzt einen entsprechenden Adapter-Funktionsblock, der für das Mapping zwischen der abstrakten Lampe und dem konkreten Lampentyp zuständig ist.

Alle Funktionsblöcke besitzen nach der Optimierung nur noch eine einzige Aufgabe. Somit haben wir jetzt eine große Menge an kleinen, statt eine kleine Menge an umfangreichen Funktionsblöcken.

Die Definition des Single Responsibility Principle

Werfen wir nun einen Blick auf die Definition des Single Responsibility Principle. Dieses besteht aus einem Grundsatz und wurde ebenfalls in dem Buch (Amazon-Werbelink *) Clean Architecture: Das Praxis-Handbuch für professionelles Softwaredesign von Robert C. Martin schon Anfang der 2000er Jahre definiert:

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

Robert C. Martin verfeinert diese Aussage weiter zu:

Ein Modul sollte für einen, und nur einen, Akteur verantwortlich sein.

Doch was ist mit Modul gemeint und wer oder was ist der Akteur?

Das Modul ist hierbei eine Codeeinheit und ist abhängig von der Perspektive, mit der ein Softwaresystem betrachtet wird. Aus der Sicht des Softwarearchitekten kann ein Modul ein REST-Service, ein Kommunikationskanal oder ein Datenbanksystem sein. Für den Softwareentwickler kann ein Modul ein Funktionsblock oder ein zusammenhängender Satz an Funktionsblöcken und Funktionen darstellen. Bei dem oben gezeigten Beispiel, war ein Modul ein Funktionsblock.

Auch der Begriff Akteur bezieht sich nicht zwangsläufig auf eine Person, sondern kann auch wieder ein bestimmter Satz an Usern oder Stakeholdern repräsentieren.

Zusammenfassung

Im letzten Post wurde durch das Dependency Inversion Principle (DIP) der Controller (FB_Controller) von den einzelnen Lampen entkoppelt. Hierzu mussten noch die einzelnen Funktionsblöcke der Lampen angepasst werden. Durch das Single Responsibility Principle (SRP) wurde diese Entkopplung weiter optimiert.

Ist es in Ordnung, wenn ein Funktionsblock für das Komprimieren und für das Verschlüsseln von Daten zuständig ist? Nein! Komprimieren und Verschlüsseln sind völlig verschiedene Verantwortungsbereiche. Man kann Daten komprimieren, ohne dabei Aspekte der Verschlüsselung zu berücksichtigen. Und auch die Verschlüsselung ist unabhängig von der Komprimierung. Es handelt sich um zwei völlig unabhängige Aufgaben. Werden etwa Komprimierung und Verschlüsselung im selben Funktionsblock behandelt, so gibt es auch zwei Gründe für Änderungen: die Verschlüsselung und die Komprimierung.

Ein weiteres Beispiel für die Anwendung des Single Responsibility Principle (aus der Sicht der Softwarearchitektur) ist das ISO/OSI-Referenzmodell für Netzwerkprotokolle. Dieses Modell definiert sieben aufeinanderfolgende Schichten mit jeweils klar definierten Aufgaben. Dieses ermöglicht das Austauschen einzelner Schichten, ohne das darüber oder darunter liegende Schichten davon beeinflusst werden. Jede Schicht hat eine(!) klar definierte Aufgabe, z.B. die Bitübertragung.

Im nächsten Post geht es um das Liskov Substitution Principle (LSP).

Author: Stefan Henneken

I’m Stefan Henneken, a software developer based in Germany. This blog is just a collection of various articles I want to share, mostly related to Software Development.

3 thoughts on “IEC 61131-3: SOLID – Das Single Responsibility Principle”

Leave a comment