IEC 61131-3: SOLID – Das Open/Closed Principle

Vererbung ist eine beliebte Methode, um bestehende Funktionsblöcke wiederzuverwenden. Dadurch lassen sich Methoden und Eigenschaften hinzufügen oder bestehende Methoden überschreiben. Hierbei ist es nicht notwendig, den Quellcode des Basis-FB zur Verfügung zu haben. Software so zu designen, dass Erweiterungen möglich sind, ohne die vorhandene Software zu verändern, ist die Grundidee des Open/Closed Principle (OCP). Doch die Anwendung von Vererbung hat hierbei auch Nachteile. Der Einsatz von Schnittstellen minimiert diese Nachteile und bietet zusätzliche Vorteile.

Mit anderen Worten: Das Verhalten von Software sollte erweiterbar sein, ohne dass sie modifiziert werden muss. Angelehnt an das Beispiel aus den bisherigen Posts, soll ein Funktionsblock entwickelt werden, um Sequenzen für die Ansteuerung von Lampen zu verwalten. Anschließend wird der Funktionsblock um zusätzliche Funktionen erweitert. Anhand dieses Beispiels wird die Grundidee des Open/Closed Principle (OCP) genauer betrachtet.

Ausgangssituation

Zentraler Ausgangspunkt ist der Funktionsblock FB_SequenceManager. Dieser stellt über die Eigenschaft aSequence die einzelnen Schritte einer Sequenz zur Verfügung. Über die Methode Sort() kann die Liste nach verschiedenen Kriterien sortiert werden.

Die Eigenschaft aSequence ist ein Array und enthält Elemente vom Typ ST_SequenceItem.

PROPERTY PUBLIC aSequence : ARRAY [1..5] OF ST_SequenceItem

Um das Beispiel überschaubar zu halten, wird mit festen Arraygrenzen von 1 bis 5 gearbeitet. Die Array-Elemente sind vom Typ ST_SequenceItem und enthalten eine eindeutige Id (nId), den Ausgangswert (nValue) für die Lampen und die Dauer (nDuration) bis zum Umschalten auf den nächsten Ausgangswert.

TYPE ST_SequenceItem :
STRUCT
  nId         : UINT;
  nValue      : USINT(0..100);
  nDuration   : UINT;
END_STRUCT
END_TYPE

Auf sämtliche Methoden für die Bearbeitung der Sequenz wurde im Rahmen dieses Beispiels verzichtet. Allerdings enthält das Beispiel die Methode Sort(), um die Liste nach verschiedenen Kriterien zu sortieren.

METHOD PUBLIC Sort
VAR_INPUT
  eSortedOrder  : E_SortedOrder;
END_VAR

Die Liste kann aufsteigend nach nId oder nValue sortiert werden.

TYPE E_SortedOrder :
(
  Id,
  Value
);
END_TYPE

In der Methode Sort() wird durch den Eingangsparameter eSortedOrder entschieden, ob nach nId oder nach nValue sortiert werden soll.

CASE eSortedOrder OF
  E_SortedOrder.Id:
    // Sort the list by nId
    // …
  E_SortedOrder.Value:
    // Sort the list by nValue
    // …
END_CASE

Bei dem Beispiel handelt es sich um eine einfache monolithische Anwendung, welche in kurzer Zeit erstellt werden kann, um die gewünschten Anforderungen zu erfüllen.

Das UML-Diagramm zeigt recht deutlich den monolithischen Aufbau der Anwendung:

Dabei wurde allerdings nicht berücksichtigt, mit welchem Aufwand zukünftige Erweiterungen realisierbar sind.

Beispiel 1 (TwinCAT 3.1.4024) auf GitHub

Erweiterung der Implementierung

Die Anwendung soll so erweitert werden, dass nicht nur nach nId und nValue sortiert werden kann, sondern auch zusätzlich nach nDuration. Bisher wurde die Liste immer aufsteigend sortiert. Eine absteigende Sortierung ist ebenfalls gewünscht.

