IEC 61131-3: SOLID – Das Dependency Inversion Principle

Feste Abhängigkeiten sind einer der Hauptursache für schlecht wartbare Software. Natürlich können nicht alle Funktionsblöcke völlig unabhängig von anderen Funktionsblöcken existieren. Schließlich agieren diese miteinander und stehen somit untereinander in Beziehungen. Durch das Anwenden des Dependency Inversion Principle können diese Abhängigkeiten aber minimiert werden. Änderungen lassen sich somit schneller umsetzen.

An einem einfachen Beispiel werde ich zeigen, wie negative Kopplungen zwischen Funktionsblöcken entstehen können. Anschließend werde ich mit Hilfe des Dependency Inversion Principle diese Abhängigkeiten auflösen.

Beispiel

Das Beispiel enthält drei Funktionsblöcke, welche jeweils unterschiedliche Lampen ansteuern. Während FB_LampOnOff nur eine Lampe ein- und ausschalten kann, kann FB_LampSetDirect den Ausgangswert direkt auf einen Wert von 0 % bis 100 % setzen. Der dritte Baustein (FB_LampUpDown) ist nur in der Lage die Lampe durch die Methoden OneStepDown() und OneStepUp() um jeweils 1 % relativ zu dimmen. Die Methode OnOff() setzt den Ausgangswert unmittelbar auf 100 % bzw. 0 %.

Aufgabenstellung

Die Ansteuerung dieser drei Funktionsblöcke übernimmt FB_Controller. Von jedem Lampentyp wird in FB_Controller eine Instanz instanziiert. Über die Eigenschaft eActiveLamp vom Typ E_LampType wird die gewünschte Lampe ausgewählt.

TYPE E_LampType :
(
  Unknown   := -1,
  SetDirect := 0,
  OnOff     := 1,
  UpDown    := 2
) := Unknown;
END_TYPE

FB_Controller besitzt für die Ansteuerung der unterschiedlichen Lampentypen wiederum entsprechende Methoden. Die Methoden DimDown() und DimmUp() dimmen die ausgewählte Lampe jeweils um 5 % nach oben bzw. 5 % nach unten. Während die Methoden On() und Off() die Lampe direkt ein- oder ausschaltet.

Für die Übermittlung der Ausgangsgröße zwischen dem Controller und der ausgewählten Lampe, wird das IEC 61131-3: Das Observer Pattern verwendet. Der Controller enthält hierzu eine Instanz von FB_AnalogValue. FB_AnalogValue implementiert die Schnittstelle I_Observer mit der Methode Update(), während die drei Funktionsblöcke für die Lampen die Schnittstelle I_Subject implementieren. Über die Methode Attach() erhält jeder Lampenbaustein einen Interface-Pointer auf die Schnittestelle I_Observer von FB_AnalogValue. Ändert sich in einem der drei Lampenbausteinen der Ausgangswert, so wird über die Methode Update() von der Schnittstelle I_Observer der neue Wert an FB_AnalogValue übermittelt.

Unser Beispiel besteht bis jetzt aus den folgenden Akteuren:

Das UML-Diagramm zeigt die Zusammenhänge der jeweiligen Elemente untereinander:

Schauen wir uns den Programmcode der einzelnen Funktionsblöcke etwas genauer an.

FB_LampOnOff / FB_LampUpDown / FB_LampSetDirect

Als Beispiel für die drei Lampentypen soll hier FB_LampSetDirect dienen. FB_LampSetDirect besitzt eine lokale Variable für den aktuellen Ausgangswert und eine lokale Variable für den Interface-Pointer auf FB_AnalogValue.

FUNCTION_BLOCK PUBLIC FB_LampSetDirect IMPLEMENTS I_Subject
VAR
  nLightLevel    : BYTE(0..100);
  _ipObserver    : I_Observer;
END_VAR

