OOP and design patterns
Design is a decision
Functional proramming, imperative programming, object-oriented programming… In university courses the focus was on learning codeing principles like a linguist views languages. There, the focus is often correctionistic, about syntax, lexical integrity, correct sematics, and, how expressive a given language is. Constructing an expressive langauge is an art of it’s own, do not concider me an expert on the subject, but still, there are patterns we follow as developers when designing software that we typically orient towards.
Let’s talk about OOP for a moment.
The thing is that the patterns OOP puts out explicitly, say for example polymorphism and inheritance, are embedded to language itself, say Python, Java or C++. A programmer with root in an OOP language, jumping to for example C, quickly realizes that the syntax of the language doesn’t imply the same kind of structure. The programmer becomes both dazzled and frightened by the amount of freedom given by a imperative, multi-paradigm language that C is.
In C it is now the responsibility of the programmer to establish the conventions and patterns used in the software design. Say goodbye intersystem modularity (and hello to unbounded performance)!
What I want to focus on in this post is the fact that we still want to enforce certain software design patterns, even though one is not excplicitly confined by any. And in fact, the OOP patterns of higher-level languages can be used in a C-style way. In the following sections we’ll briefly go through 13 different design patterns:
- Object pattern
- Opaque pattern
- Singleton pattern
- Factory pattern
- Callback pattern
- Inheritance pattern
- Bridge pattern
- Return value pattern
- Concurrency pattern
- Spinlock pattern
- Semaphore pattern
- Mutex pattern
- Conditional pattern
Design patterns, in C
Object pattern
In object pattern all function calls and variables are encapsulated inside a single object. This ensures a code section is only modified through an abtract interface. The object design makes it possible to create several instances of an object, given a “object blueprint”. Take for example a global logger object versus a subsystem specific logger.
One way to implement this in C is:
my_logger.h
#ifndef MY_LOGGER_H
#define MY_LOGGER_H
#include <stddef.h>
#include <stdint.h>
#define LOGGER_BUF_SIZE 64u
enum logger_level {
LOGGER_ERROR = 0,
LOGGER_WARN,
LOGGER_INFO,
LOGGER_DEBUG,
};
/* The "blueprint". Every instance carries its own state. */
struct logger {
const char *prefix;
enum logger_level level;
uint32_t written;
uint32_t dropped;
char buf[LOGGER_BUF_SIZE];
};
int32_t logger_init(struct logger *self, const char *prefix, enum logger_level level);
int32_t logger_set_level(struct logger *self, enum logger_level level);
int32_t logger_log(struct logger *self, enum logger_level level, const char *msg);
const char *logger_last(const struct logger *self);
#endif /* LOGGER_H */
The implementation:
#include "logger.h"
#include <stdio.h>
#include <string.h>
static const char *const level_tag[] = { "E", "W", "I", "D" };
int32_t logger_init(struct logger *self, const char *prefix, enum logger_level level)
{
if (self == NULL || prefix == NULL) {
return -1;
}
memset(self, 0, sizeof(*self));
self->prefix = prefix;
self->level = level;
return 0;
}
int32_t logger_set_level(struct logger *self, enum logger_level level)
{
if (self == NULL || level > LOGGER_DEBUG) {
return -1;
}
self->level = level;
return 0;
}
int32_t logger_log(struct logger *self, enum logger_level level, const char *msg)
{
if (self == NULL || msg == NULL || level > LOGGER_DEBUG) {
return -1;
}
if (level > self->level) {
self->dropped++;
return 0;
}
int n = snprintf(self->buf, sizeof(self->buf), "[%s] %s: %s",
level_tag[level], self->prefix, msg);
if (n < 0) {
return -1;
}
self->written++;
return n;
}
const char *logger_last(const struct logger *self)
{
return (self != NULL) ? self->buf : "";
}
#include <stdio.h>
#include "logger.h"
int main(void)
{
struct logger sys_log;
struct logger motor_log;
logger_init(&sys_log, "sys", LOGGER_INFO);
logger_init(&motor_log, "motor", LOGGER_DEBUG);
logger_log(&sys_log, LOGGER_INFO, "boot complete");
logger_log(&sys_log, LOGGER_DEBUG, "this one is dropped");
logger_log(&motor_log, LOGGER_DEBUG, "pwm duty 42 %");
printf("%s\n", logger_last(&sys_log));
printf("%s\n", logger_last(&motor_log));
printf("sys: written=%u dropped=%u\n", sys_log.written, sys_log.dropped);
return 0;
}
The object functions (kinda like methods) act on a given data structure with pointer reference *self, which is a literally a struct base pointer. In object pattern, the a function acting on an object struct shouldn’t access global variables nor should call other functions that access global variables. So, encapsulation. Respect the pattern, and all function effect stay contained in object structs. See: ARM’s CMSIS implementation where a peripheral driver is defined as a struct.
Opaque pattern
As the name suggests, in the opaque pattern the definition of the data structure is hidden from the user. Only a forward declaration struct my_obj; is visible in the header, and the client works through a pointer to it. This comes with several benefits:
- Compiler-enforced encapsulation. my_obj->var = 0 no longer compiles, so the module’s invariants can only be broken by the module itself.
- Binary compatibility.
libchands outFILE *. Because clients never see the size or the layout ofFILE *itself, the library can change without the clients recompiling. - Swappable implementations. A client can link to different implementations. This implies test mocks, POSIX ports, register abstaction layers.
- Controlled lifetime. The client cannot define an instance of a type whose size is uknown:
struct my_opaque_obj. Instead an initialization function likemy_opaque_obj_create()is enforced. Think C++ and RAII.
TSince every access goes through the module’s functions, there is a single place to add bounds checks, tracing or a lock. Where the object is allocated is hidden too, which is sometimes good and sometimes bad: the client cannot put it on the stack, in DMA-capable RAM, or know its footprint.
Let’s see the logger using opaque pattern:
my_opaque_logger.h
#ifndef LOGGER_H
#define LOGGER_H
#include <stdint.h>
enum logger_level {
LOGGER_ERROR = 0,
LOGGER_WARN,
LOGGER_INFO,
LOGGER_DEBUG,
};
/* Incomplete type: callers only ever hold a pointer to it. */
struct logger;
struct logger *logger_create(const char *prefix, enum logger_level level);
void logger_destroy(struct logger *self);
int32_t logger_set_level(struct logger *self, enum logger_level level);
int32_t logger_log(struct logger *self, enum logger_level level, const char *msg);
const char *logger_last(const struct logger *self);
uint32_t logger_dropped(const struct logger *self);
#endif /* LOGGER_H */
And the implementation. This would be an accompanied .a or a .so library.
#include "logger.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#define LOGGER_BUF_SIZE 64u
#define LOGGER_MAX_COUNT 4u
/* Full definition is visible only here. Fields can change without
* touching any caller. */
struct logger {
bool in_use;
const char *prefix;
enum logger_level level;
uint32_t written;
uint32_t dropped;
char buf[LOGGER_BUF_SIZE];
};
/* Callers cannot allocate a struct whose size they do not know,
* so the module owns the storage. */
static struct logger pool[LOGGER_MAX_COUNT];
static const char *const level_tag[] = { "E", "W", "I", "D" };
struct logger *logger_create(const char *prefix, enum logger_level level)
{
if (prefix == NULL || level > LOGGER_DEBUG) {
return NULL;
}
for (size_t i = 0; i < LOGGER_MAX_COUNT; i++) {
if (!pool[i].in_use) {
struct logger *self = &pool[i];
memset(self, 0, sizeof(*self));
self->in_use = true;
self->prefix = prefix;
self->level = level;
return self;
}
}
return NULL; /* pool exhausted */
}
void logger_destroy(struct logger *self)
{
if (self != NULL) {
memset(self, 0, sizeof(*self)); /* also clears in_use */
}
}
int32_t logger_set_level(struct logger *self, enum logger_level level)
{
if (self == NULL || level > LOGGER_DEBUG) {
return -1;
}
self->level = level;
return 0;
}
int32_t logger_log(struct logger *self, enum logger_level level, const char *msg)
{
if (self == NULL || msg == NULL || level > LOGGER_DEBUG) {
return -1;
}
if (level > self->level) {
self->dropped++;
return 0;
}
int n = snprintf(self->buf, sizeof(self->buf), "[%s] %s: %s",
level_tag[level], self->prefix, msg);
if (n < 0) {
return -1;
}
self->written++;
return n;
}
const char *logger_last(const struct logger *self)
{
return (self != NULL) ? self->buf : "";
}
uint32_t logger_dropped(const struct logger *self)
{
return (self != NULL) ? self->dropped : 0u;
}
In a context:
#include <stdio.h>
#include "logger.h"
int main(void)
{
struct logger *sys_log = logger_create("sys", LOGGER_INFO);
struct logger *motor_log = logger_create("motor", LOGGER_DEBUG);
logger_log(sys_log, LOGGER_INFO, "boot complete");
logger_log(sys_log, LOGGER_DEBUG, "this one is dropped");
logger_log(motor_log, LOGGER_DEBUG, "pwm duty 42 %");
printf("%s\n", logger_last(sys_log));
printf("%s\n", logger_last(motor_log));
printf("sys dropped=%u\n", logger_dropped(sys_log));
/* sys_log->dropped = 0; error: invalid use of incomplete type */
logger_destroy(motor_log);
logger_destroy(sys_log);
return 0;
}
The difference here is subtle, but meaningful. In the object, the struct definitions is in the header, and in opaque, in the .c file.
Singleton pattern
In a sense, a global variable, say a counter, implements a singleton pattern. Expanding on that, in some cases, it makes sense to have only one instance of an object. Given some system, if it is not acceptable, under any circumstance, to have a duplicate of an object, then singleton is the right design choise. This is the case for:
- Hardware that exists once. A systick timer, a watchdog, the interrupt controller. Note that some systems have multiple controllers, like uarts.
- System-wide services. Say a scheduler algorithm, heap allocation, non-volatile memory storage, single sink, like a console.
- Globally consistant variables. A monotonic clock, boot and trace counters.
A subsystem logger is an object. A global logger is a singleton.
Let’s look at a systick implemetation.
systick.h
#ifndef SYSTICK_H
#define SYSTICK_H
#include <stdint.h>
/* No self pointer anywhere: the one instance is implicit. */
void systick_init(void);
uint32_t systick_now(void);
void systick_isr(void); /* called from the timer interrupt */
#endif /* SYSTICK_H */
systick.c
#include "systick.h"
#include <stdbool.h>
/* The single instance lives here, invisible to callers. */
static volatile uint32_t ticks;
static bool initialized;
void systick_init(void)
{
if (initialized) {
return; /* second init is a no-op, not a second instance */
}
ticks = 0;
/* SysTick->LOAD = ...; SysTick->CTRL = ...; */
initialized = true;
}
uint32_t systick_now(void)
{
return ticks;
}
void systick_isr(void)
{
ticks++;
}
Having only one way to fetch the logger intance, from the previous example, would then make it a singleton. Note that eager initialization is prefered with singleton, so the first callers don’t race the initialization function.
As an eye opener, singleton patterns tend to happen accidentally. When some object, be it in any language, has a member, that every other object is interested of, an accidental singleton can be mistakenly created, also known as tight coupling. This can be an actual variable, or a handler to some sub-component. When a lot of objects directly access a member of some other object, modifying the central member breaks the referencing “clients”.
Factory pattern
Concisely, a factory is a function that turns a description of what is needed into a ready object, hiding which kind was chosen and how it was built. In languages with built-in polymorphism the factory simply returns a base-class reference and the language dispatches to the right variant. C has no such mechanism, so the factory and its callers must agree on a common handle type, built by hand.
logger_factory.h
#ifndef LOGGER_FACTORY_H
#define LOGGER_FACTORY_H
#include "logger.h"
enum subsystem {
SUBSYS_SYSTEM = 0,
SUBSYS_MOTOR,
SUBSYS_COOLER,
SUBSYS_COUNT,
};
struct logger *logger_for(enum subsystem id);
#endif /* LOGGER_FACTORY_H */
logger_factory.c
#include "logger_factory.h"
#include <stddef.h>
static const struct {
const char *prefix;
enum logger_level level;
} config[SUBSYS_COUNT] = {
[SUBSYS_SYSTEM] = { "sys", LOGGER_INFO },
[SUBSYS_MOTOR] = { "motor", LOGGER_DEBUG },
[SUBSYS_COOLER] = { "cooler", LOGGER_WARN },
};
static struct logger *cache[SUBSYS_COUNT];
struct logger *logger_for(enum subsystem id)
{
if (id >= SUBSYS_COUNT) {
return NULL;
}
if (cache[id] == NULL) {
cache[id] = logger_create(config[id].prefix, config[id].level);
}
return cache[id];
}
Usage:
logger_log(logger_for(SUBSYS_MOTOR), LOGGER_DEBUG, "pwm duty 42 %");
logger_log(logger_for(SUBSYS_COOLER), LOGGER_DEBUG, "dropped, cooler is WARN");
A factory is also easily scalable. Adding another logger type in the example above is straight forward. This factory selects a configuration rather than a kind, but the shape is the same: adding a subsystem is one enum entry and one table row, and no caller changes. A factory that returns genuinely different kinds behind one handle needs the inheritance pattern, which we get to below.
Callback pattern
In some case, having a single callback function, with one definition makes sense. This inverts the direction of a call. Usually a module calls down into things it depends on. With a callback, the module is handed a callback pointer by its caller and calls up into code it has never seen. The module defines the signature, the caller provides the body. This explains, how generic modules stay generic. A Sort<T> can sort any types, given it has the callback functions. An interrupt handler table dispatched to code the vendor never wrote.
In C, the callback implemetation uses void pointers. Note, that a bare function pointer has no context, so a caller has no idea who made the call. For this reason, a void *ctx is stored by the caller at registration, and returned to the caller on every call. This is the contract between the two end points, so every caller knows the callback execution was turthfully put out by the exact caller.
Continuing the logger examples, one might want to choose, where the logger stores its output.
logger.h
/* The callback type: what the logger will call to get a line out.
* `ctx` is handed back untouched so the callee can find its own state. */
typedef int32_t (*logger_sink_fn)(void *ctx, const char *line, size_t len);
int32_t logger_set_sink(struct logger *self, logger_sink_fn fn, void *ctx);
logger.c
struct logger {
/* ... */
logger_sink_fn sink; /* where lines go */
void *sink_ctx; /* opaque to the logger */
char buf[LOGGER_BUF_SIZE];
};
int32_t logger_set_sink(struct logger *self, logger_sink_fn fn, void *ctx)
{
if (self == NULL) {
return -1;
}
self->sink = fn;
self->sink_ctx = ctx;
return 0;
}
int32_t logger_log(struct logger *self, enum logger_level level, const char *msg)
{
/* ... level check and snprintf into self->buf as before ... */
if (self->sink == NULL) {
return 0; /* no sink registered: silently drop */
}
/* The logger knows nothing about UARTs, files or ring buffers.
* It just calls back. */
return self->sink(self->sink_ctx, self->buf, len);
}
main.c
/* Sink 1: stdio. ctx is a FILE *. */
static int32_t sink_stdio(void *ctx, const char *line, size_t len)
{
return (int32_t)fwrite(line, 1, len, (FILE *)ctx);
}
/* Sink 2: a ring buffer owned by the caller. ctx is the ring. */
struct ring {
char data[128];
size_t head;
};
static int32_t sink_ring(void *ctx, const char *line, size_t len)
{
struct ring *r = ctx;
for (size_t i = 0; i < len; i++) {
r->data[r->head] = line[i];
r->head = (r->head + 1) % sizeof(r->data);
}
return (int32_t)len;
}
int main(void)
{
struct ring motor_ring = { .head = 0 };
struct logger *sys_log = logger_create("sys", LOGGER_INFO);
struct logger *motor_log = logger_create("motor", LOGGER_DEBUG);
logger_set_sink(sys_log, sink_stdio, stdout); /* same logger code ... */
logger_set_sink(motor_log, sink_ring, &motor_ring); /* ... two destinations */
logger_log(sys_log, LOGGER_INFO, "boot complete");
logger_log(motor_log, LOGGER_DEBUG, "pwm duty 42 %");
return 0;
}
One logger logs to a ring, the other to a file. With a typedef int32_t (*logger_sink_fn)(void *, const char *, size_t); the code becomes more readable. A sink, that logs inside its callback, recurses into the logger.
A message type handler is a common use of callbacks. Or an event in an event loop. Modularity comes free.
The opaque pattern keeps the caller from seeing inside the module, and the callback pattern keeps the module from seeing outside itself. Together they give a module with a hard boundary in both directions.
Still uncovered patterns
Inheritance pattern
TBD
Bridge pattern
TBD
Return value pattern
TBD
Concurrency pattern
TBD
Spinlock pattern
TBD
Semaphore pattern
TBD
Mutex pattern
TBD
Conditional pattern
TBD