Wie lässt sich unser Beispiel anpassen, damit die beiden Kundenwünsche erfüllt werden?

Ansatz 1: Quick & Dirty

Ein Ansatz besteht darin, die vorhandene Methode Sort() einfach zu erweitern, so dass diese auch nach nDuration sortieren kann. Hierzu wird E_SortedOrder um das Feld eDuration erweitert.

TYPE E_SortedOrder :
(
  Id,
  Value,
  Duration
);
END_TYPE

Zusätzlich wird noch ein Parameter benötigt, der angibt ob in aufsteigender oder in absteigender Reihenfolge sortiert werden soll:

TYPE E_SortedDirection :
(
  Ascending,
  Descending
);
END_TYPE

Somit besitzt die Methode Sort() jetzt zwei Parameter:

METHOD PUBLIC Sort
VAR_INPUT
  eSortedOrder      : E_SortedOrder;
  eSortedDirection  : E_SortedDirection;
END_VAR

Die Methode Sort() enthält jetzt zwei ineinander verschachtelte CASE-Anweisungen. Die Äußere für die Auswahl der Sortierrichtung und die Innere für das Element nach dem sortiert wird.

CASE eSortedDirection OF
  E_SortedDirection.Ascending:
    CASE eSortedOrder OF
      E_SortedOrder.Id:
        // Sort the list by nId in ascending order
        // …
      E_SortedOrder.Value:
        // Sort the list by nValue in ascending order
        // …
      E_SortedOrder.Duration:
        // Sort the list by nDuration in ascending order
        // …
    END_CASE
  E_SortedDirection.Descending:
    CASE eSortedOrder OF
      E_SortedOrder.Id:
        // Sort the list by nId in descending order
        // …
      E_SortedOrder.Value:
        // Sort the list by nValue in descending order
        // …
      E_SortedOrder.Duration:
        // Sort the list by nDuration in descending order
        // …
    END_CASE
  END_CASE
END_CASE

Dieser Ansatz ist schnell umzusetzen. Bei einer kleinen Anwendung, wo der Quellcode nicht sehr umfangreich ist, durchaus ein guter Ansatz. Allerdings muss der Quellcode zur Verfügung stehen, damit diese Erweiterungen überhaupt möglich sind. Außerdem muss sichergestellt sein, dass FB_SequenceManager nicht mit anderen Projekten geteilt wird, z. B. durch eine SPS-Bibliothek in der FB_SequenceManager enthalten ist. Da bei der Methode Sort() ein Parameter hinzugekommen ist, hat sich die Signatur geändert. Programmteile, die die Methode mit einem Parameter aufrufen, lassen sich dadurch nicht mehr compilieren.

Durch das UML-Diagramm ist gut zu erkennen, dass sich die Struktur nicht geändert hat. Es ist weiterhin eine sehr monolithische Anwendung:

Beispiel 2 (TwinCAT 3.1.4024) auf GitHub

Ansatz 2: Vererbung

Ein weiterer Ansatz, um die Anwendung mit den gewünschten Funktionen zu erweitern, ist der Einsatz von Vererbung. Dadurch lassen sich Funktionsblöcke erweitern, ohne dass der vorhandene Funktionsblock verändert werden muss.

Hierzu wird als erstes ein neuer Funktionsblock angelegt, der von FB_SequenceManager erbt:

FUNCTION_BLOCK PUBLIC FB_SequenceManagerEx EXTENDS FB_SequenceManager

Der neue Funktionsblock erhält die Methode SortEx(), mit den beiden Parametern, welche die gewünschte Sortierung vorgibt:

METHOD PUBLIC SortEx : BOOL
VAR_INPUT
  eSortedOrder      : E_SortedOrderEx;
  eSortedDirection  : E_SortedDirection;
END_VAR

Auch hier wird wieder der Datentyp E_SortedDirection hinzugefügt, der angibt ob in aufsteigender oder in absteigender Reihenfolge sortiert werden soll:

TYPE E_SortedDirection :
(
  Ascending,
  Descending
);
END_TYPE