Schaltet FB_Controller auf die Lampe vom Typ FB_LampSetDirect um, so ruft FB_Controller die Methode Attach() auf und übergibt an FB_LampSetDirect den Interface-Pointer auf FB_AnalogValue. Ist der Wert gültig (ungleich 0), so wird dieser in der lokalen Variablen (Backing Variable) _ipObserver gespeichert.

Anmerkung: Lokale Variablen, die den Wert einer Eigenschaft speichern, werden auch als Backing Variable bezeichnet und mit einem Unterstrich im Variablennamen gekennzeichnet.

METHOD Attach
VAR_INPUT
  ipObserver     : I_Observer;
END_VAR
IF (ipObserver = 0) THEN
  RETURN;
END_IF
_ipObserver := ipObserver;

Die Methode Detach() setzt den Interface-Pointer auf 0, wodurch die Methode Update() nicht mehr aufgerufen wird (siehe weiter unten).

METHOD Detach
_ipObserver := 0;

Über die Methode SetLightLevel() wird der neue Ausgangswert übergeben und in die lokale Variable nLightLevel gespeichert. Außerdem wird vom Interface-Pointer _ipObserver die Methode Update() aufgerufen. Hierdurch erhält die Instanz von FB_AnalogValue, welche sich in FB_Controller befindet, den neuen Ausgangswert.

METHOD PUBLIC SetLightLevel
VAR_INPUT
  nNewLightLevel    : BYTE(0..100);
END_VAR
nLightLevel := nNewLightLevel; 
IF (_ipObserver <> 0) THEN
  _ipObserver.Update(nLightLevel);
END_IF

Bei allen drei Lampenbausteinen sind die Methoden Attach() und Detach() identisch. Unterschiede gibt es nur in den Methoden, welche den Ausgangswert ändern.

FB_AnalogValue

FB_AnalogValue enthält sehr wenig Programmcode, da dieser Funktionsblock ausschließlich zum Speichern der Ausgangsgröße dient.

FUNCTION_BLOCK PUBLIC FB_AnalogValue IMPLEMENTS I_Observer
VAR
  _nActualValue   : BYTE(0..100);
END_VAR

METHOD Update : BYTE
VAR_INPUT
  nNewValue       : BYTE(0..100);
END_VAR

Zusätzlich hat FB_AnalogValue noch die Eigenschaft nValue, über der der aktuelle Wert nach außen zur Verfügung gestellt wird.

FB_Controller

FB_Controller enthält die Instanzen der drei Lampenbausteine. Des Weiteren ist eine Instanz von FB_AnalogValue vorhanden, um den aktuellen Ausgangswert der aktiven Lampe entgegenzunehmen. _eActiveLamp speichert den aktuellen Zustand der Eigenschaft eActiveLamp.

FUNCTION_BLOCK PUBLIC FB_Controller
VAR
  fbLampOnOff      : FB_LampOnOff();
  fbLampSetDirect  : FB_LampSetDirect();
  fbLampUpDown     : FB_LampUpDown();
  fbActualValue    : FB_AnalogValue();
  _eActiveLamp     : E_LampType;
END_VAR

Das Umschalten zwischen den drei Lampen erfolgt über den Setter der Eigenschaft eActiveLamp.

Off();

fbLampOnOff.Detach();
fbLampSetDirect.Detach();
fbLampUpDown.Detach();

CASE eActiveLamp OF
  E_LampType.OnOff:
    fbLampOnOff.Attach(fbActualValue);
  E_LampType.SetDirect:
    fbLampSetDirect.Attach(fbActualValue);
  E_LampType.UpDown:
    fbLampUpDown.Attach(fbActualValue);
END_CASE

_eActiveLamp := eActiveLamp;

Wird über die Eigenschaft eActiveLamp auf eine andere Lampe umgeschaltet, so wird zu Beginn die noch aktuelle Lampe über die lokale Methode Off() ausgeschaltet. Des Weiteren wird bei allen drei Lampen die Methode Detach() aufgerufen. Hierdurch wird eine mögliche Verbindung zu FB_AnalogValue beendet. Innerhalb der CASE-Anweisung wird bei der neuen Lampe die Methode Attach() aufgerufen und der Interface-Pointer auf fbActualValue übergeben. Zum Schluss wird der Zustand der Eigenschaft in die lokale Variable _eActiveLamp gespeichert.

