C is for S.O.L.I.D.

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.

 

Single Responsibility

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.

float quote(struct Room *room, struct Carpet *carpet){
float area = room->length * room->width;
if(carpet->roundUp){
area = ceil(area);
}
return area * carpet->pricePerSqrMetre;
}

view raw
carpet_quote.c
hosted with ❤ by GitHub

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.

 

Open Closed

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.

#include "carpet_quote.h"
#include "room.h"
#include "carpet.h"
float quote(struct Room *room, struct Carpet *carpet){
return price(carpet, area(room));
}

view raw
carpet_quote.c
hosted with ❤ by GitHub

Whether or not I can swap in a different implementation without modifying carpet_quote.c will depend on what’s exposed in room.h.

#ifndef ENCAPSULATION_ROOM_H
#define ENCAPSULATION_ROOM_H
typedef struct Room Room;
Room* new_room(float width, float length);
float area(struct Room *room);
#endif //ENCAPSULATION_ROOM_H

view raw
room.h
hosted with ❤ by GitHub

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.

#ifndef ENCAPSULATION_RECTANGULAR_ROOM_H
#define ENCAPSULATION_RECTANGULAR_ROOM_H
#include "room.h"
Room* new_rectangular_room(float width, float length);

view raw
rectangular_room.h
hosted with ❤ by GitHub

#include <stdlib.h>
#include "rectangular_room.h"
struct Room {
float width;
float length;
};
Room* new_rectangular_room(float width, float length){
Room* room = malloc(sizeof(Room));
room->width = width;
room->length = length;
return room;
}

view raw
rectangular_room.c
hosted with ❤ by GitHub

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.

#ifndef ENCAPSULATION_CIRCULAR_ROOM_H
#define ENCAPSULATION_CIRCULAR_ROOM_H
#include "room.h"
Room* new_circular_room(float radius);

view raw
circular_room.h
hosted with ❤ by GitHub

struct Room {
float radius;
};
Room* new_circular_room(float radius){
Room* room = malloc(sizeof(Room));
room->radius = radius;
return room;
}

view raw
circular_room.c
hosted with ❤ by GitHub

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().

float quote(struct Room *room, float (*area)(Room*), struct Carpet *carpet){
return price(carpet, area(room));
}

view raw
carpet_quote.c
hosted with ❤ by GitHub

Then rectangular_room and circular_room can have their own unique functions for calculating room area.

float rectangular_area(struct Room *room) {
return room->length * room->width;
}

view raw
rectangular_room.c
hosted with ❤ by GitHub

float circular_area(struct Room *room) {
/* a circular room requires cutting a square carpet of length/width 2 * radius */
return (2 * room->radius) * (2 * room->radius);
}

view raw
circular_room.c
hosted with ❤ by GitHub

Now our client just needs to create the correct kind of Room and pass in the associated area calculation function.

int main() {
Carpet* carpet = new_carpet(10.0, false);
Room* rectangular_room = new_rectangular_room(2.5, 2.5);
float total = quote(rectangular_room, &rectangular_area , carpet);
printf("Price of rectangular carpet 2.5m x 2.5m at 10.0 p/sqm with no rounding up is %g\n", total);
Room* circular_room = new_circular_room(2.5);
total = quote(circular_room, &circular_area, carpet);
printf("Price of circular carpet of radius 2.5m at 10.0 p/sqm with no rounding up is %g\n", total);
return 0;
}

view raw
main.c
hosted with ❤ by GitHub

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.

#ifndef ENCAPSULATION_ROOM_H
#define ENCAPSULATION_ROOM_H
typedef struct Dimensions Dimensions;
typedef struct Room Room;
struct Room {
Dimensions *dimensions;
float (*area)(struct Room*);
};
#endif //ENCAPSULATION_ROOM_H

view raw
room.h
hosted with ❤ by GitHub

We can assign a reference to the appropriate area function inside each constructor.

struct Dimensions {
float width;
float length;
};
Room* new_rectangular_room(float width, float length){
Dimensions* dimensions = malloc(sizeof(Dimensions));
Room* room = malloc(sizeof(Room));
dimensions->width = width;
dimensions->length = length;
room->dimensions = dimensions;
room->area = &rectangular_area;
return room;
}
float rectangular_area(struct Room *room) {
Dimensions *dimensions = room->dimensions;
return dimensions->length * dimensions->width;
}

view raw
rectangular_room.c
hosted with ❤ by GitHub

struct Dimensions {
float radius;
};
struct Room* new_circular_room(float radius){
Dimensions* dimensions = malloc(sizeof(Dimensions));
dimensions->radius = radius;
Room* room = malloc(sizeof(Room));
room->dimensions = dimensions;
room->area = &circular_area;
return room;
}
float circular_area(struct Room *room) {
/* a circular room requires cutting a square carpet of length/width 2 * radius */
float radius = room->dimensions->radius;
return (2 * radius) * (2 * radius);
}

view raw
circular_room.c
hosted with ❤ by GitHub

Then we can re-write the quote() function to use the attached area() function.

float quote(Room *room, struct Carpet *carpet){
return price(carpet, room->area(room));
}

view raw
carpet_quote.c
hosted with ❤ by GitHub

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.

 

Liskov Substitution

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.

void test_area(float base_dimension, Room *room){
assert(room->area(room) >= (base_dimension));
}
int main() {
test_area(2.5, new_rectangular_room(2.5, 2.5));
test_area(2.5, new_circular_room(2.5));
return 0;
}

view raw
main.c
hosted with ❤ by GitHub

 

Interface Segregation

Modules should present client-specific interfaces

Here we’re talking about what modules make visible to the client modules that use them. This is more important in a statically-typed language like C than it is in dynamically-typed languages like JavaScript. I guess the advice here is to make good use of .h files to control what clients see.

Taking carpet_quote.c as an example, it references room.h and carpet.h.

#include "carpet_quote.h"
#include "room.h"
#include "carpet.h"
float quote(Room *room, struct Carpet *carpet){
return price(carpet, room->area(room));
}

view raw
carpet_quote.c
hosted with ❤ by GitHub

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?

typedef struct Dimensions Dimensions;
typedef struct Room Room;
struct Room {
Dimensions *dimensions;
float (*area)(struct Room*);
};

view raw
room.h
hosted with ❤ by GitHub

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.

 

Dependency Inversion

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.

Putting it all together, we’ve achieved a kind of dependency inversion in the way that carpet_quote only depends on an abstract definition of a Room. The details of that room’s internal dimensions, and the way we calculate the area of carpet required to fill it, are hidden from carpet_quote. Room could be thought of as an abstract class in this sense, and implementations of are injected as an argument of the quote() function. (Or, as we did before, we can inject the area() function implementation directly. This is very similar to the way we achieved dependency inversion in functional JavaScript or Ruby in previous posts.)

The way to know if we have inverted dependencies is to examine the imports: what files does carpet_quote.c need to include?

#include "carpet_quote.h"
#include "room.h"
#include "carpet.h"

view raw
carpet_quote.c
hosted with ❤ by GitHub

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.

#include <stdio.h>
#include <string.h>
#include "carpet_quote.h"
#include "rectangular_room.h"
#include "circular_room.h"
#include "bool.h"
#include <assert.h>
void test_area(float base_dimension, Room *room){
assert(room->area(room) >= (base_dimension));
}
int main() {
test_area(2.5, new_rectangular_room(2.5, 2.5));
test_area(2.5, new_circular_room(2.5));
return 0;
}

view raw
main.c
hosted with ❤ by GitHub

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

Author: codemanship

Founder of Codemanship Ltd and code craft coach and trainer

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s