Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 43

Implementing Strategy with Function Pointers in C

Recommended resources

Hello! Welcome back to our advanced exploration of pointers.

In our last lesson, we saw how to use arrays of function pointers to create efficient jump tables for command dispatching. That was a powerful, performance-oriented pattern. Today, we'll build on that foundation to explore a more general and fundamental design principle: the Strategy design pattern.

Your learning outcome for this lesson is to apply function pointers to implement the Strategy design pattern in C. This powerful pattern allows you to define a family of interchangeable algorithms and select one at runtime, leading to more flexible, maintainable, and extensible code—a key goal in complex systems like the AUTOSAR middleware you develop.

From Fixed Logic to Interchangeable Strategies

Imagine you're developing a component for an ADAS system that needs to process sensor data. The processing algorithm might need to change based on the driving mode (e.g., 'urban', 'highway') or environmental conditions. A naive implementation might use a large switch statement:

void process_sensor_data(SensorData* data, DrivingMode mode) {
    switch (mode) {
        case URBAN:
            // Apply urban-specific filtering and object detection
            break;
        case HIGHWAY:
            // Apply highway-specific filtering (e.g., for higher speeds)
            break;
        // ... more cases
    }
}

This approach tightly couples the process_sensor_data function to every possible algorithm. Adding a new mode, like 'off-road', requires modifying and recompiling this core function, violating a key software design principle. The Strategy pattern provides a much cleaner solution.


Understanding the Strategy Pattern

At its core, the Strategy pattern is about favoring composition over inheritance and enabling interchangeable behaviors. It defines a family of algorithms, encapsulates each one, and makes them swappable. This decouples the client that uses the algorithm from the implementation of the algorithm itself.

To get a clear, high-level understanding of the "why" and "what" behind this pattern, let's watch a classic explanation. While the video uses object-oriented terminology, the core concepts of decoupling and encapsulating behavior are universal.

Strategy Pattern – Design Patterns (ep 1)

This video from Christopher Okhravi provides an excellent conceptual overview of the Strategy Pattern. It explains the problem it solves and how it restructures the code to be more flexible.

Please watch the following segments: The Definition (00:48 - 03:25): Focus on the formal definition: "Define a family of algorithms, encapsulate each one, and make them interchangeable." The Problem (03:25 - 10:40): Pay attention to how using a rigid class hierarchy to share behavior (like fly or quack for different ducks) becomes unmanageable when requirements change. The Solution (10:40 - 19:26): Understand how behaviors are extracted into their own separate entities (interfaces) and then 'composed' into the main object. This is the central idea.

The video introduces three key roles in the pattern, which we can visualize with a standard UML diagram:

UML Diagram of the Strategy Design Pattern
This diagram shows the three main components of the Strategy pattern. The **Context** (Client) is the object whose behavior we want to change. The **Strategy** is an interface that defines how that behavior is called. **Concrete Strategies** are the individual implementations of that behavior.

Implementing the Strategy Pattern in C

Now, how do we translate these concepts to C, which doesn't have classes or interfaces in the same way? We use the tools we've been studying: structs and function pointers.

  • Strategy (Interface) → This is defined by a typedef for a function pointer. It specifies the signature (return type and parameters) that all our algorithm implementations must follow.
  • Concrete Strategy → This is a standard C function that matches the function pointer's signature. Each function encapsulates one specific algorithm.
  • Context → This is a struct that holds the data it operates on, plus a function pointer member to hold the currently selected concrete strategy.

Let's see this in action with a clear, practical C-specific guide.

Patterns in C - Part 3: STRATEGY

The article 'Patterns in C - Part 3: STRATEGY' by Adam Petersen provides a complete walkthrough of implementing this pattern in C. It starts with a relatable problem and builds the solution step-by-step.

Please read from the beginning to the section 'Binding the Strategy'. Focus on: The initial problem with the switch-based price calculation. The explanation of the Open-Closed Principle: 'open for extension, but closed for modification'. The Strategy pattern is a primary way to achieve this. How the C implementation uses a typedef for CustomerPriceStrategy (the interface), separate functions for bronzePriceStrategy, silverPriceStrategy, etc. (the concrete strategies), and a struct Customer (the context) containing a priceStrategy function pointer.

As the article demonstrates, this design is far superior. The placeOrder function in Customer.c doesn't need to know which pricing algorithm is being used. It simply calls the function pointed to by customer->priceStrategy. If a new "Platinum" customer tier is introduced, you only need to add a new platinumPriceStrategy function; the Customer struct and placeOrder function remain untouched. This is the Open-Closed Principle in practice.


Strategy in an Embedded Context

This pattern is extremely useful in embedded systems. Consider processing data from different types of sensors. Each sensor might require a unique calibration or conversion algorithm.

Embedded Design: The Strategy Pattern

This article, 'Embedded Design: The Strategy Pattern', gives a concise C implementation for this exact sensor-processing scenario.