Die Methoden DimDown(), DimUp(), Off() und On() haben die Aufgabe den gewünschten Ausgangswert einzustellen. Da die einzelnen Lampentypen hierzu verschiedene Methoden anbieten, muss jeder Lampentyp einzeln behandelt werden.

Die Methode DimDown() soll die aktive Lampe um 5 % runterdimmen. Der Ausgangswert soll hierbei aber 10 % nicht unterschreiten.

METHOD PUBLIC DimDown
CASE _eActiveLamp OF
  E_LampType.OnOff:
    fbLampOnOff.Off();
  E_LampType.SetDirect:
    IF (fbActualValue.nValue >= 15) THEN
      fbLampSetDirect.SetLightLevel(fbActualValue.nValue - 5);
    END_IF
  E_LampType.UpDown:
    IF (fbActualValue.nValue >= 15) THEN	
      fbLampUpDown.OneStepDown();
      fbLampUpDown.OneStepDown();
      fbLampUpDown.OneStepDown();
      fbLampUpDown.OneStepDown();
      fbLampUpDown.OneStepDown();
    END_IF
END_CASE

FB_LampOnOff kennt nur die Zustände 0 % und 100 %. Ein Dimmen ist somit nicht möglich. Als Kompromiss wird deshalb beim Runterdimmen die Lampe ausgeschaltet (Zeile 4).

Bei FB_LampSetDirect kann mit Hilfe der Methode SetLightLevel() der neue Ausgangswert direkt gesetzt werden. Hierzu werden vom aktuellen Ausgangswert 5 subtrahiert und an die Methode SetLightLevel() übergeben (Zeile 7). Die IF-Abfrage in Zeile 6 stellt sicher, dass der Ausgangswert nicht unter 10 % eingestellt wird.

Da die Methode OneStepDown() von FB_LampUpDown den Ausgangswert nur um 1 % reduziert, wird die Methode 5-mal aufgerufen (Zeilen 11-15). Auch hier stellt eine IF-Abfrage in Zeile 10 sicher, dass die 10 % nicht unterschritten werden.

DimUp(), Off() und On() haben einen vergleichbaren Aufbau. Durch eine CASE-Anweisung werden die verschiedenen Lampentypen gesondert behandelt und somit die jeweiligen Besonderheiten berücksichtigt.

Beispiel 1 (TwinCAT 3.1.4024) auf GitHub

Analyse der Implementierung

Auf dem ersten Blick wirkt die Umsetzung solide. Das Programm macht was es soll und der vorgestellte Code ist in seiner jetzigen Größe wartbar. Wäre sichergestellt, dass das Programm an Umfang nicht zunimmt, könnte alles so bleiben wie es ist.

Doch in der Praxis entspricht der aktuelle Stand eher dem ersten Entwicklungszyklus eines größeren Projektes. Die kleine, überschaubare Anwendung wird im Laufe der Zeit durch Erweiterungen an Codeumfang zunehmen. Somit ist eine genaue Inspektion des Codes schon zu Beginn sinnvoll. Ansonsten besteht die Gefahr, den richtigen Zeitpunkt für grundlegende Optimierungen zu verpassen. Mängel lassen sich dann nur noch mit großem Zeitaufwand beseitigen.

Doch welche grundlegenden Probleme hat das obige Beispiel?

Punkt 1: CASE-Anweisung

In jeder Methode des Controllers befindet sich dasselbe CASE-Konstrukt.

CASE _eActiveLamp OF
  E_LampType.OnOff:
    fbLampOnOff...
  E_LampType.SetDirect:
    fbLampSetDirect...
  E_LampType.UpDown:
    fbLampUpDown...
END_CASE

