Table of Contents
KEY TAKEAWAYS
- OCP: open for extension, closed for modification
- In C, use function pointers and registration tables to achieve OCP
- The core handler/dispatcher never changes; new behavior is added by registering new entries
- This is the same pattern used in real device drivers and protocol stacks
What Is the Open/Closed Principle?
The Open/Closed Principle (OCP) states: software entities should be open for extension, but closed for modification.This means you should be able to add new behavior without changing existing, tested code. In C, we achieve this primarily through function pointers and registration patterns.Example 1: Shape Area Calculator
Bad: Adding a new shape requires editing the function
typedef enum { SHAPE_CIRCLE, SHAPE_RECTANGLE } shape_type_t;
typedef struct {
shape_type_t type;
float param1; /* radius or width */
float param2; /* 0 or height */
} shape_t;
float calculate_area(shape_t *shape) {
switch (shape->type) {
case SHAPE_CIRCLE:
return 3.14159f * shape->param1 * shape->param1;
case SHAPE_RECTANGLE:
return shape->param1 * shape->param2;
/* To add triangle, you MUST edit this function */
default:
return 0;
}
}Every new shape requires modifying calculate_area. If this function is tested and deployed, editing it risks introducing bugs in the existing shapes.Good: New shapes added without touching existing code
/* Each shape carries its own area function */
typedef struct {
void *data;
float (*area)(void *data);
} shape_t;
/* Circle */
typedef struct { float radius; } circle_t;
float circle_area(void *data) {
circle_t *c = (circle_t *)data;
return 3.14159f * c->radius * c->radius;
}
/* Rectangle */
typedef struct { float width, height; } rectangle_t;
float rectangle_area(void *data) {
rectangle_t *r = (rectangle_t *)data;
return r->width * r->height;
}
/* Adding a triangle - no existing code modified! */
typedef struct { float base, height; } triangle_t;
float triangle_area(void *data) {
triangle_t *t = (triangle_t *)data;
return 0.5f * t->base * t->height;
}
/* Generic function - never needs to change */
float calculate_area(shape_t *shape) {
return shape->area(shape->data);
}
/* Usage */
circle_t my_circle = { .radius = 5.0f };
shape_t s1 = { &my_circle, circle_area };
triangle_t my_tri = { .base = 4.0f, .height = 3.0f };
shape_t s2 = { &my_tri, triangle_area };
printf("Circle area: %.2f\n", calculate_area(&s1));
printf("Triangle area: %.2f\n", calculate_area(&s2));calculate_area is closed for modification (never changes) but open for extension (works with any new shape that provides an area function).Example 2: Protocol Handler with Pluggable Commands
Bad: Every new command requires editing the handler
void handle_command(uint8_t cmd, uint8_t *payload, int len) {
switch (cmd) {
case 0x01:
handle_ping(payload, len);
break;
case 0x02:
handle_read_sensor(payload, len);
break;
case 0x03:
handle_set_led(payload, len);
break;
/* Must add new cases here for every new command */
default:
send_error(cmd, ERR_UNKNOWN);
break;
}
}Good: Register new commands without modifying the handler
typedef void (*cmd_handler_fn)(uint8_t *payload, int len);
typedef struct {
uint8_t cmd_id;
cmd_handler_fn handler;
} cmd_entry_t;
#define MAX_COMMANDS 16
static cmd_entry_t cmd_table[MAX_COMMANDS];
static int cmd_count = 0;
/* Registration function - any module can add commands */
void register_command(uint8_t cmd_id, cmd_handler_fn handler) {
if (cmd_count < MAX_COMMANDS) {
cmd_table[cmd_count].cmd_id = cmd_id;
cmd_table[cmd_count].handler = handler;
cmd_count++;
}
}
/* Handler - never needs to change */
void handle_command(uint8_t cmd, uint8_t *payload, int len) {
for (int i = 0; i < cmd_count; i++) {
if (cmd_table[i].cmd_id == cmd) {
cmd_table[i].handler(payload, len);
return;
}
}
send_error(cmd, ERR_UNKNOWN);
}
/* In sensor.c - registers itself */
void sensor_init(void) {
register_command(0x02, handle_read_sensor);
}
/* In led.c - registers itself */
void led_init(void) {
register_command(0x03, handle_set_led);
}Adding a new command (e.g., motor control) means creating a new file and calling register_command — without touching the command handler or any other module.When to Apply OCP
- When you have a growing
switchorif-elsechain that keeps getting new cases - When multiple developers need to add features to the same module
- When you want plug-in or driver-style architecture
When NOT to Over-Apply
- If your
switchhas 3 cases and will never grow, a simple switch is fine - Over-abstraction with function pointers everywhere makes code harder to follow
- Apply OCP when you see the pattern of “I keep editing this same function to add new types”
Key Takeaways
- OCP: open for extension, closed for modification
- In C, use function pointers and registration tables to achieve OCP
- The core handler/dispatcher never changes; new behavior is added by registering new entries
- This is the same pattern used in real device drivers and protocol stacks
Example 3: Extensible Command Parser
Embedded systems and CLI tools often need to parse and execute commands. A common mistake is building a giant switch statement that must be modified for every new command.Bad — Switch Statement Grows Forever
void execute_command(const char *cmd, const char *args) {
if (strcmp(cmd, "help") == 0) {
print_help();
} else if (strcmp(cmd, "status") == 0) {
print_status();
} else if (strcmp(cmd, "reset") == 0) {
do_reset();
} else if (strcmp(cmd, "config") == 0) {
set_config(args);
}
// Every new command = modify this function
}Good — Registration-Based Command Table
typedef struct {
const char *name;
void (*handler)(const char *args);
} Command;
static Command commands[32];
static int cmd_count = 0;
void register_command(const char *name, void (*handler)(const char *)) {
commands[cmd_count].name = name;
commands[cmd_count].handler = handler;
cmd_count++;
}
void execute_command(const char *cmd, const char *args) {
for (int i = 0; i < cmd_count; i++) {
if (strcmp(commands[i].name, cmd) == 0) {
commands[i].handler(args);
return;
}
}
printf("Unknown command: %s\n", cmd);
}
// Adding commands — no modification to execute_command needed
void init_commands(void) {
register_command("help", cmd_help);
register_command("status", cmd_status);
register_command("reset", cmd_reset);
register_command("config", cmd_config);
}
// New module can simply call register_command() — OCP satisfiedEach module registers its own commands. The parser never changes. New features are added by writing new command handlers and calling register_command() — the core is closed for modification but open for extension.OCP in Practice: When to Apply
- Apply OCP when a function or module changes frequently because new variants are added (new protocols, new commands, new file formats). A registration or function-pointer approach pays off quickly.
- Skip OCP for code that rarely changes or has only 2-3 cases. A simple
if/elseorswitchis fine when the list is short and stable. Over-engineering a plugin system for two cases violates KISS.

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.