Statt E_SortedOrder zu erweitern, wir ein neuer Datentyp angelegt:

TYPE E_SortedOrderEx :
(
  Id,
  Value,
  Duration
);
END_TYPE

In der Methode SortEx() können jetzt die gewünschten Sortierungen umgesetzt werden.

Bei der Sortierung in aufsteigender Reihenfolge ist der Zugriff auf die Methode Sort() des Basis-FBs (FB_SequenceManager) möglich. Dadurch ist eine erneute Implementierung der schon vorhandenen Sortieralgorithmen nicht erforderlich. Nur die zusätzliche Sortierung muss hinzugefügt werden:

CASE eSortedOrder OF
  E_SortedOrderEx.Id:
    SUPER^.Sort(E_SortedOrder.Id);
  E_SortedOrderEx.Value:
    SUPER^.Sort(E_SortedOrder.Value);
  E_SortedOrderEx.Duration:
    // Sort the list by nDuration in ascending order
    // …
END_CASE

Die Sortierung in absteigender Reihenfolge muss allerdings komplett programmiert werden, da hier nicht auf bestehende Methoden zurückgegriffen werden kann.

Erbt ein Funktionsblock von einem anderen Funktionsblock, so erhält der neue Funktionsblock den Funktionsumfang des Basis-FBs. Durch zusätzliche Methoden und Eigenschaften kann dieser erweitert werden, ohne die Notwendigkeit, den Basis-FB zu verändern (offen für Erweiterungen). Durch den Einsatz von Bibliotheken kann der Quellcode auch komplett vor Veränderung geschützt werden (geschlossen gegen Modifikationen).

Vererbung ist somit eine Methode, um das Open/Closed Principle (OCP) umzusetzen.

Beispiel 3 (TwinCAT 3.1.4024) auf GitHub

Dieser Ansatz hat allerdings zwei Nachteile:

Durch den übermäßigen Einsatz von Vererbung können komplexe Hierarchien entstehen. Ein abgeleiteter FB ist fest an seinen Basis-FB gebunden. Wird der Basis-FB um weitere Methoden oder Eigenschaften erweitert, so erbt auch jeder abgeleitete FB diese Elemente (wenn diese PUBLIC sind), auch dann, wenn der abgeleitete FB diese Elemente nach Außen gar nicht anbieten möchte.

Eine Erweiterung durch Vererbung ist unter Umständen nur dann möglich, wenn die abgeleiteten Funktionsblöcke auf die internen Zustände des Basis-FBs Zugriff haben. Der Zugriff auf diese internen Elemente kann durch PROTECTED gekennzeichnet werden. Somit können nur abgeleitete Funktionsblöcke darauf zugreifen.

Im obigen Beispiel konnten nur deshalb die Sortieralgorithmen hinzugefügt werden, weil der Setter der Eigenschaft aSequence als PROTECTED deklariert wurde. Wäre ein Schreibzugriff auf die Eigenschaft aSequence nicht möglich, so könnte der abgeleitete Funktionsblock die Liste nicht verändern und somit auch nicht sortieren.

Dieses bedeutet aber, dass der Entwickler dieses Funktionsblocks immer zwei Anwendungsfälle berücksichtigen muss. Zum einen den Anwender, der die öffentlichen Methoden und Eigenschaften verwendet. Zusätzlich aber noch den Anwender, der den Funktionsblock als Basis-FB verwendet und auch über die PROTECTED Elemente neue Funktionalitäten hinzufügt. Doch welche internen Elemente sollen als PROTECTED markiert werden? Auch müssen diese Elemente dokumentiert werden, damit eine Anwendung überhaupt möglich ist.

Ansatz 3: zusätzliche Schnittstelle

Ein weiterer Lösungsansatz ist der Einsatz von Schnittstellen anstatt der Vererbung. Allerdings muss dieses direkt bei dem Design berücksichtigt werden.

