Object-oriented programming in C – part 2 (Interfaces)

Introduction

This article is the second in a small series of articles that explain how object-oriented programming (OOP) can be applied in C in embedded software projects. In the previous article I explained when and why it makes sense to apply the concept of objects in C, and how to implement them. Doing this makes the software design more reusable, scalable and flexible and thus maintainable.

Another aspect of OOP is that of inheritance, which means that one object can be a specific type of another object – like how an apple is a specific type of fruit. Applying inheritance can improve the reusability, flexibility and testability of a software architecture even more, because it allows you to decouple the components in your design by introducing interfaces between them.

In this article I will explain how interfaces can be implemented in C, using the room thermostat example project from the previous article.

Design

Main components

The previous article discussed the averaging module of a room thermostat software design. The module is used in the component that reads out the temperature sensor(s). Say we call it the TemperatureReader component.

We obviously need such component in our room thermostat design, but it is not the component that implements the most important functionality in our software system. The component that actually regulates the room temperature is the most important component in the design: it is the reason why the room thermostat software exists and can be called thermostat software in the first place. Let’s call this component TemperatureControl.

Everytime TemperatureControl fetches the current room temperature from the TemperatureReader, it decides whether or not the heater should be turned on. The TemperatureController should not be bothered with the actual control of and communication with the heater, so we need another component to do that: HeaterControl. This component is capable of communicating with the heater and can turn the heater on or off.

Our room thermostat has a turning knob for changing the desired room temperature, and a screen for displaying the desired and actual room temperatures, and the heater’s on/off status. Hence, in our software design, we need a UserInterfaceControl component that communicates with these knob and display peripherals.

Without interface objects

Now that we have identified the main components in our software design, let’s see what the component diagram looks like if we wouldn’t use interfaces:

PlantUML Syntax:
component Main
component TemperatureReader
component TemperatureControl
component HeaterControl
component UserInterfaceControl

Main --> TemperatureControl
TemperatureControl --> TemperatureReader
TemperatureControl --> HeaterControl
TemperatureControl --> UserInterfaceControl

Note that TemperatureControl depends on all other controllers in the design. Put differently, the C file that implements TemperatureControl will contain #include statements pointing to header files that belong to each of the other controllers. By doing it this way, there’s a tight coupling between our most important component and the implementation of the components it depends on.

This tight coupling makes our design more difficult to unit test, change and reuse. Suppose the company we create our room thermostat software for wants to develop two variants of the room thermostat: a basic thermostat, supporting only ON/OFF-switching heaters, and a deluxe thermostat, which has an extra switch with which consumers can select a specific heater type: ON/OFF-switching, PWM, or OpenTherm.

Suppose we’re using a single software project (i.e. one main.c file) with build configurations to compile each of the two variants. We don’t want TemperatureControl to know what heater it is dealing with. We should therefore create a HeaterControl that contains logic to support each type of heater. You will end up burying #define directives to select the right include files and logic depending on the build variant:

#if defined(VARIANT_I)
    #include "on_off_heater.h"
#elif defined(VARIANT_II)
    #include "on_off_heater.h"
    #include "pwm_heater.h"
    #include "opentherm_heater.h"
#else
    #error "No heater selected!"
#endif

We effectively introduce an extra layer of software in between TemperatureControl and the actual HeaterControl – a so called facade – that maps the function calls made by TemperatureControl to the active HeaterControl functions at compile-time:

The same is true when we want to support additional types of displays for example, or other types of temperature sensors. So how can interfaces prevent for such tight coupling between components and modules?

Now with interface objects

Let’s briefly look at what the component diagram should look like after we have introduced interfaces in our software design:

PlantUML Syntax:
component Main
component TemperatureReader
component TemperatureControl
component HeaterControl
component UserInterfaceControl

Main --> TemperatureControl
Main --> TemperatureReader
Main --> HeaterControl
Main --> UserInterfaceControl
TemperatureReader ---> TemperatureControl
HeaterControl ---> TemperatureControl
UserInterfaceControl ---> TemperatureControl