Es ist zwar eine Ähnlichkeit zwischen dem Wert von _eActiveLamp (z.B.  E_LampType.SetDirect) und der lokalen Variable (z.B. fbLampSetDirect) zu erkennen, doch müssen trotzdem die einzelnen Fälle manuell beachtet und programmiert werden.

Punkt 2: Erweiterbarkeit

Soll ein neuer Lampentyp hinzugefügt werden, so muss zunächst der Datentyp E_LampType erweitert werden. Anschließend ist es notwendig in jeder Methode vom Controller die CASE-Anweisung zu ergänzen.

Punkt 3: Zuständigkeiten

Dadurch, dass der Controller das Zuordnen der Befehle auf alle Lampentypen durchführt, ist die Logik eines Lampentyps auf mehrere FBs verteilt. Dieses ist eine äußerst unpraktische Gruppierung. Will man verstehen, wie der Controller einen bestimmten Lampentyp anspricht, so muss man von Methode zu Methode springen und sich aus der CASE-Anweisung den korrekten Fall raussuchen.

Punkt 4: Kopplung

Der Controller hat eine enge Bindung zu den unterschiedlichen Lampenbausteinen. Dadurch ist der Controller stark abhängig von Änderungen an den einzelnen Lampentypen. Jede Änderung an den Methoden eines Lampentyps führt zwangsläufig auch zu Anpassungen am Controller.

Optimierung der Implementierung

Derzeit besitzt das Beispiel feste Abhängigkeiten in einer Richtung. Der Controller ruft die Methoden der jeweiligen Lampentypen auf. Diese direkte Abhängigkeit sollte aufgelöst werden. Dazu benötigen wir eine gemeinsame Abstraktionsebene.

Auflösen der CASE-Anweisungen

Hierzu bieten sich abstrakte Funktionsblöcke und Schnittstelle an. Im Folgenden verwende ich den abstrakten Funktionsblock FB_Lamp und die Schnittstelle I_Lamp. Die Schnittstelle I_Lamp besitzt die gleichen Methoden wie der Controller. Der abstrakte FB implementiert die Schnittstelle I_Lamp und besitzt dadurch ebenfalls alle Methoden von FB_Controller.

Wie abstrakte Funktionsblöcke und Schnittstellen miteinander kombiniert werden können, habe in IEC 61131-3: Abstrakter FB vs. Schnittstelle vorgestellt.

Alle Lampentypen erben von diesem abstrakten Lampentyp. Aus Sicht des Controllers sehen alle Lampentypen hierdurch gleich aus. Des Weiteren implementiert der abstrakte FB die Schnittstelle I_Subject.

FUNCTION_BLOCK PUBLIC ABSTRACT FB_Lamp IMPLEMENTS I_Subject, I_Lamp

Die Methoden Detach() und Attach() von FB_Lamp werden nicht als abstract deklariert und enthalten den notwendigen Programmcode. Dadurch ist es nicht notwendig, den Programmcode für diese beiden Methoden in jeden Lampentyp erneut zu implementieren.

Da die Lampentypen von FB_Lamp erben, sind diese aus Sicht des Controllers alle gleich.

Die Methode SetLightLevel() bleibt unverändert. Das Zuordnen der Methoden von FB_Lamp (DimDown(), DimUp(), Off() und On()) auf die jeweiligen Lampentypen erfolgt jetzt nicht mehr im Controller, sondern im jeweiligen FB des Lampentyps:

METHOD PUBLIC DimDown
IF (nLightLevel >= 15) THEN
  SetLightLevel(nLightLevel - 5);
END_IF

Somit ist nicht mehr der Controller für das Zuordnen der Methoden zuständig, sondern jeder Lampentyp selbst. Die CASE-Anweisungen in den Methoden von FB_Controller entfallen vollständig.

Auflösen von E_LampType

Die Verwendung von E_LampType bindet den Controller weiterhin an die jeweiligen Lampentypen. Doch wie kann auf die verschiedenen Lampentypen umgeschaltet werden, wenn E_LampType entfällt? Um dieses zu erreichen, wird dem Controller der gewünschte Lampentyp über eine Eigenschaft per Referenz übergeben.

