IEC 61131-3: Das Dekorierer Pattern

Mit Hilfe des Dekorierer Pattern können neue Funktionsblöcke auf Basis bestehender Funktionsblöcke entwickelt werden, ohne das Prinzip der Vererbung überzustrapazieren. In dem folgenden Post werde ich den Einsatz dieses Pattern an Hand eines einfachen Beispiels vorstellen.

Das Beispiel soll für verschiedene Pizzen den Preis (GetPrice()) berechnen. Auch wenn dieses Beispiel keinen direkten Bezug zur Automatisierungstechnik hat, so wird das Grundprinzip des Dekorierer-Pattern recht gut beschrieben. Genauso gut könnten die Pizzen auch durch Pumpen, Zylinder oder Achsen ersetzt werden.

Erste Variante: Der ‚Super-Funktionsblock‘

In dem Beispiel gibt es zwei Grundsorten; American Style und Italian Style. Jede dieser Grundsorten kann mit den Beilagen Salami (Salami), Käse (Cheese) und Brokkoli (Broccoli) versehen werden.

Der naheliegendste Ansatz könnte darin bestehen, die gesamte Funktionalität in einem Funktionsblock zu legen.

Eigenschaften legen die Zusammensetzung der Pizza fest, während eine Methode die gewünschte Berechnung durchführt.

Picture01

Des Weiteren wird FB_init() so erweitert, dass schon bei der Deklaration der Instanzen die Zutaten festgelegt werden. Somit lassen sich verschiedene Pizzavarianten recht einfach erstellen.

fbAmericanSalamiPizza : FB_Pizza(ePizzaStyle := E_PizzaStyle.eAmerican,
                                 bHasBroccoli := FALSE,
                                 bHasCheese := TRUE,
                                 bHasSalami := TRUE);
fbItalianVegetarianPizza : FB_Pizza(ePizzaStyle := E_PizzaStyle.eItalian,
                                    bHasBroccoli := TRUE,
                                    bHasCheese := FALSE,
                                    bHasSalami := FALSE);

Die Methode GetPrice() wertet diese Informationen aus und gibt den geforderten Wert zurück:

METHOD PUBLIC GetPrice : LREAL

IF (THIS^.eStyle = E_PizzaStyle.eItalian) THEN
  GetPrice := 4.5;
ELSIF (THIS^.eStyle = E_PizzaStyle.eAmerican) THEN
  GetPrice := 4.2;
ELSE
  GetPrice := 0;
  RETURN;
END_IF
IF (THIS^.bBroccoli) THEN
  GetPrice := GetPrice + 0.8;
END_IF
IF (THIS^.bCheese) THEN
  GetPrice := GetPrice + 1.1;
END_IF
IF (THIS^.bSalami) THEN
  GetPrice := GetPrice + 1.4;
END_IF

Eigentlich eine ganz solide Lösung. Doch wie so häufig in der Softwareentwicklung, ändern sich die Anforderungen. So kann die Einführung neuer Pizzen, weitere Zutaten erfordern. Der Funktionsblock FB_Pizza wächst kontinuierlich an und somit auch die Komplexität. Auch die Tatsache, dass sich alles in einem Funktionsblock befindet, macht es schwierig die Endwicklung auf mehrere Personen zu verteilen.

Beispiel 1 (TwinCAT 3.1.4022) auf GitHub

Zweite Variante: Die ‚Vererbungshölle‘

Im zweiten Ansatz wird für jede Pizza-Variante ein separater Funktionsblock erstellt. Zusätzlich definiert eine Schnittstelle (I_Pizza) alle gemeinsamen Eigenschaften und Methoden. Da von allen Pizzen der Preis ermittelt werden soll, enthält die Schnittstelle die Methode GetPrice().

Diese Schnittstelle implementieren die beiden Funktionsblöcke FB_PizzaAmericanStyle und FB_PizzaItalianStyle. Somit ersetzen die Funktionsblöcke die Aufzählung E_PizzaStyle und sind die Basis für alle weiteren Pizzen. Die Methode GetPrice() gibt bei diesen beiden FBs den jeweiligen Basispreis zurück.

Darauf aufbauend werden die einzelnen Pizzen, mit den unterschiedlichen Zutaten definiert. So enthält die Pizza Margherita zusätzlich Käse (Cheese) und Tomaten (Tomato). Die Pizza Salami benötigt außerdem noch Salami. Somit erbt der FB für die Pizza Salami von dem FB der Pizza Margherita.