Please read the sections '1. Strategy Interface and Concrete Strategies' and '3. Complete Example in C'. Notice how it follows the exact same pattern: a typedef for SensorStrategy, concrete functions like temperature_strategy, and a context struct SensorProcessor that holds the strategy pointer.

The table in this resource effectively summarizes the benefits: you can add new algorithms without modifying core logic, you eliminate complex switch statements, and you can even change the strategy at runtime.

Test your understanding!

Let's apply this to a scenario from your work. Imagine a software component in a gateway that needs to serialize a data structure before sending it over SOME/IP. You need to support different serialization formats, like a verbose JSON format for diagnostics and a compact binary format for performance-critical messages.

Your Task:
Design the components for a PacketSerializer using the Strategy pattern.

  1. Define the Strategy Interface: Create a typedef named serialize_strategy_t for a function pointer. The function should take a const void* data_payload and return a char* (representing the serialized buffer).
  2. Define the Context: Create a struct named PacketSerializer. It should contain a pointer to the data to be serialized and a function pointer of type serialize_strategy_t.
  3. Implement Concrete Strategies: Write skeleton functions for serialize_to_json and serialize_to_binary. They should match the serialize_strategy_t signature. For now, they can just print a message and return NULL.
  4. Implement the Executor: Write a function perform_serialization that takes a PacketSerializer* context and uses the strategy within it to process the data.
Show answer
#include <stdio.h>
#include <stdlib.h>

// Dummy data structure
typedef struct {
    int id;
    float value;
} SensorReading;

// 1. Define the Strategy Interface
typedef char* (*serialize_strategy_t)(const void* data_payload);

// 2. Define the Context
typedef struct {
    const void* payload;
    serialize_strategy_t current_strategy;
} PacketSerializer;

// 3. Implement Concrete Strategies
char* serialize_to_json(const void* data_payload) {
    const SensorReading* reading = (const SensorReading*)data_payload;
    printf("Strategy: Serializing sensor reading (ID: %d) to JSON format.\n", reading->id);
    // In a real implementation, you would use a JSON library to build the string.
    // char* json_buffer = ...;
    // return json_buffer;
    return NULL;
}

char* serialize_to_binary(const void* data_payload) {
    const SensorReading* reading = (const SensorReading*)data_payload;
    printf("Strategy: Serializing sensor reading (ID: %d) to compact binary format.\n", reading->id);
    // In a real implementation, you would copy bytes into a buffer.
    // char* binary_buffer = ...;
    // return binary_buffer;
    return NULL;
}

// 4. Implement the Executor
void perform_serialization(PacketSerializer* serializer) {
    if (serializer && serializer->current_strategy && serializer->payload) {
        printf("Context: Initiating serialization...\n");
        char* serialized_data = serializer->current_strategy(serializer->payload);
        // In a real system, you would now transmit this data.
        // For this example, we'll just free the dummy buffer if it weren't NULL.
        free(serialized_data);
    }
}

int main() {
    SensorReading my_data = { .id = 101, .value = 98.6f };

    // Create a serializer context configured for JSON
    PacketSerializer json_serializer = { .payload = &my_data, .current_strategy = serialize_to_json };
    perform_serialization(&json_serializer);

    printf("\n--- Switching strategy ---\n\n");

    // Create another context configured for binary
    PacketSerializer binary_serializer = { .payload = &my_data, .current_strategy = serialize_to_binary };
    perform_serialization(&binary_serializer);

    // You can also change the strategy on an existing context at runtime
    json_serializer.current_strategy = serialize_to_binary;
    perform_serialization(&json_serializer);


    return 0;
}

This design allows the serialization logic to be completely decoupled from the component that triggers it. You can add new formats (e.g., XML, Protobuf) without ever touching PacketSerializer or perform_serialization.


Conclusion

Today, we took a significant step up in abstraction. We've seen how function pointers are not just for simple callbacks or jump tables, but are the key C mechanism for implementing powerful design patterns like Strategy.

Key Takeaways:

  • The Strategy Pattern lets you define a family of algorithms, encapsulate them, and make them interchangeable at runtime.
  • It promotes the Open-Closed Principle, allowing you to add new behaviors (strategies) without modifying existing, tested code.
  • In C, the pattern is implemented using:
    • A typedef for a function pointer to define the Strategy Interface.
    • C functions matching the signature to act as Concrete Strategies.
    • A struct containing a function pointer member to serve as the Context.
  • This pattern decouples the "what" (the context's goal) from the "how" (the specific algorithm used), leading to cleaner, more modular, and safer code.

Preview of the Next Lesson:
We've seen how to swap between different algorithms that are all compiled into your application. What if you wanted to add a new algorithm without recompiling the main application at all? In our next lesson, we will explore how to design a simple plugin architecture using function pointers. This will take the concept of dynamic behavior to the next level, allowing for true runtime extensibility, a pattern often seen in highly modular and customizable software systems.