In my last post, I dusted off my C skills – it’s been a very long time – to demonstrate how we might have achieved encapsulation in C thirty years ago. (Indeed, this is how I was taught to do it by engineers who’d been working in C in the 1980s.)
That ticks one box for the software design principles I teach on the Codemanship course: modules should Tell, Don’t Ask.
What about the other design principles? Well, can we all agree that Simple Design applies in any programming language?
- C code should work
- C code should clearly communicate its intent
- C code should not repeat itself (unless repeating itself makes it easier to understand)
- C code should be composed out of the simplest parts
That leaves the S.O.L.I.D. principles from the course. Let’s go through them one letter at a time and see how (or if, or why) they could be applied in C.
Modules should only have one reason to change
In the example I used last time, we started with a carpet_quote module that “knew too much”. It calculated the area of a room based on the room’s dimensions, and it calculated a total price for a fitted carpet based on the carpet’s price per square metre, and whether we should round up to the nearest square metre.
What has calculating the total price got to do with how we calculate the area of the room? I can easily imagine wanting to change how we do either of those calculations independently of the other. For example, what about L-shaped rooms? What if we need to apply a discount for larger rooms? Arguably, these two pieces of logic belong in two separate modules. We split them up to help carpet_quote Tell, Don’t Ask, and here’s another good reason why we should have split them up.
Modules should be open to extension and closed to modification
In the refactored Tell, Don’t Ask version of the carpet quote example code, we ended up with carpet_quote using a room and a carpet module inside which the data and the details of the calculations were hidden. What if I wanted to extend this design to price carpets for circular rooms? carpet_quote binds directly to the current room.h header.
Whether or not I can swap in a different implementation without modifying carpet_quote.c will depend on what’s exposed in room.h.
There are two blockers here if we want the two kinds of room to co-exist in the same code: first, the “constructor” function new_room(). It’s signature would need to change for a circular room (e.g., new_room(float radius) ), and any modules importing room.h would be affected by that change.
What we need here is a clean separation of the abstractions of a room from the details of how the room is created.
So let’s define two implementations of room in separate modules, with their own constructor functions.
Note here that, because the Room struct is only implemented internally, we can implement it again in a circular_room.c file without any naming conflicts.
Rinse and repeat for a circular room.
So far, so good. But both of these modules can’t implement the area() function defined in room.h, or we get a naming conflict. How can we have two implementations of area() co-exist in a language that doesn’t explicitly support polymorphism?
The simplest solution is to use a function pointer in carpet_quote that matches the signature of area(), instead of directly invoking area().
Then rectangular_room and circular_room can have their own unique functions for calculating room area.
Now our client just needs to create the correct kind of Room and pass in the associated area calculation function.
This is, of course, nasty. We’re holding the client responsible for making this type safe. If we try to apply rectangular_area() to a circular room, we’ll get an error. How can we ensure that the right area function is applied to any room?
We’re in luck. If, in C, functions can be data, then structs can contain functions.
We can assign a reference to the appropriate area function inside each constructor.
Then we can re-write the quote() function to use the attached area() function.
Now the client doesn’t need to know anything about the area() function. It just decides what kind of room to create, as before.
This refactored design allows us to add new kinds of room (new kinds of dimensions) and new ways of calculating the area of carpet required without making any changes to the existing modules.
Yes: it’s hard work in C! But it can be done. It can be done in any programming language that supports function pointers.
An instance of any type can be substituted with an instance of any of its subtypes.
This design principle is all about contracts. If we define an abstraction for calculating the area of carpet required for a room, the client will have expectations about how to use that function and what they should get from it that must hold regardless of which implementation is being used at runtime.
In practical terms, if we wrote a contract test for the area() function – e.g., when the deminsions are positive numbers the output should be a positive number greater than or equal to a “base dimension” – then every implementation of area() must pass that test.
Modules should present client-specific interfaces
Taking carpet_quote.c as an example, it references room.h and carpet.h.
It needs to know about the type Room, and it needs to know a Room struct has an area() function. What’s exposed in room.h?
carpet_quote doesn’t need to know about dimensions. How could we refactor this so that carpet_quote is only exposed to what it uses? The honest answer is “not easily”. Once we’ve defined a Room struct with an area() function, we can’t redefine it to have additional features.
If we extracted Dimensions into it’s own .h file, we’d just have to include it here anyway. Importantly, the details of what Dimensions contains is hidden from carpet_quote, because we encapsulated the implementations inside rectangular_room.c and circular_room.c.
So 100% client-specific interfaces is tricky in C. But at least we can control what functions clients are exposed to through the use of header files, which gets us much of the way there. carpet_quote.c knows nothing about how rooms and carpets are created, knows nothing about what data they contain and doesn’t know about the room-specific functions for calculating areas.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions shouldn’t depend on details. Details shoud depend on abstractions.
The way to know if we have inverted dependencies is to examine the imports: what files does carpet_quote.c need to include?
Aside from its owner header file, it only imports the abstractions in room.h and carpet.h. This high-level modules doesn’t depend on low-level modules, nor does it depend on details.
When we use dependency injection to wire our collaborating modules together, the tendency is for the details – the dependencies on implementations – to bubble to the top of the call stack. Good modular architectures wear their implementation dependencies on the outside.
Examining the imports in main.c, this appears to be exactly what has happened.
We might then go through a similar process to abstract the way that carpet price are calculated. (I’ve left this for you to do.)
So there you have it. S.O.L.I.D. – for the most part – can be applied in C. And, back in the day, I routinely applied it when C++ was not an option. If you’ve got function pointers, you can SOLID.
You can view a complete copy of the finished code at https://github.com/jasongorman/solid_c