Fixed dependencies are one of the main causes of poorly maintainable software. Certainly, not all function blocks can exist completely independently of other function blocks. After all, these interact with each other and are thus interrelated. However, by applying the Dependency Inversion Principle, these dependencies can be minimized. Changes can therefore be implemented more quickly.
With a simple example, I will show how negative couplings can arise between function blocks. Then, I will resolve these dependencies with the help of the Dependency Inversion Principle.
The example contains three function blocks, each of which controls different lamps. While FB_LampOnOff can only switch a lamp on and off, FB_LampSetDirect can set the output value directly to a value from 0 % to 100 %. The third function block (FB_LampUpDown) is only able to relatively dim the lamp by 1 % using the OneStepDown() and OneStepUp() methods. The method OnOff() sets the output value immediately to 100 % or 0 %.
These three function blocks are controlled by FB_Controller. An instance of each lamp type is instantiated in FB_Controller. The desired lamp is selected via the property eActiveLamp of type E_LampType.
TYPE E_LampType : ( Unknown := -1, SetDirect := 0, OnOff := 1, UpDown := 2 ) := Unknown; END_TYPE
In turn, FB_Controller has appropriate methods for controlling the different lamp types. The DimDown() and DimUp() methods dim the selected lamp by 5 % upwards or 5 % downwards. While the On() and Off() methods switch the lamp on or off directly.
The IEC 61131-3: The Observer Pattern is used to transmit the output variable between the controller and the selected lamp. The controller contains an instance of FB_AnalogValue for this purpose. FB_AnalogValue implements the interface I_Observer with the method Update(), while the three function blocks for the lamps implement the interface I_Subject. Using the Attach() method, each lamp block receives an interface pointer to the I_Observer interface of FB_AnalogValue. If the output value changes in one of the three lamp blocks, the new value is transferred to FB_AnalogValue from the interface I_Observer via the method Update().
Our example so far consists of the following actors:
The UML diagram shows the relationships between the respective elements:
Let’s take a closer look at the program code of the individual function blocks.
FB_LampOnOff / FB_LampUpDown / FB_LampSetDirect
FB_LampSetDirect is used here as an example for the three lamp types. FB_LampSetDirect has a local variable for the current output value and a local variable for the interface pointer to FB_AnalogValue.
FUNCTION_BLOCK PUBLIC FB_LampSetDirect IMPLEMENTS I_Subject VAR nLightLevel : BYTE(0..100); _ipObserver : I_Observer; END_VAR
If FB_Controller switches to the lamp of the type FB_LampSetDirect, FB_Controller calls the Attach() method and passes the interface pointer to FB_AnalogValue to FB_LampSetDirect. If the value is valid (not equal to 0), it is saved in the local variable (backing variable) _ipObserver.
Note: Local variables that store the value of a property are also known as backing variables and are indicated by an underscore in the variable name.
METHOD Attach VAR_INPUT ipObserver : I_Observer; END_VAR IF (ipObserver = 0) THEN RETURN; END_IF _ipObserver := ipObserver;
The Detach() method sets the interface pointer to 0, which means that the Update() method is no longer called (see below).
METHOD Detach _ipObserver := 0;
The new output value is passed via the SetLightLevel() method and stored in the local variable nLightLevel. In addition, the method Update() is called by the interface pointer _ipObserver. This gives the new output value to the instance of FB_AnalogValue located in FB_Controller.
METHOD PUBLIC SetLightLevel VAR_INPUT nNewLightLevel : BYTE(0..100); END_VAR nLightLevel := nNewLightLevel; IF (_ipObserver <> 0) THEN _ipObserver.Update(nLightLevel); END_IF
The Attach() and Detach() methods are identical for all three lamp blocks. There are differences only in the methods that change the initial value.
FB_AnalogValue contains very little program code, since this function block is only used to store the output variable.
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
In addition, FB_AnalogValue has the property nValue, via which the current value is made available externally.
FB_Controller contains the instances of the three lamp blocks. Furthermore, there is an instance of FB_AnalogValue to receive the current output value of the active lamp. _eActiveLamp stores the current state of the eActiveLamp property.
FUNCTION_BLOCK PUBLIC FB_Controller VAR fbLampOnOff : FB_LampOnOff(); fbLampSetDirect : FB_LampSetDirect(); fbLampUpDown : FB_LampUpDown(); fbActualValue : FB_AnalogValue(); _eActiveLamp : E_LampType; END_VAR
Switching between the three lamps is done by the setter of the eActiveLamp property.
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;
If the eActiveLamp property is used to switch to another lamp, the current lamp is switched off at first using the local method Off(). Furthermore, the method Detach() is called for all three lamps. This terminates a possible connection to FB_AnalogValue. Within the CASE statement, the method Attach() is called for the new lamp and the interface pointer is passed to fbActualValue. Finally, the state of the property is saved in the local variable _eActiveLamp.
The methods DimDown(), DimUp(), Off() and On() have the task of setting the desired output value. Since the individual lamp types offer different methods for this, each lamp type must be handled individually.
The DimDown() method should dim the active lamp by 5 %. However, the initial value should not fall below 10 %.
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 only knows the states 0 % and 100 %. Dimming is therefore not possible. As a compromise, the lamp will in fact be switched off when it is dimmed down (line 4).
With FB_LampSetDirect, the SetLightLevel() method can be used to set the new initial value directly. To do this, 5 is subtracted from the current output value and passed to the SetLightLevel() method (line 7). The IF query in line 6 ensures that the initial value is not set below 10 %.
Since the OneStepDown() method of FB_LampUpDown only reduces the initial value by 1 %, the method is called 5 times (lines 11-15). Here again, the IF query in line 10 ensures that the value does not fall below 10 %.
DimUp(), Off() and On() have a comparable structure. The various lamp types are treated separately using a CASE statement, and the respective special features are thus taken into account.
At first glance, the implementation seems solid. The program does what it should and the presented code is maintainable in its current size. If it were ensured that the program would not increase in size, everything could remain as it is.
But in practice, the current state is more like the first development cycle of a larger project. The small manageable application will grow in code size over time as extensions are added. Thus, a close inspection of the code right at the beginning makes sense. Otherwise, there is a risk of missing the right time for fundamental optimizations. Defects can then only be eliminated with a great deal of time.
But what are the fundamental issues with the above example?
1st issue: CASE statement
Every method of the controller has the same CASE construct.
CASE _eActiveLamp OF E_LampType.OnOff: fbLampOnOff... E_LampType.SetDirect: fbLampSetDirect... E_LampType.UpDown: fbLampUpDown... END_CASE
Although there is a similarity between the value of _eActiveLamp (e.g., E_LampType.SetDirect) and the local variable (e.g., fbLampSetDirect), the individual cases have still to be observed and programmed manually.
2nd issue: Extensibility
If a new lamp type has to be added, the data type E_LampType must first be extended. Then, it is necessary to add the CASE statement in each method of the controller.
3rd issue: Responsibilities
Because the controller assigns the commands to all lamp types, the logic of a lamp type is distributed over several FBs. This is an extremely impractical grouping. If you want to understand how the controller addresses a specific lamp type, you have to jump from method to method and pick the correct case in the CASE statement.
4th issue: Coupling
The controller has a close connection to the different lamp modules. As a result, the controller is highly dependent on changes to the individual lamp types. Every change to the methods of a lamp type inevitably leads to adjustments of the controller.
Currently, the example has fixed dependencies in one direction. The controller calls the methods of the respective lamp types. This direct dependency should be resolved. To do this, we need a common level of abstraction.
Resolving the CASE statements
Abstract function blocks and interfaces can be used for this purpose. In the following, I use the abstract function block FB_Lamp and the interface I_Lamp. The interface I_Lamp has the same methods as the controller. The abstract FB implements the interface I_Lamp and thus also has all the methods of FB_Controller.
I presented in IEC 61131-3: Abstract FB vs. interface, how abstract function blocks and interfaces can be combined with each other.
All lamp types inherit from this abstract lamp type. This makes all lamp types look the same from the controller’s point of view. Furthermore, the abstract FB implements the I_Subject interface.
FUNCTION_BLOCK PUBLIC ABSTRACT FB_Lamp IMPLEMENTS I_Subject, I_Lamp
The methods Detach() and Attach() of FB_Lamp are not declared as abstract and contain the necessary program code. This means that it is not necessary to implement the program code for these two methods in each lamp type again.
Since the lamp types inherit from FB_Lamp, they are all the same from the controller’s point of view.
The SetLightLevel() method remains unchanged. The assignment of the methods of FB_Lamp (DimDown(), DimUp(), Off() and On()) to the respective lamp types is now no longer done in the controller, but in the respective FB of the lamp type:
METHOD PUBLIC DimDown IF (nLightLevel >= 15) THEN SetLightLevel(nLightLevel - 5); END_IF
Thus, the controller is no longer responsible for assigning the methods, but rather each lamp type itself. The CASE statements in the FB_Controller methods are omitted completely.
The use of E_LampType still binds the controller to the respective lamp types. But how to switch to the different lamp types if E_LampType is omitted? To achieve this, the desired lamp type is passed to the controller via a property by reference.
PROPERTY PUBLIC refActiveLamp : REFERENCE TO FB_Lamp
Thus, all lamp types can be passed. The only condition is that the passed lamp type must inherit from FB_Lamp. This defines all methods and properties that are necessary for an interaction between the controller and the lamp block.
Note: This technique of ‚injecting‘ dependencies is also called Dependency Injection.
Switching to the new lamp module is done in the setter of the refActiveLamp property. The method Detach() of the active lamp is called there (line 2), while the method Attach() is called in line 6 by the new lamp. In line 4, the reference of the new lamp is stored in the local variable (backing variable) _refActiveLamp.
IF (__ISVALIDREF(_refActiveLamp)) THEN _refActiveLamp.Detach(); END_IF _refActiveLamp REF= refActiveLamp; IF (__ISVALIDREF(refActiveLamp)) THEN refActiveLamp.Attach(fbActualValue); END_IF
In the methods DimDown(), DimUp(), Off() and On(), the method call is forwarded to the active lamp via _refActiveLamp. Instead of the CASE statement, there are only a few lines here, since it is no longer necessary to distinguish between the different lamp types.
METHOD PUBLIC DimDown IF (__ISVALIDREF(_refActiveLamp)) THEN _refActiveLamp.DimDown(); END_IF
The controller is therefore generic. If a new lamp type is defined, the controller remains unchanged.
Admittedly, this delegated the task of selecting the desired lamp type to the caller of FB_Controller. Now, it must create the various lamp types and pass them to the controller. This is a good approach if, for example, all elements are contained in a library. With the adjustments shown above, it is now possible to develop your own lamp types without having to make adjustments to the library.
Although a function block and an interface have been added, the amount of program code has not increased. The code only needed to be reasonably restructured to eliminate the problems mentioned above. The result is a long-term sustainable program structure, which was divided into several consistently small artifacts with clear responsibilities. The UML diagram shows the new distribution very well:
FB_Controller no longer has a fixed binding to the individual lamp types. Instead, the abstract function block FB_Lamp is accessed, which is passed to the controller via the refActiveLamp property. The individual lamp types are then accessed via this abstraction level.
The definition of the Dependency Inversion Principle
The Dependency Inversion Principle consists of two rules and is described very well in the book (Amazon advertising link *) Clean Architecture: A Craftsman’s Guide to Software Structure and Design by Robert C. Martin:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Referring to the above example, the high-level module is the FB_Controller function block. It should not directly access low-level modules that contain details. The low-level modules are the individual lamp types.
Abstractions should not depend on details. Details should depend on abstractions.
The details are the individual methods offered by the respective lamp types. In the first example, FB_Controller depends on the details of all lamp types. If a change is made to a lamp type, the controller must also be adapted.
What exactly does the Dependency Inversion Principle invert?
In the first example, FB_Controller accesses the individual lamp types directly. This makes FB_Controller (higher level) dependent on the lamp types (lower level).
The Dependency Inversion Principle inverts this dependency. For this purpose, an additional abstraction level is introduced. The higher layer specifies what this abstraction layer looks like. The lower layers must meet these requirements. This changes the direction of the dependencies.
In the above example, this additional abstraction level was implemented by combining the abstract function block FB_Lamp and the interface I_Lamp.
With the Dependency Inversion Principle, there is a risk of overengineering. Not every coupling should be resolved. Where an exchange of function blocks is to be expected, the Dependency Inversion Principle can be of great help. Above, I gave an example of a library in which different function blocks are interdependent. If the user of the library wants to intervene in these dependencies, fixed dependencies would prevent this.
The Dependency Inversion Principle increases the testability of a system. FB_Controller can be tested completely independently of the individual lamp types. For the unit tests, an FB is created which is derived from FB_Lamp. This dummy FB contains only functions that are necessary for the tests of FB_Controller, and is also called a mocking object. Jakob Sagatowski introduces this concept in his post Mocking objects in TwinCAT.
In the next post, I will analyze and further optimize the sample program using the Single Responsibility Principle (SRP).