Post

Post #2.1: Understanding Design Patterns in C++

Welcome to Post #2.1 of the Mastering Object-Oriented Programming, C++, and Embedded Systems series!

In this series, we explore the intricacies of Object-Oriented Programming (OOP) and C++ concepts, applying them to embedded systems development using the ESP32-S3 microcontroller. Each topic is divided into two parts: C++ concepts and their application in embedded systems.

Note: This series reflects my personal learning journey. Newcomers to OOP might find some concepts challenging, but those with a basic understanding will see how these principles are applied in embedded systems.

Explore the full series here.

Today’s Focus: Understanding Design Patterns in C++

Let’s delve into design patterns and their significance in C++ and embedded systems development.

Introduction: What are Design Patterns?

Design patterns are standard solutions to common software development challenges. They offer templates for writing code that is efficient, modular, and maintainable. Instead of reinventing the wheel, developers can leverage design patterns to address issues that have already been effectively resolved.

In software engineering, design patterns are categorized into three main types:

  1. Creational Patterns - Focus on object creation mechanisms, ensuring objects are instantiated appropriately.
  2. Structural Patterns - Concern the composition of classes and objects, providing ways to form large structures from smaller components.
  3. Behavioral Patterns - Define how objects interact and collaborate with each other.

Why Use Design Patterns in C++ for Embedded Systems?

Embedded systems, such as those using microcontrollers like the ESP32, often face constraints in resources like memory, processing power, and power consumption. Design patterns are especially beneficial in this context because they:

  • Enhance Efficiency: Patterns optimize resource management, crucial for embedded systems.
  • Improve Maintainability: Well-defined patterns make the codebase easier to understand, extend, and maintain.
  • Promote Modularity and Reusability: Patterns encourage modular design, facilitating code reuse across different parts of the system or in other projects.

Applying design patterns in embedded systems development with C++ helps create cleaner, more efficient, and flexible code that meets the needs of embedded applications.

Common Design Patterns in C++

Let’s explore some design patterns relevant to embedded systems development:

1. Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to that instance. This is particularly useful for managing shared resources in an embedded system, such as a logger, configuration manager, or hardware peripherals.

Example: Implementing a Singleton Logger in C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <string>

class Logger {
private:
    static Logger* instance;  // Pointer to the single instance.
    Logger() {}  // Private constructor to prevent direct instantiation.

public:
    static Logger* getInstance() {
        if (!instance)
            instance = new Logger();
        return instance;
    }

    void log(const std::string& message) {
        std::cout << "Log: " << message << std::endl;
    }
};

// Initialize the static instance pointer.
Logger* Logger::instance = nullptr;

int main() {
    Logger* logger = Logger::getInstance();
    logger->log("Starting the system...");
    logger->log("System running smoothly.");
    
    // Trying to create another instance will return the same instance
    Logger* anotherLogger = Logger::getInstance();
    anotherLogger->log("This is the same logger instance.");
    
    return 0;
}

Explanation

  • Singleton Pattern Implementation: The Logger class uses a private static pointer (instance) to hold the single instance of the class. The constructor is private to prevent direct instantiation.
  • getInstance() Method: Creates and returns a Logger object if the instance is nullptr, otherwise returns the existing instance.
  • log Method: Logs messages to the console.
  • main Function: Demonstrates the Singleton pattern by obtaining a single Logger instance and logging messages. Any attempt to create another instance will return the same instance.

2. Factory Pattern

The Factory pattern provides an interface for creating objects but allows subclasses to determine the exact class to instantiate. It promotes loose coupling by delegating object creation to another class.

Example: Implementing a Sensor Factory in C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <string>

class Sensor {
public:
    virtual ~Sensor() {}  // Virtual destructor
    virtual void readData() = 0;
};

class TemperatureSensor : public Sensor {
public:
    void readData() override {
        std::cout << "Reading temperature data." << std::endl;
    }
};

class HumiditySensor : public Sensor {
public:
    void readData() override {
        std::cout << "Reading humidity data." << std::endl;
    }
};

class SensorFactory {
public:
    static Sensor* createSensor(const std::string& type) {
        if (type == "temperature") return new TemperatureSensor();
        if (type == "humidity") return new HumiditySensor();
        return nullptr;
    }
};

int main() {
    Sensor* tempSensor = SensorFactory::createSensor("temperature");
    Sensor* humiditySensor = SensorFactory::createSensor("humidity");

    if (tempSensor) tempSensor->readData();
    if (humiditySensor) humiditySensor->readData();

    // Clean up dynamically allocated memory
    delete tempSensor;
    delete humiditySensor;

    return 0;
}