Die Methode GetPrice() greift immer mit dem Super-Zeiger auf die darunter liegende Methode zu und addiert den Betrag für die eigenen Zutaten hinzu, vorausgesetzt diese sind vorhanden.

METHOD PUBLIC GetPrice : LREAL

GetPrice := SUPER^.GetPrice();
IF (THIS^.bSalami) THEN
  GetPrice := GetPrice + 1.4;
END_IF

Daraus ergibt sich eine Vererbungshierarchie, welche die Abhängigkeiten der einzelnen Pizza-Varianten wiederspiegelt.

Picture02

Auch diese Lösung sieht auf den ersten Blick sehr elegant aus. Ein Vorteil ist die gemeinsame Schnittstelle. Jeder Instanz eines der Funktionsblöcke kann somit einem Interface-Pointer vom Typ I_Pizza zugewiesen werden. Dieses ist z.B. bei Methoden hilfreich, da über einen Parameter vom Typ I_Pizza jede Pizza-Variante übergeben werden kann.

Auch können verschiedene Pizzen in ein Array abgelegt und der gemeinsame Preis berechnet werden:

PROGRAM MAIN
VAR
  fbItalianPizzaPiccante     : FB_ItalianPizzaPiccante;
  fbItalianPizzaMozzarella   : FB_ItalianPizzaMozzarella;
  fbItalianPizzaSalami       : FB_ItalianPizzaSalami;
  fbAmericanPizzaCalifornia  : FB_AmericanPizzaCalifornia;
  fbAmericanPizzaNewYork     : FB_AmericanPizzaNewYork;
  aPizza                     : ARRAY [1..5] OF I_Pizza;
  nIndex                     : INT;
  lrPrice                    : LREAL;
END_VAR

aPizza[1] := fbItalianPizzaPiccante;
aPizza[2] := fbItalianPizzaMozzarella;
aPizza[3] := fbItalianPizzaSalami;
aPizza[4] := fbAmericanPizzaCalifornia;
aPizza[5] := fbAmericanPizzaNewYork;

lrPrice := 0;
FOR nIndex := 1 TO 5 DO
  lrPrice := lrPrice + aPizza[nIndex].GetPrice();
END_FOR

Trotzdem weist dieser Ansatz verschiedene Nachteile auf.

Was ist, wenn die Menükarte angepasst und sich die Zusammensetzung einer Pizza dadurch ändert? Angenommen die Pizza Salami soll auch Pilze (Mushroom) erhalten, dann erbt die Pizza Piccante ebenfalls die Pilze, obwohl dieses nicht gewünscht wird. Die gesamte Vererbungshierarchie muss angepasst werden. Durch die festen Beziehungen über die Vererbung, wird die Lösung unflexibel.

Wie kommt das System mit individuellen Kundenwüschen zurecht? Wie z.B. doppelter Käse oder Zutaten, die eigentlich für eine bestimmte Pizza nicht vorgesehen sind.

Befinden sich die Funktionsblöcke in einer Bibliothek, so wären diese Anpassungen nur eingeschränkt möglich.

Vor allen Dingen besteht die Gefahr, dass bestehende Anwendungen, die mit einem älteren Stand der Bibliothek kompiliert wurden, sich nicht mehr korrekt verhalten.

Beispiel 2 (TwinCAT 3.1.4022) auf GitHub

Dritte Variante: Das Dekorierer Pattern

Zur Optimierung der Lösung sind einige Entwurfsprinzipien der objektorientierten Softwareentwicklung hilfreich. Das Einhalten dieser Prinzipen soll helfen, die Softwarestruktur sauber zu halten.

Open Closed Principle

Offen für Erweiterungen: Das bedeutet, dass sich durch die Verwendung von Erweiterungsmodulen die ursprüngliche Funktionalität eines Moduls verändern lässt. Dabei enthalten die Erweiterungsmodule nur die Anpassungen von der ursprünglichen Funktionalität.

Geschlossen für Änderungen: Das bedeutet, dass keine Änderungen des Moduls nötig sind, um es zu erweitern. Das Modul bietet definierte Erweiterungspunkte, über die sich die Erweiterungsmodule anknüpfen lassen.

Identifiziere jene Aspekte, die sich ändern und trenne diese von jenen, die konstant bleiben

