Applying SOLID is not always appropriate. Dependency inversion implies some indirection, and that typically means overhead. This kind of overhead is unlikely to be appropriate in memory-constrained devices. But not all is lost: we can implement relevant OOP functionality in C, but we might also find that using the preprocessor provides enough flexibility.
A typical dependency inversion example refactors this kind of code:
class Dependency {
int concreteStuff;
}
class Context {
Dependency d;
void doSomething() {
print(d.concreteStuff);
}
}
new Context(new Dependency()).doSomething();
To:
interface Interface {
int getConcreteStuff();
}
class Dependency implements Interface {
int concreteStuff;
int getConcreteStuff() { return this.concreteStuff; }
}
class Context {
Interface i;
void doSomething() {
print(i.getConcreteStuff());
}
}
new Context(new Dependency()).doSomething();
While C doesn't have interfaces in the Java sense, one option is to implement this OOP-like functionality (run-time polymorphism) ourselves:
// interface:
typedef struct {
void* data;
int (*getConcreteStuff)(Interface*);
} Interface;
// dependency:
typedef struct {
int concreteStuff;
} Dependency;
static int getConcreteStuff(Interface* interface) {
return ((Dependency*)interface->data)->concreteStuff;
}
Interface Dependency_new() {
Dependency* d = malloc(sizeof(*d));
d->concreteStuff = 0;
return { d, getConcreteStuff };
}
// context:
typedef struct {
Interface i;
} Context;
void Context_doSomething(Context* ctx) {
printf("%d\n", ctx->i.getConcreteStuff(&ctx->i));
}
// composition
Context ctx = { Dependency_new() };
Context_doSomething(&ctx);
The Interface
represents a classic vtable that stores function pointers to the interface methods. In simple cases where you only have a few function pointers you can do away with the explicit interface and store the pointers directly in the context. The context does not know anything about the concrete dependency, and only interacts with it through the interface function pointers – the actual dependency is hidden behind a void pointer. In all cases, the concrete dependency is resolved during composition, and can be freely selected at run time.
So this kind of approach is appropriate when you do need the ability to select different dependencies at run time, or when you don't know all possible interface implementations (e.g. when you are writing a library to be extended by other applications).
But that kind of runtime flexibility is not always needed! Especially in an embedded context, you might be able to resolve the dependencies at build time and then flash the appropriate configuration. You also likely know all possible dependencies in advance. Then, the most C-ish approach is to use the preprocessor.
For example, you might use the preprocessor to select the correct definitions for structs and functions
#ifdef DEPENDENCY = "TEST"
typedef struct {} Dependency;
int getConcreteStuff(Dependency*) { return 42; }
#else
typedef struct {
int concreteStuff;
} Dependency;
int getConcreteStuff(Dependency* d) { return d->concreteStuff; }
#endif
typedef struct {
Dependency d;
} Context;
void doSomething(Context* ctx) {
printf("%d\n", getConcreteStuff(&ctx->d));
}
Alternatively, you might compile all dependencies and use the preprocessor to name the correct dependency:
// invoke compiler with -DDependency=TestDependency to use this implementation
typedef struct {} TestDependency;
int TestDependency_getConcreteStuff(TestDependency*) {
return 42;
}
typedef struct {
int concreteStuff;
} StandardDependency;
int StandardDependency_getConcreteStuff(StandardDependency* d) {
return d->concreteStuff;
}
// default to StandardDependency
#ifndef Dependency
#define Dependency StandardDependency
#endif
// helper to call functions with correct name
#define METHOD(m) Dependency ## _ ## m;
typedef struct {
Dependency d;
} Context;
void doSomething(Context* ctx) {
printf("%d\n", METHOD(getConcreteStuff)(&ctx->d));
}
I prefer this latter approach because all the code is still compiled and type checked, thus guarding against bitrot. The extra generated machine code can be optimized away to save space, either if the dependency function are inline, have internal linkage, or by using link time optimization.