Explanation

  • Sensor Class: An abstract base class with a pure virtual function readData() to be implemented by derived classes.
  • TemperatureSensor and HumiditySensor Classes: Concrete implementations of the Sensor class, each overriding readData() to provide specific functionality.
  • SensorFactory Class: Contains a static method createSensor() to create and return a Sensor instance based on the provided type.
  • main Function: Demonstrates the Factory pattern by creating instances of TemperatureSensor and HumiditySensor via the SensorFactory, reading data from these sensors, and then cleaning up the allocated memory.

3. Observer Pattern

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. This is ideal for scenarios where multiple parts of an embedded system need to react to changes in sensor data.

Example: Implementing the Observer Pattern in C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <iostream>
#include <vector>
#include <algorithm>

// Observer interface
class Observer {
public:
    virtual void update(int data) = 0;
    virtual ~Observer() {}  // Virtual destructor
};

// Sensor class with Observer pattern implementation
class Sensor {
private:
    int sensorData;
    std::vector<Observer*> observers;
public:
    void addObserver(Observer* observer) {
        observers.push_back(observer);
    }

    void removeObserver(Observer* observer) {
        observers.erase(std::remove(observers.begin(), observers.end(), observer), observers.end());
    }

    void notifyObservers() {
        for (Observer* obs : observers) {
            obs->update(sensorData);
        }
    }

    void setData(int data) {
        sensorData = data;
        notifyObservers();
    }
};

// Display class that implements the Observer interface
class Display : public Observer {
public:
    void update(int data) override {
        std::cout << "Display updated with new data: " << data << std::endl;
    }
};

// Main function demonstrating the Observer pattern
int main() {
    Sensor sensor;
    Display display1, display2;

    sensor.addObserver(&display1);
    sensor.addObserver(&display2);

    sensor.setData(100);  // Both displays get notified
    sensor.removeObserver(&display1);

    sensor.setData(200);  // Only display2 gets notified

    return 0;
}

Explanation

  • Observer Interface: Defines a method update() for observers to implement.
  • Sensor Class: Manages a list of observers and notifies them when the data changes.
  • Display Class: Implements the Observer interface and updates itself based on notifications from the Sensor.
  • Main Function: Creates instances of Sensor and Display, registers the displays as observers, changes sensor data, and demonstrates observer notifications.

4. Strategy Pattern

The Strategy pattern allows selecting an algorithm’s behavior at runtime. This is useful for dynamically switching between different data processing algorithms based on current requirements in embedded systems.

Example: Implementing the Strategy Pattern in C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
#include <vector>
#include <numeric>  // For std::accumulate

// Strategy interface
class Strategy {
public:
    virtual int process(std::vector<int> data) = 0;
    virtual ~Strategy() {}  // Virtual destructor
};

// Concrete strategy for computing the sum
class SumStrategy : public Strategy {
public:
    int process(std::vector<int> data) override {
        return std::accumulate(data.begin(), data.end(), 0);
    }
};

// Concrete strategy for computing the average
class AverageStrategy : public Strategy {
public:
    int process(std::vector<int> data) override {
        if (data.empty()) return 0;  // Handle empty vector
        return std

::accumulate(data.begin(), data.end(), 0) / data.size();
    }
};

// Context class using a Strategy
class DataProcessor {
private:
    Strategy* strategy;
public:
    void setStrategy(Strategy* str) {
        strategy = str;
    }

    void processData(const std::vector<int>& data) {
        int result = strategy->process(data);
        std::cout << "Processed result: " << result << std::endl;
    }
};

// Main function demonstrating the Strategy pattern
int main() {
    DataProcessor processor;
    std::vector<int> data = {1, 2, 3, 4, 5};

    SumStrategy sumStrategy;
    AverageStrategy avgStrategy;

    processor.setStrategy(&sumStrategy);
    processor.processData(data);

    processor.setStrategy(&avgStrategy);
    processor.processData(data);

    return 0;
}

Explanation

  • Strategy Interface: Defines a process() method for different strategies.
  • Concrete Strategies: SumStrategy and AverageStrategy implement the Strategy interface with different algorithms.
  • DataProcessor Class: Uses a Strategy object to process data.
  • Main Function: Demonstrates switching between different strategies for processing data.
This post is licensed under CC BY 4.0 by the author.