Wie werden die Funktionsblöcke aufgeteilt, damit Erweiterungen an möglichst wenigen Stellen notwendig sind?

Bisher wurden die beiden Pizza-Grundsorten American Style und Italian Style durch Funktionsblöcke abgebildet. Warum also nicht auch die Zutaten als Funktionsblöcke definieren? Damit würden wir das Open Closed Principle erfüllen. Unsere Grundsorten und Zutaten sind konstant und somit für Veränderungen geschlossen. Allerdings müssen wir dafür sorgen, dass jede Grundsorte mit beliebigen Zutaten erweitert werden kann. Die Lösung wäre somit offen für Erweiterungen.

Das Dekorierer Pattern verlässt sich beim Erweitern des Verhaltens nicht auf Vererbung. Vielmehr kann jede Beilage auch als Hülle (Wrapper) verstanden werden. Diese Hülle legt sich um ein bereits bestehendes Gericht. Damit dieses möglich ist, implementieren auch die Beilagen das Interface I_Pizza. Jede Beilage enthält des Weiteren ein Interface-Pointer auf die darunterliegende Hülle.

Die Pizza-Grundsorte und die Beilagen werden hierdurch ineinander verschachtelt. Wird die Methode GetPrice() von der äußersten Hülle aufgerufen, so delegiert diese den Aufruf an die darunterliegende Hülle weiter und addiert anschließend seinen Preis hinzu. Das geht solange, bis die Aufrufkette an der Pizza-Grundsorte angekommen ist, welche den Basispreis zurückliefert.

Picture03

Die innerste Hülle gibt ihren Basispreis zurück:

METHOD GetPrice : LREAL

GetPrice := 4.5;

Jede weitere Hülle (Dekorierer) addiert auf die darunterliegende Hülle den gewünschten Aufschlag:

METHOD GetPrice : LREAL

IF (THIS^.ipSideOrder <> 0) THEN
  GetPrice := THIS^.ipSideOrder.GetPrice() + 0.9;
END_IF

Damit die jeweils darunterliegende Hülle an den Baustein übergeben werden kann, wird die Methode FB_init() um einen zusätzlichen Parameter vom Typ I_Pizza erweitert. Somit werden schon bei der Deklaration der FB-Instanzen die gewünschten Zutaten festgelegt.

METHOD FB_init : BOOL
VAR_INPUT
  bInitRetains	: BOOL; // if TRUE, the retain variables are initialized (warm start / cold start)
  bInCopyCode	: BOOL; // if TRUE, the instance afterwards gets moved into the copy code (online change)
  ipSideOrder	: I_Pizza;
END_VAR

THIS^.ipSideOrder := ipSideOrder;

Damit das Durchlaufen der einzelnen Hüllen besser erkennbar wird, habe ich die Methode GetDescription() vorgesehen. Jede Hülle erweitert den bestehenden String um eine kurze Beschreibung.

Picture04

Im folgenden Beispiel, wird die Zusammenstellung der Pizza direkt bei der Deklaration angegeben:

PROGRAM MAIN
VAR
  // Italian Pizza Margherita (via declaration)
  fbItalianStyle : FB_PizzaItalianStyle;
  fbTomato       : FB_DecoratorTomato(fbItalianStyle);
  fbCheese       : FB_DecoratorCheese(fbTomato);
  ipPizza        : I_Pizza := fbCheese;

  fPrice         : LREAL;
  sDescription   : STRING;
END_VAR

fPrice := ipPizza.GetPrice(); // output: 6.5
sDescription := ipPizza.GetDescription(); // output: 'Pizza Italian Style: - Tomato - Cheese'

Zwischen den Funktionsblöcken besteht keine feste Verbindung. Neue Pizzasorten können definiert werden, ohne dass Veränderungen an bestehenden Funktionsblöcken notwendig sind. Die Vererbungshierarchie legt nicht die Abhängigkeiten zwischen den einzelnen Pizza-Varianten fest.

Picture05

Zusätzlich kann der Interface-Pointer auch per Eigenschaft übergeben werden. Somit ist eine Zusammensetzung oder eine Änderung der Pizza auch zur Laufzeit des Programms möglich.

PROGRAM MAIN
VAR
  // Italian Pizza Margherita (via runtime)
  fbItalianStyle  : FB_PizzaItalianStyle;
  fbTomato        : FB_DecoratorTomato(0);
  fbCheese        : FB_DecoratorCheese(0);
  ipPizza         : I_Pizza;

  bCreate         : BOOL;
  fPrice          : LREAL;
  sDescription    : STRING;
