Question

I know SOLID principles were written for object oriented languages.

I found in the book: "Test driven development for embedded C" by Robert Martin, the following sentence in the last chapter of the book:

"Applying the Open-Closed Principle and the Liskov Substitution Principle makes more flexible designs."

As this is a book of C (no c++ or c#), there should be a way of way of implementing this principles.

There exists any standard way for implementing this principles in C?

Was it helpful?

Solution

Open-closed principle states that a system should be designed so that it's open to extension while keeping it closed from modification, or that it can be used and extended without modifying it. An I/O subsystem, as mentioned by Dennis, is a fairly common example: in a reusable system the user should be able to specify how data is read and written instead of assuming that data can only be written to files for example.

The way to implement this depends on your needs: You may allow the user to pass in an open file descriptor or handle, which already enables the use of sockets or pipes in addition to files. Or you may allow the user to pass in pointers to the functions that should be used for reading and writing: this way your system could be used with encrypted or compressed data streams in addition to what the OS permits.

The Liskov substitution principle states that it should always be possible to replace a type with a subtype. In C you don't often have subtypes, but you can apply the principle in the module level: code should be designed so that using an extended version of a module, like a newer version, should not break it. An extended version of a module may use a struct that has more fields than the original, more fields in an enum, and similar things, so your code shouldn't assume that a struct that is passed in has a certain size, or that enum values have a certain maximum.

One example of this is how socket addresses are implemented in the BSD socket API: there an "abstract" socket type struct sockaddr that can stand for any socket address type, and a concrete socket type for each implementation, such as struct sockaddr_un for Unix domain sockets and struct sockaddr_in for IP sockets. Functions that work on socket addresses have to be passed a pointer to the data and the size of the concrete address type.

OTHER TIPS

First, it helps to think about why we have these design principles. Why does following the SOLID principles make software better? Work to understand the goals of each principle, and not just the specific implementation details required to use them with a specific language.

  • The Single Responsibility Principle improves modularity by increasing cohesion; better modularity leads to improved testability, usability, and reusability.
  • The Open/Closed Principle enables asynchronous deployment by decoupling implementations from each other.
  • The Liskov Substitution Principle promotes modularity and reuse of modules by ensuring the compatibility of their interfaces.
  • The Interface Segregation Principle reduces coupling between unrelated consumers of the interface, while increasing readability and understandability.
  • The Dependency Inversion Principle reduces coupling, and it strongly enables testability.

Notice how each principle drives an improvement in a certain attribute of the system, whether it be higher cohesion, looser coupling, or modularity.

Remember, your goal is to produce high quality software. Quality is made up of many different attributes, including correctness, efficiency, maintainability, understandability, etc. When followed, the SOLID principles help you get there. So once you've got the "why" of the principles, the "how" of the implementation gets a lot easier.

EDIT:

I'll try to more directly answer your question.

For the Open/Close Principle, the rule is that both the signature and the behavior of the old interface must remain the same before and after any changes. Don't disrupt any code that is calling it. That means it absolutely takes a new interface to implement the new stuff, because the old stuff already has a behavior. The new interface must have a different signature, because it offers the new and different functionality. So you meet those requirements in C just the same way as you'd do it in C++.

Let's say you have a function int foo(int a, int b, int c) and you want to add a version that's almost exactly the same, but it takes a fourth parameter, like this: int foo(int a, int b, int c, int d). It's a requirement that the new version be backward compatible with the old version, and that some default (such as zero) for the new parameter would make that happen. You'd move the implementation code from old foo into new foo, and in your old foo you'd do this: int foo(int a, int b, int c) { return foo(a, b, c, 0);} So even though we radically transformed the contents of int foo(int a, int b, int c), we preserved its functionality. It remained closed to change.

The Liskov substitution principle states that different subtypes must work compatibly. In other words, the things with common signatures that can be substituted for each other must behave rationally the same.

In C, this can be accomplished with function pointers to functions that take identical sets of parameters. Let's say you have this code:

#include <stdio.h>
void fred(int x)
{
    printf( "fred %d\n", x );
}
void barney(int x)
{
    printf( "barney %d\n", x );
}

#define Wilma 0
#define Betty 1

int main()
{

    void (*flintstone)(int);

    int wife = Betty;
    switch(wife)
    {
    case Wilma:
        flintstone = &fred;
    case Betty:
        flintstone = &barney;
    }

    (*flintstone)(42);

    return 0;
}

fred() and barney() must have compatible parameter lists for this to work, of course, but that's no different than subclasses inheriting their vtable from their superclasses. Part of the behavior contract would be that both fred() and barney() should have no hidden dependencies, or if they do, they must also be compatible. In this simplistic example, both functions rely only on stdout, so it's not really a big deal. The idea is that you preserve correct behavior in both situations where either function could be used interchangeably.

The closest thing I can think of off the top of my head (and it's not perfect, so if someone has a much better idea they are welcome to one-up me) is mostly for when I'm writing functions for some sort of library.

For Liskov substitution, if you have a header file that defines a number of functions, you do not want the functionality of that library to depend on which implementation you have of the functions; you ought to be able to use any reasonable implementation and expect your program to do its thing.

As for the Open/Closed principle, if you want to implement an I/O library, you want to have functions that do the bare minimum (like read and write). At the same time, you may want to use those to develop more sophisticated I/O functions (like scanf and printf), but you aren't going to modify the code that did the bare minimum.

I see it has been a while since the question was opened, but I think it worth some newer sight.

The five SOLID principles refer to the five aspects of a software entities, as it shown in the SOLID diagram. Although this is a class diagram, it can basically serve other types of SW identities. Interfaces exposed for callers (the left arrow, stands for Interface Segregation) and interfaced requested as callees (the right arrow, stands for Dependency Inversion) can just as well be classical C functions and arguments interfaces.

The top arrow (extension arrow, stands for the Liskov Substitution Principle) works for any other implementation of a similar entity. E.g., if you have an API for a Linked List, you can change the implementation of its functions and even the structure of the vector "object" (assuming, for example, that it preserve the structure of the original one, as in the BSD Sockets example, or it is an opaque type). Sure, this is not as elegant as an Object in an OOP language, but it follows the same principle, and can be used, for example, using dynamic linkage.

In a similar way, the bottom arrow (generalization arrow, stands for the Open/Close Principle), defines whet is defined by your entity and what is open. For example, some functionality might be defined in one file and should not be replaced, while other functionality might call another set of APIs, which allows using different implementations.

This way you can write SOLID SW with C as well, although it will probably be done using higher level entities and might require some more engineering.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top