With the help of the decorator pattern, new function blocks can be developed on the basis of existing function blocks without overstraining the principle of inheritance. In the following post, I will introduce the use of this pattern using a simple example.
The example should calculate the price (GetPrice()) for different pizzas. Even if this example has no direct relation to automation technology, the basic principle of the decorator pattern is described quite well. The pizzas could just as well be replaced by pumps, cylinders or axes.
First variant: The ‚Super Function Block‘
In the example, there are two basic kinds of pizza: American style and Italian style. Each of these basic sorts can have salami, cheese and broccoli as a topping.
The most obvious approach could be to place the entire functionality in one function block.
Properties determine the ingredients of the pizza, while a method performs the desired calculation.
Furthermore, FB_init() is extended in such a way that the ingredients are already defined during the declaration of the instances. Thus different pizza variants can be created quite simply.
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);
The GetPrice() method evaluates this information and returns the requested value:
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
Actually, it’s a pretty solid solution. But as is so often the case in software development, the requirements change. So the introduction of new pizzas may require additional ingredients. The FB_Pizza function block is constantly growing and so is its complexity. The fact that everything is contained in one function block also makes it difficult to distribute the final development among several people.
Second Variant: The ‚Hell of Inheritance‘
In the second approach, a separate function block is created for each pizza variant. In addition, an interface (I_Pizza) defines all common properties and methods. Since the price has to be determined for all pizzas, the interface contains the GetPrice() method.
The two function blocks FB_PizzaAmericanStyle and FB_PizzaItalianStyle implement this interface. Thus the function blocks replace the enumeration E_PizzaStyle and are the basis for all further pizzas. The GetPrice() method returns the respective base price for these two FBs.
Based on this, different pizzas are defined with the different ingredients. For example, the pizza Margherita has cheese and tomatoes. The salami pizza also needs salami. Thus, the FB inherits for the salami pizza from the FB of the pizza Margherita.
The GetPrice() method always uses the super pointer to access the underlying method and adds the amount for its own ingredients, given that they are available.
METHOD PUBLIC GetPrice : LREAL GetPrice := SUPER^.GetPrice(); IF (THIS^.bSalami) THEN GetPrice := GetPrice + 1.4; END_IF
This results in an inheritance hierarchy that reflects the dependencies of the different pizza variants.
This solution also looks very elegant at first glance. One advantage is the common interface. Each instance of one of the function blocks can be assigned to an interface pointer of type I_Pizza. This is helpful, for example, with methods, since each pizza variant can be passed via a parameter of type I_Pizza.
Also different pizzas can be stored in an array and the common price can be calculated:
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 := fbItalianPizzaPiccante; aPizza := fbItalianPizzaMozzarella; aPizza := fbItalianPizzaSalami; aPizza := fbAmericanPizzaCalifornia; aPizza := fbAmericanPizzaNewYork; lrPrice := 0; FOR nIndex := 1 TO 5 DO lrPrice := lrPrice + aPizza[nIndex].GetPrice(); END_FOR
Nevertheless, this approach has several disadvantages.
What happens if the menu is adjusted and the ingredients of a pizza change as a result? Assuming the salami pizza should also get mushrooms, the pizza Piccante also inherits the mushrooms, although this is not desired. The entire inheritance hierarchy must be adapted. The solution becomes inflexible because of the firm relationship through inheritance.
How does the system handle individual customer wishes? For example, double cheese or ingredients that are not actually intended for a particular pizza.
If the function blocks are located in a library, these adaptations would be only partially possible.
Above all, there is a danger that existing applications compiled with an older version of the library will no longer behave correctly.
Third variant: The Decorator Pattern
Some design principles of object-oriented software development are helpful to optimize the solution. Adhering to these principles should help to keep the software structure clean.
Open for extensions: This means that the original functionality of a module can be changed by using extension modules. The extension modules only contain the adaptations of the original functionality.
Closed for changes: This means that no changes to the module are necessary to extend it. The module provides defined extension points to connect to the extension modules.
Identify those aspects that change and separate them from those that remain constant
How are the function blocks divided so that extensions are necessary in as few places as possible?
So far, the two basic pizza varieties, American style and Italian style, have been represented by function blocks. So why not also define the ingredients as function blocks? This would enable us to comply with the Open Closed Principle. Our basic varieties and ingredients are constant and therefore closed to change. However, we must ensure that each basic variety can be extended with any number of ingredients. The solution would therefore be open to extensions.
The decorator pattern does not rely on inheritance when behaviour is extended. Rather, each side order can also be understood as a wrapper. This wrapper covers an already existing dish. To make this possible, the side orders also implement the interface I_Pizza. Each side order also contains an interface pointer to the underlying wrapper.
The basic pizza type and the side orders are thereby nested into each other. If the GetPrice() method is called from the outer wrapper, it delegates the call to the underlying wrapper and then adds its price. This goes on until the call chain has reached the basic pizza type that returns the base price.
The innermost wrapper returns its base price:
METHOD GetPrice : LREAL GetPrice := 4.5;
Each further decorator adds the requested surcharge to the underlying wrapper:
METHOD GetPrice : LREAL IF (THIS^.ipSideOrder <> 0) THEN GetPrice := THIS^.ipSideOrder.GetPrice() + 0.9; END_IF
So that the underlying wrapper can be passed to the function block, the method FB_init() is extended by an additional parameter of type I_Pizza. Thus, the desired ingredients are already defined during the declaration of the FB instances.
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;
To make it easier to see how the individual wrappers run through, I have provided the GetDescription() method. Each wrapper adds a short description to the existing string.
In the following example, the ingredients of the pizza are specified directly in the declaration:
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'
There is no fixed connection between the function blocks. New pizza types can be defined without having to modify existing function blocks. The inheritance hierarchy does not determine the dependencies between the different pizza variants.
In addition, the interface pointer can also be passed by property. This makes it possible to combine or change the pizza at run-time.
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
Special features can also be integrated in each function block. These can be additional properties, but also further methods.
The function block for the tomatoes is to be offered optionally also as organic tomato. One possibility, of course, is to create a new function block. This is necessary if the existing function block cannot be extended (e.g., because it is in a library). However, if this requirement is known before the first release, it can be directly taken into account.
The function block receives an additional parameter in the method FB_init().
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;
This parameter could also be changed at run-time using a property. When the price is calculated, the option is taken into account as required.
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
A further optimization can be the introduction of a basic FB (FB_Decorator) for all decorator FBs.
In the book (Amazon Advertising Link *) Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph E. Johnson and John Vlissides, it is expressed as follows:
The decorator patterns provide a flexible alternative to subclassing for […] extending functionality.
The crucial point with the decorator pattern is that when extending a function block, inheritance is not used. If the behaviour is to be supplemented, function blocks are nested into each other; they are decorated.
The central component is the IComponent interface. The functional blocks to be decorated (Component) implement this interface.
The function blocks that serve as decorators (Decorator) also implement the IComponent interface. In addition, they also contain a reference (interface pointer component) to another decorator (Decorator) or to the basic function block (Component).
The outermost decorator thus represents the basic function block, extended by the functions of the decorators. The method Operation() is passed through all function blocks. Whereby each function block may add any functionalities.
This approach has some advantages:
- The original function block (component) does not know anything about the add-ons (decorator). It is not necessary to extend or adapt it.
- The decorators are independent of each other and can also be used for other applications.
- The decorators can be combined with each other at any time.
- A function block can therefore change its behaviour either by declaration or at run-time.
- A client that accesses the function block via the IComponent interface can handle a decorated function block in the same way. The client does not have to be adapted; it becomes reusable.
But also some disadvantages have to be considered:
- The number of function blocks can increase significantly, which makes the integration into an existing library more complex.
- The client does not recognize whether it is the original base component (if accessed via the IComponent interface) or whether it has been enhanced by decorators. This can be an advantage (see above), but can also lead to problems.
- The long call sequences make troubleshooting more difficult. The long call sequences can also have a negative effect on the performance of the application.
Related to the example above, the following mapping results:
|Decorator||FB_DecoratorCheese, FB_DecoratorSalami, FB_DecoratorTomato|
The decorator pattern is very often found in classes that are responsible for editing data streams. This concerns both the Java standard library and the Microsoft .NET framework.
Thus, there is the class System.IO.Stream in the .NET Framework. System.IO.FileStream and System.IO.MemoryStream inherit from this class. Both subclasses also contain an instance of Stream. Many methods and properties of FileStream and MemoryStream access this instance. You can also say: The subclasses FileStream and MemoryStream decorate Stream.
Further use cases are libraries for the creation of graphical user interfaces. These include WPF from Microsoft as well as Swing for Java.
A text box and a border are nested into each other; the text box is decorated with the border. The border (with the text box) is then passed to the page.