PROPERTY PUBLIC refActiveLamp : REFERENCE TO FB_Lamp

Somit können alle Lampentypen übergeben werden, einzige Voraussetzung, der übergebene Lampentyp muss von FB_Lamp erben. Dadurch werden alle Methoden und Eigenschaften festgelegt, die für eine Interaktion zwischen Controller und Lampenbaustein notwendig sind.

Anmerkung: Diese Technik des ‚reinreichen‘ von Abhängigkeiten wird auch als Dependency Injection bezeichnet.

Die Umschaltung auf den neuen Lampenbaustein erfolgt im Setter der Eigenschaft refActiveLamp. Dort wird die Methode Detach() der aktiven Lampe aufgerufen (Zeile 2), während in Zeile 6 von der neuen Lampe die Methode Attach() aufgerufen wird. In Zeile 4 wird die Referenz der neuen Lampe in die lokale Variable (Backing Variable) _refActiveLamp abgespeichert.

IF (__ISVALIDREF(_refActiveLamp)) THEN
  _refActiveLamp.Detach();
END_IF
_refActiveLamp REF= refActiveLamp;
IF (__ISVALIDREF(refActiveLamp)) THEN
   refActiveLamp.Attach(fbActualValue);
END_IF

In den Methoden DimDown(), DimUp(), Off() und On() wird über _refActiveLamp der Methodenaufruf an die aktive Lampe weitergeleitet. Anstelle der CASE-Anweisung stehen hier nur noch wenige Zeile, da nicht mehr zwischen den verschiedenen Lampentypen unterschieden werden muss.

METHOD PUBLIC DimDown
IF (__ISVALIDREF(_refActiveLamp)) THEN
  _refActiveLamp.DimDown();
END_IF

Der Controller ist somit generisch. Wird ein neuer Lampentyp definiert, so bleibt der Controller unverändert.

Zugegeben: hierdurch wurde das Auswählen des gewünschten Lampentyps an den Aufrufer von FB_Controller übertragen. Dieser muss jetzt die verschiedenen Lampentypen anlegen und an den Controller übergeben. Dieses ist dann ein guter Ansatz, wenn sich z.B. alle Elemente in einer Bibliothek befinden. Durch die oben gezeigten Anpassungen können jetzt eigene Lampentypen entwickelt werden, ohne dass Anpassungen an der Bibliothek notwendig sind.

Beispiel 2 (TwinCAT 3.1.4024) auf GitHub

Analyse der Optimierung

Obwohl ein Funktionsblock und eine Schnittstelle hinzugekommen sind, so ist die Menge des Programmcodes nicht mehr geworden. Der Code brauchte nur sinnvoll umstrukturiert werden, um die oben genannten Probleme zu eliminieren. Das Ergebnis ist eine langfristig tragfähige Programmstruktur, welche in mehrere gleichbleibend kleine Artefakte mit klaren Verantwortlichkeiten aufgeteilt wurde. Das UML-Diagramm zeigt sehr gut die neue Aufteilung:

(abstrakte Elemente werden in kursiver Schriftart dargestellt)

FB_Controller besitzt keine feste Bindung mehr zu den einzelnen Lampentypen. Stattdessen wird auf den abstrakten Funktionsblock FB_Lamp zugegriffen, welcher über die Eigenschaft refActiveLamp an den Controller hinein gereicht wird. Über diese Abstraktionsebene wird dann auf die einzelnen Lampentypen zugegriffen.

Die Definition des Dependency Inversion Principle

Das Dependency Inversion Principle besteht aus zwei Grundsätzen und wird in dem Buch (Amazon-Werbelink *) Clean Architecture: Das Praxis-Handbuch für professionelles Softwaredesign von Robert C. Martin sehr gut beschrieben:

Module hoher Ebenen sollten nicht von Modulen niedriger Ebenen abhängen. Beide sollten von Abstraktionen abhängen.