END_VAR

IF (bCreate) THEN
  bCreate := FALSE;
  fbTomato.ipDecorator := fbItalianStyle;
  fbCheese.ipDecorator := fbTomato;
  ipPizza := fbCheese;
END_IF
IF (ipPizza <> 0) THEN
  fPrice := ipPizza.GetPrice(); // output: 6.5
  sDescription := ipPizza.GetDescription(); // output: 'Pizza Italian Style: - Tomato - Cheese'
END_IF

Auch können in jedem Funktionsblock Besonderheiten eingebaut werden. Dabei kann es sich um zusätzliche Eigenschaften, aber auch um weitere Methoden handeln.

Der Funktionsblock für die Tomaten soll optional auch als Bio-Tomate angeboten werden. Eine Möglichkeit ist natürlich das Anlegen eines neuen Funktionsblocks. Das ist dann notwendig, wenn der vorhandene Funktionsblock nicht erweiterbar ist (z.B. weil er sich in einer Bibliothek befindet). Ist diese Anforderung aber schon vor der ersten Freigabe bekannt, so kann dieses unmittelbar berücksichtigt werden.

Der Funktionsblock erhält in der Methode FB_init() einen zusätzlichen Parameter.

METHOD FB_init : BOOL
VAR_INPUT
  bInitRetains		: BOOL; // if TRUE, the retain variables are initialized (warm start / cold start)
  bInCopyCode		: BOOL; // if TRUE, the instance afterwards gets moved into the copy code (online change)
  ipSideOrder		: I_Pizza;
  bWholefoodProduct	: BOOL;
END_VAR

THIS^.ipSideOrder := ipSideOrder;
THIS^.bWholefood := bWholefoodProduct;

Auch dieser Parameter könnte über eine Eigenschaft zur Laufzeit änderbar sein. Bei der Berechnung des Preises wird die Option wie gewünscht berücksichtigt.

METHOD GetPrice : LREAL

IF (THIS^.ipSideOrder <> 0) THEN
  GetPrice := THIS^.ipSideOrder.GetPrice() + 0.9;
  IF (THIS^.bWholefood) THEN
    GetPrice := GetPrice + 0.3;
  END_IF
END_IF

Eine weitere Optimierung kann die Einführung eines Basis-FB (FB_Decorator) für alle Decorator-FBs sein.

Picture06

Beispiel 3 (TwinCAT 3.1.4022) auf GitHub

Definition

In dem Buch (Amazon-Werbelink *) Design Patterns: Entwurfsmuster als Elemente wiederverwendbarer objektorientierter Software von Erich Gamma, Richard Helm, Ralph E. Johnson und John Vlissides wird dieses wie folgt ausgedrückt:

Das Dekorierer Pattern ist eine flexible Alternative zur Unterklassenbildung […], um eine Klasse um zusätzliche Funktionalitäten zu erweitern.

Implementierung

Der entscheidende Punkt beim Dekorierer Pattern ist, dass beim Erweitern eines Funktionsblock nicht Vererbung zum Einsatz kommt. Soll das Verhalten ergänzt werden, so werden Funktionsblöcke ineinander verschachtelt; sie werden dekoriert.

Zentrale Komponente ist die Schnittstelle IComponent. Diese Schnittstelle implementieren die Funktionsblöcke, die dekoriert werden sollen (Componnent).

Die Funktionsblöcke, die als Dekorierer dienen (Decorator), implementieren ebenfalls die Schnittstelle IComponent. Zusätzlich enthalten diese aber auch eine Referenz (Interface-Pointer component) auf einen weiteren Dekorierer (Decorator) oder auf den Basis-Funktionsblock (Component).

Der äußerste Dekorierer repräsentiert somit den Basis-Funktionsblock, erweitert um die Funktionen der Dekorierer. Die Methode Operation() wird durch alle Funktionsblöcke durchgereicht. Wobei jeder Funktionsblock beliebige Funktionalitäten hinzufügen darf.