The important thing to notice is that the direction of the dependency arrows between the TemperatureControl and the other controllers have inverted – which is good! Each of the other controllers depends on an interface object in the TemperatureControl. Let’s first explore how this works at the class-level. At some point I’ll get back to the newly introduced dependencies between Main and the TemperatureReader, HeaterControl, and UserInterfaceControl.

At the class-level

Let’s zoom in on the TemperatureControl component and the components that depend on it:

PlantUML Syntax:

package TemperatureControl {
interface Heater {
+enable_heater()
+disable_heater()
}

interface TemperatureReader {
+float get_room_temperature()
}

interface UserInterface {
+float get_desired_room_temperature()
+set_actual_room_temperature(float t)
+set_heater_status(bool enabled)
}

class TemperatureControl {
+init(TemperatureReader* tr, Heater* h, UserInterface* ui)
+run()
}

TemperatureControl --> Heater
TemperatureControl --> TemperatureReader
TemperatureControl --> UserInterface
}

class HeaterControlImpl {
-enable_heater()
-disable_heater()
}

HeaterControlImpl -up-|> Heater

class UserInterfaceControlImpl {
-float get_desired_room_temperature()
-set_actual_room_temperature(float t)
-set_heater_status(bool enabled)
}

UserInterfaceControlImpl -up-|> UserInterface

class TemperatureReaderImpl {
-float get_room_temperature()
}

TemperatureReaderImpl -up-|> TemperatureReader

class main <<module>>

main -up---> TemperatureControl
main -up--> TemperatureReaderImpl : creates
main -up--> UserInterfaceControlImpl : creates
main -up--> HeaterControlImpl : creates

As the most important component, TemperatureControl defines the interfaces to which the modules in the lower-level components must adhere. All arrows that cross the boundary of the TemperatureControl component point inward, which means that this component doesn’t depend on any less important components. This is thanks to the application of the Dependency Inversion Principle (DIP), which is the last of the SOLID principles.

Note that while I designed all controllers to be classes they can just as well be implemented as modules instead – so without the ability to create multiple objects of them. If future requirement changes need it, the module is easily refactored to a class after all, since no other component depend on it.

Implementation

Interfaces

An interface is nothing more than a list of function pointers. For example, for the Heater interface:

typedef struct Heater_struct {
    void (*enable_heater)();
    void (*disable_heater)();
} Heater;

Within the HeaterControl component, the HeaterControlImpl class provides the implementations for enable_heater() and disable_heater(). The signature of the HeaterControlImpl class’ constructor in HeaterControlImpl.h looks as follows:

extern void HeaterControlImpl__create(Heater * inout_instance);

Note that he constructor doesn’t initialize an instance of HeaterControlImpl, but an instance of its interface class Heater. This is the object type the TemperatureControl needs to work with.

In HeaterControlImpl.c, the implementations of enable_heater() and disable_heater() may be declared static (as in: they may be private functions). The implementation of HeaterControlImpl__create() simply maps to these functions:

#include "HeaterControlImpl.h"

static void enable_heater();
static void disable_heater();

extern void HeaterControlImpl__create(Heater * inout_instance) {
    if(inout_instance != NULL) {
        inout_instance-&gt;enable_heater = enable_heater;
        inout_instance-&gt;disable_heater = disable_heater;
    }
}

static void enable_heater() {
    // implementation omitted intentionally
}

static void disable_heater() {
    // implementation omitted intentionally
}

The main() function

It is in main() where all objects are created and get injected into TemperatureControl:

 
#include "TemperatureControl.h"
#include "TemperatureReaderImpl.h"
#include "UserInterfaceImpl.h"
#include "HeaterControllerImpl.h"

void main() {
    TemperatureReader reader;
    UserInterface user_interface;
    Heater heater;

    TemperatureReaderImpl__create(&reader);
    UserInterfaceImpl__create(&user_interface);
    HeaterControlImpl__create(&heater);

    TemperatureControl control;

    // Inject dependencies into TemperatureControl
    TemperatureControl__init(&reader, &heater, &user_interface);

    // Pass on execution to TemperatureControl
    TemperatureControl__run();
}