Bezogen auf das obige Beispiel ist das Modul der hohen Ebene der Funktionsblock FB_Controller. Dieser sollte nicht direkt auf Module niedriger Ebene zugreifen, in der Details enthalten sind. Die Module niedriger Ebene sind die einzelnen Lampentypen.

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

Die Details sind die einzelnen Methoden, die die jeweiligen Lampentypen anbieten. Im ersten Beispiel ist FB_Controller von den Details aller Lampentypen abhängig. Wird an einem Lampentyp eine Änderung vorgenommen, so muss auch der Controller angepasst werden.

Was genau wird durch das Dependency Inversion Principle umgedreht?

Im ersten Beispiel greift FB_Controller direkt auf die einzelnen Lampentypen zu. Dadurch ist FB_Controller (höhere Ebene) abhängig von den Lampentypen (niedrigere Ebene).

Das Dependency Inversion Principle invertiert diese Abhängigkeit. Hierzu wird eine zusätzliche Abstraktionsebene eingeführt. Die höhere Ebene legt fest, wie diese Abstraktionsebene aussieht. Die niederen Schichten müssen diese Vorgaben erfüllen. Dadurch ändert sich die Richtung der Abhängigkeiten.

Im obigen Beispiel wurde diese zusätzliche Abstraktionsebene durch die Kombination des abstrakten Funktionsblock FB_Lamp und der Schnittstelle I_Lamp umgesetzt.

Zusammenfassung

Bei dem Dependency Inversion Principle besteht die Gefahr des Overengineering. Nicht jede Kopplung sollte aufgelöst werden. Dort wo ein Austausch von Funktionsblöcken zu erwarten ist, kann das Dependency Inversion Principle eine große Hilfe sein. Weiter oben hatte ich das Beispiel einer Bibliothek genannt, in der verschiedene Funktionsblöcke untereinander abhängig sind. Will der Anwender der Bibliothek in diese Abhängigkeiten eingreifen, so würden feste Abhängigkeiten dieses verhindern.

Durch das Dependency Inversion Principle erhöht sich die Testbarkeit eines Systems. FB_Controller kann völlig unabhängig von den einzelnen Lampentypen getestet werden. Für die Unit-Tests wird ein FB erstellt, welcher von FB_Lamp abgeleitet wird. Dieser Dummy-FB, welcher nur Funktionen enthält die für die Tests von FB_Controller notwendig sind, wird auch als Mocking-Object bezeichnet. Jakob Sagatowski stellt in seinem Post Mocking objects in TwinCAT dieses Konzept vor.

In den nächsten Post werde ich das Beispielprogramm mit Hilfe des Single Responsibility Principle (SRP) analysieren und weiter optimieren.

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.

8 thoughts on “IEC 61131-3: SOLID – Das Dependency Inversion Principle”

  1. Vielen Dank fuer den Artikel, wie jeden anderen Artikel, sofort durchgelesen.
    Sehr informativ, aber werden wohl noch ein paar Durchgaenge notwendig, bis ich das verstanden habe.

  2. Hi Stefan,
    mal wieder ein toller Artikel. Freue mich auf die Fortsetzung.

    Fuer alle Interessierten kann ich noch folgende Posts zur weiterfuehrenden Anwendung der SOLID Prinzipien empfehlen:
    https://blogs.cuttingedge.it/steven/posts/2011/meanwhile-on-the-command-side-of-my-architecture/
    https://blogs.cuttingedge.it/steven/posts/2011/meanwhile-on-the-query-side-of-my-architecture/

    Ich habe die generischen Typen mit Hilfe von T_Arg substituiert. Verliert so aber natuerlich an Charme, da die automatische Service-Auswahl wegfaellt und die Gefahr beim Casten auf den entsprechenden Typen gross ist.
    Problematisch wird es, wie immer, bei lang laufenden (mehr als ein Zyklus) Prozessen. Hierfuer musste ich das einfache Interface um die ueblichen Busy, Error, Ready Signale erweitern.

Leave a comment