Dieser Ansatz bringt einige Vorteile mit sich:

  • Der ursprüngliche Funktionsblock (Component) weiß nichts von den Ergänzungen (Decorator). Es ist nicht notwendig diesen zu erweitern oder anzupassen.
  • Die Dekorierer sind unabhängig voneinander und können auch bei anderen Anwendungen eingesetzt werden.
  • Die Dekorierer können beliebig miteinander kombiniert werden.
  • Ein Funktionsblock kann somit deklarativ oder auch zur Laufzeit sein Verhalten ändern.
  • Ein Client, der über die Schnittstelle IComponent auf den Funktionsblock zugreift, kann auf die gleiche Weise auch mit einem dekorierten Funktionsblock umgehen. Der Client muss nicht angepasst werden; er wird wiederverwendbar.

Aber auch einige Nachteile sind zu beachten:

  • Die Anzahl der Funktionsblöcke kann deutlich zunehmen, Dieses macht die Einarbeitung in eine bestehende Bibliothek aufwendiger.
  • Der Client erkennt nicht, ob es sich um die ursprüngliche Basis-Komponente handelt (wenn über die Schnittstelle IComponent darauf zugegriffen wird), oder ob diese durch Dekorierer erweitert wurden. Das kann ein Vorteil sein (siehe oben), aber auch zu Problemen führen.
  • Durch die langen Aufrufketten wird die Fehlersuche erschwert. Auch können sich die langen Aufrufketten negativ auf die Performanz der Applikation auswirken.

UML Diagramm

Picture07

Bezogen auf das obige Beispiel ergibt sich folgende Zuordnung:

ClientMAIN
IComponentI_Pizza
Operation()GetPrice(), GetDescription()
DecoratorFB_DecoratorCheese, FB_DecoratorSalami, FB_DecoratorTomato
AddedBehavior()bWholefoodProduct
componentipSideOrder
ComponentFB_PizzaItalianStyle, FB_PizzaAmericanStyle

Anwendungsbeispiele

Das Dekorierer Pattern ist sehr häufig in Klassen wiederzufinden, die für die Bearbeitung von Datenstreams zuständig sind. Dieses betrifft sowohl die Java Standardbibliothek, als auch das Microsoft .NET Framework.

So gibt es im .NET Framework die Klasse System.IO.Stream. Von dieser Klasse erben u.a. System.IO.FileStream und System.IO.MemoryStream. Beide Unterklassen enthalten aber auch eine Instanz von Stream. Viele Methoden und Eigenschaften von FileStream und MemoryStream greifen auf diese Instanz zu. Man kann auch sagen: Die Unterklassen FileStream und MemoryStream dekorieren Stream.

Weitere Anwendungsfälle sind Bibliotheken zur Erstellung von grafischen Oberflächen. Dazu zählt WPF von Microsoft als auch Swing für Java.

Ein Textfeld (TextBox) und ein Rahmen (Border) werden ineinander verschachtelt; das Textfeld wird mit dem Rahmen dekoriert. Der Rahmen (mit dem Textfeld) wird anschließend an die Page übergeben.

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.

2 thoughts on “IEC 61131-3: Das Dekorierer Pattern”

  1. Hallo Stefan,

    vielen Dank für einen weiteren, großartigen Artikel.
    Mit deiner Design-Pattern-Reihe habe ich den Spaß am Programmieren wiedergefunden. Menschen wie du und Jakob bringen stehengebliebene Denkmuster um Jahre vorwärts.

    Was mir dabei jedoch aufgefallen ist, ist wie schwierig es ist diese abzulegen. Nach der Veröffentlichung des State-Pattern-Artikels war ich erstmal voller Euphorie. Als ich jedoch anfing dieses (und die anderen Pattern) in der Praxis anzuwenden, bin ich schnell an meine Grenzen gestoßen. Nach dem Studium unzähliger Lektüre, fängt es jetzt erst so langsam an eine Struktur zu ergeben, einfach weil die Pattern für sich betrachtet zwar gekapselt sind, für eine sinnvolle Anwendung sind sie jedoch nur ein kleiner Teil vieler weiterer Paradigmen und Regeln. Es ist ein schwieriger Weg in die Welt des TDDs, XPs, CIs usw. Macht aber zum Glück auch wahnsinnig viel Spaß.

    1. Hallo Maxim,

      freud mich sehr, dass meine Artikel dir weiterhelfen. Danke für dein Lob.
      Man muss wirklich eine Weile probieren und nicht immer ist das Ergebnis wie erhofft.
      Da hilft manchmal nur weiter probieren.

      Stefan

Leave a comment