Soll FB_SequenceManager so entworfen werden, dass der Anwender des Funktionsblocks beliebige Sortieralgorithmen hinzufügen kann, so sollte der Code für das Sortieren der Liste aus FB_SequenceManager entfernt werden. Der Zugriff aus dem Sortieralgorithmus auf die Liste sollte stattdessen über eine Schnittstelle erfolgen.

Bezogen auf unser Beispiel wird die Schnittstelle I_SequenceSortable hinzugefügt. Diese Schnittstelle enthält die Methode SortList(), welche eine Referenz auf die zu sortierende Liste enthält.

METHOD SortList
VAR_INPUT
  refSequence  : REFERENCE TO ARRAY [1..5] OF ST_SequenceItem;
END_VAR

Als nächstes werden die Funktionsblöcke angelegt, in denen die jeweiligen Sortieralgorithmen hinterlegt sind. Jeder dieser Funktionsblöcke implementiert die Schnittstelle I_SequenceSortable. Als Beispiel wird hier der Funktionsblock gezeigt, der nach nId aufsteigend sortiert.

FUNCTION_BLOCK PUBLIC FB_SequenceSortedByIdAscending IMPLEMENTS I_SequenceSortable

Der Name des Funktionsblocks ist beliebig, entscheidend ist die Implementierung der Schnittstelle I_SequenceSortable. Dadurch ist sichergestellt das FB_SequenceSortedByIdAscending die Methode SortList() enthält. In der Methode SortList() wird der eigentliche Sortieralgorithmus implementiert.

METHOD SortList
VAR_INPUT
  refSequence  : REFERENCE TO ARRAY [1..5] OF ST_SequenceItem;
END_VAR
// Sort the list by nId in ascending order
// …

FB_SequenceManager erhält in der Methode Sort() einen Parameter vom Typ I_SequenceSortable. Wird die Methode Sort() aufgerufen, so wird ein Funktionsblock (z.B. FB_SequenceSortedByIdAscending) übergeben, welcher die Schnittstelle I_SequenceSortable implementiert und somit auch die Methode SortList() enthält. In der Methode Sort() von FB_SequenceManager wird SortList() aufgerufen und eine Referenz der Liste aSequence übergeben.

METHOD PUBLIC Sort
VAR_INPUT
  ipSequenceSortable  : I_SequenceSortable;
END_VAR
IF (ipSequenceSortable <> 0) THEN
  ipSequenceSortable.SortList(THIS^._aSequence);
END_IF

Dadurch erhält der Funktionsblock mit dem implementierten Sortieralgorithmus die Referenz auf die zu sortierende Liste.

Für jeden gewünschten Sortieralgorithmus wird ein Funktionsblock erstellt. Somit stehen uns zum einen FB_SequenceManager mit der Methode Sort() zur Verfügung und zum anderen die Funktionsblöcke, welche die Schnittstelle I_SequenceSortable implementieren und die Sortieralgorithmen enthalten.

Wird von FB_SequenceManager die Methode Sort() aufgerufen, so wird ein Funktionsblock übergeben (hier FB_SequenceSortedByIdAscending). Dieser Funktionsblock enthält die Schnittstelle I_SequenceSortable über die anschließend die Methode SortList() aufgerufen wird.

PROGRAM MAIN
VAR
  fbSequenceManager              : FB_SequenceManager;
  fbSequenceSortedByIdAscending  : FB_SequenceSortedByIdAscending;
  // …
END_VAR
fbSequenceManager.Sort(fbSequenceSortedByIdAscending);
// …

Bei diesem Ansatz wird keine Vererbung angewendet. Die Funktionsblöcke für die Sortieralgorithmen könnten ihre eigene Vererbungshierarchie anwenden, falls dieses gefordert wird. Ebenfalls könnten die Funktionsblöcke weitere Schnittstellen implementieren, da das Implementieren mehrerer Schnittstellen möglich ist.

Datenhaltung (Liste) und Datenverarbeitung (Sortierung) sind durch den Einsatz der Schnittstelle klar voneinander getrennt. Die Eigenschaft aSequence benötigt keinen Schreibzugriff. Auch sind Zugriffe auf interne Variablen von FB_SequenceManager nicht notwendig.