This also explains why the component diagram shows dependencies from Main to each of the controllers.

The two room thermostat variants

The interface-based design allows for a straight-forward implementation of each of the two software variants discussed earlier – or any other thinkable variant for that matter! For each of the three heater an implementation must be developed that adheres to the Heater interface such that it can be injected into TemperatureControl. When managed as individual software projects – i.e. each with their own main(), each project’s main() will simply inject a different type of heater. The only difference between the variants is that in case of the second, the heater type can change at run-time:

#include "TemperatureControl.h"
#include "TemperatureReaderImpl.h"
#include "UserInterfaceImpl.h"
#include "HeaterControllerImplOnOff.h"
#include "HeaterControllerImplPWM.h"
#include "HeaterControllerImplOpentherm.h"

// Declared outside main() such that they're accessible
// by on_button_press_handler()
static Heater heater_onoff;
static Heater heater_pwm;
static Heater heater_opentherm;
static Heater * selected_heater;

void on_button_released_handler(void);

void main(void)
{
    TemperatureReader reader;
    UserInterface user_interface;

    TemperatureReaderImpl__create(&reader);
    UserInterfaceImpl__create(&user_interface);
    HeaterControlImplOnOff__create(&heater_onoff);
    HeaterControlImplPWM__create(&heater_pwm);
    HeaterControlImplOpentherm__create(&heater_opentherm);
    TemperatureControl control;

    // Default to the simplest heater type
    selected_heater = &heater_onoff;

    // Register callback handler for changing the heater selection
    user_interface->set_on_button_released_callback(on_button_released_handler);

    // Inject dependencies into TemperatureControl
    TemperatureControl__init(&reader, &selected_heater, &user_interface);

    // Pass on execution to TemperatureControl
    TemperatureControl__run();
}

void on_button_released_handler(void)
{
    // Simply cycle through the heaters
    if(&heater_onoff == selected_heater)
    {
        selected_heater = &heater_pwm;
    }
    else if(&heater_pwm == selected_heater)
    {
        selected_heater = &heater_opentherm;
    }
    else if(&heater_opentherm == selected_heater)
    {
        selected_heater = &heater_onoff;
    }
}

Unit Testing

The testability of the design has improved greatly now that there’s a loose coupling between components and modules. We are now able to test each component or module in isolation from the other components and modules.

Suppose we want to test the implementation of the TemperatureController module. The module under test still requires the injection of its three dependencies TemperatureReader, UserInterface, and Heater. The difference is that we can now create test-specific implementations (e.g. stubs or mocks) of these dependencies, that do almost nothing and don’t require hardware to be available.

Let’s for example create a HeaterControllerTestImpl module:

#include "HeaterControlTestImpl.h"

// We need malloc()/free() to construct the object on the heap
#include "stdlib.h"
#include "stdbool.h"

extern bool heater_is_on = false;

static void enable_heater();
static void disable_heater();

extern void HeaterControlTestImpl__create(Heater * inout_instance) {
    if(NULL != inout_instance) {
        inout_instance->enable_heater = enable_heater;
        inout_instance->disable_heater = disable_heater;
    }
}

static void enable_heater() {
    heater_is_on = true;
}

static void disable_heater() {
    heater_is_on = false;
}

The test-implementation simply sets or clears heater_is_on, a flag that can be read back from in the assertion-part of the unit test in order to verify if it resulted in the desired behavior. In a similar fashion, you can create test-implementations for UserInterfaceTestImpl and TemperatureReaderTestImpl. This allows you to easily create tests for all the applicable use-cases.

Conclusion

By introducing interfaces between components, we are able to invert the dependencies between components. As a result there’s a less tight coupling between components and components become independent. This, in turn, makes the software design modular and allows the design to be changed during its lifetime without too much pain. Independent components are inherently more reusable and testable.

It is always good to try to make a software design as flexible as possible (without overdoing it), since you never know HOW requirements change in the future, but you can assume they WILL change!

 




No Comments


You can leave the first : )



Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

This site uses cookies. By continuing to use this website you agree to their use.