Auch die beiden Datentypen E_SortedOrder und E_SortedDirection werden nicht benötigt. Die Auswahl der Sortierung wird ausschließlich durch den Funktionsblock bestimmt, der an Sort() übergeben wird.

Wird eine neue Sortierung hinzugefügt, so ist es nicht notwendig schon vorhandene Elemente zu verändern oder anzupassen.

Beispiel 4 (TwinCAT 3.1.4024) auf GitHub

Analyse der Optimierung

Es gibt verschiedene Techniken, um einen bestehenden Funktionsblock funktional zu erweitern, ohne diesen verändern zu müssen. Neben der Vererbung, eine der Hauptmerkmale der objektorientierten Programmierung (OOP), stellen Schnittstellen evtl. eine bessere Alternative dar.

Bei der Verwendung von Schnittstellen ist die Entkopplung größer. Allerdings müssen beim Softwareentwurf die einzelnen Schnittstellen implementiert werden. Es muss also im Vorfeld überlegt werden, welche möglichen Bereiche durch Schnittstellen abstrahiert werden und welche nicht.

Aber auch bei Verwendung von Vererbung muss bei der Entwicklung eines Funktionsblocks überlegt werden, welche internen Elemente den abgeleiteten Funktionsblöcken per PROTECTED angeboten werden.

Die Definition des Open/Closed Principle

Das Open/Closed Principle (OCP) wurde im Jahr 1988 von Bertrand Meyer formuliert und besagt:

Eine Softwareentität sollte offen für Erweiterungen, aber zugleich auch geschlossen gegenüber Modifikationen sein.

Softwareentität: Damit ist eine Klasse, Funktionsblock, Module, Methode, Service, … gemeint.

Offen: Das Verhalten von Softwaremodule sollte erweiterbar sein.

Geschlossen: Eine Erweiterbarkeit soll nicht dadurch erreicht werden, indem bestehende Software verändert wird.

Als das Open/Closed Principle (OCP) von Bertrand Meyer Ende der 80er definiert wurde, lag der Fokus auf der Programmiersprache C++. Er nutzte die in der objektorientierten Welt bekannte Vererbung. Die damals noch junge Disziplin der Objektorientierung versprach große Verbesserungen bei Wiederverwendbarkeit und Wartbarkeit dadurch, dass konkrete Klassen als Basisklassen für neue Klassen verwendet werden können.

Als Robert C. Martin in den 90er Jahren das Prinzip von Bertrand Meyer übernahm, setzte er es technisch anders um. C++ ermöglicht die Verwendung von Mehrfachvererbung, während in neueren Programmiersprachen Mehrfachvererbung eher selten anzutreffen ist. Aus diesem Grund setzte Robert C. Martin den Fokus auf die Verwendung von Schnittstellen. Weitere Informationen hierzu sind in dem Buch (Amazon-Werbelink *) Clean Architecture: Das Praxis-Handbuch für professionelles Softwaredesign zu finden.

Zusammenfassung

Die Einhaltung des Open/Closed Principle (OCP) birgt allerdings die Gefahr des Overengineering. Die Möglichkeit für Erweiterungen sollte nur dort implementiert werden, wo sie konkret benötigt wird. Eine Software lässt sich nicht so designen, dass jede denkbare Erweiterung umgesetzt werden kann, ohne dass Anpassungen an dem Quellcode notwendig sind.

Damit ist meine Serie über die SOLID-Prinzipien abgeschlossen. Neben den SOLID-Prinzipien gibt es allerdings noch weitere Prinzipien, wie z.B. Keep It Simple, Stupid (KISS), Don’t Repeat Yourself (DRY), Law Of Demeter (LOD) oder You Ain‘t Gonna Need It (YAGNI). All diese Prinzipen haben das gemeinsame Ziel, die Wartbarkeit und die Wiederverwendbarkeit von Software zu verbessern.

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.

Leave a comment