I don't recall when exactly I first encountered RCM's articles on the SOLID-principles, but it was well over 15 years ago. By the way, the Single Responsibility Principle wasn't part of the original set of articles I read. SRP and also the term SOLID were coined much later. Around the time I read a ton of books on OO and most of those started with and were fundamentally about using inheritance to model the real world, which is something I've found more harmful than useful.
RCM's articles on the S O L I D -principles and his earlier book, Designing Object-Oriented Applications Using The Booch Method, on the other hand, started with and were fundamentally about managing dependencies, which is something that I've found tremendously useful while designing abstractions regardless of whether I've been using OO or not. Rather than being the center of attention, the use of (abstract classes or interfaces and) inheritance was merely the mechanism used for the purpose of managing dependencies.
So, what are the SOLID-principles all about and are they really meaningful outside of OO?
Consider the following dependency structure:
Client -> Server
The problem here is that the client, which also corresponds to a higher level program component, is directly tied to a specific server, or a lower level program component. While the server can be used by many different clients, the client cannot be used with many different servers. In other words, the client is not parametric with respect to servers.
This is really the default way of how things end up in pretty much every
approach to programming. In functional programming, for example, a function,
the client
, that calls another function, the server
, exhibits this
dependency structure:
let client ... =
...
let ... = server ...
...
Similarly, in modular programming, a module, the Client
, that refers to
something
defined in another module, the Server
, also exhibits this
dependency structure:
module Client = struct
...
let ... = ... Server.something ...
...
end
Take a moment to think about the above templates. Aren't those exactly what the bulk of code in pretty much any language looks like?
Can we make a client usable with more than just one specific server?
To make a client parametric with respect to servers, we can introduce a level of indirection, an interface or abstraction, that both the client and server depend upon and thus break the direct dependency of the client upon a particular server. We end up with the following inverted dependency structure:
Client -> Interface <- Server
In the context of specific OO languages, the term interface has specific
meanings, but in the broader context, there are many ways to specify interfaces.
For example, an interface can be something as simple as the type of a function
... -> ...
:
let client (server: ... -> ...) ... =
...
let ... = server ...
...
Or something more complex like the formal parameter signature of a parameterized module:
module Client =
functor (Server: sig
...
val something: ...
...
end) ->
struct
...
let ... = ... Server.something ...
...
end
Written this way, the client
function, or the Client
module, is no longer
directly tied to a specific server
function, or a specific Server
module.
But isn't this much obvious?
As beneficial as it can be to create abstractions to invert dependencies, it just isn't practical to write programs in fully-functorial style. There is a point where the plumbing becomes more trouble that it is worth. Abstraction must be strategic.
Furthermore, creating an abstraction, some abstraction, is really the easy part. Some tools even automate the act of extracting an interface for you. The difficult part is coming up with abstractions that are worth the trouble.
How do you come up with good abstractions?
This is where the SOLID-principles can help the aspiring programmer. The SOLID-principles are basically subtly different ways to look at, understand or evaluate a design as a target for, with respect to or corresponding to an inverted dependency structure or abstraction:
-
Single Responsibility Principle: Are there multiple reasons for a module to change? If so, would it be beneficial to introduce separate abstractions corresponding to different reasons for change to make the program more resilient to changes?
-
Open/Closed Principle: Is there a way in which the behavior of a module needs to be extended or varied? Would it make sense to introduce an abstraction and parameterize the module with respect to that abstraction to make it possible to program that module once and for all?
-
Liskov Substitution Principle: What can the clients of an implementation of an abstraction assume? What can an implementation of the abstraction assume from its clients?
-
Interface Segregation Principle: Are there multiple different kinds of clients that each use a different subset of a module? If so, could you make your program more resilient to changes by introducing separate abstractions corresponding to the needs of each kind of client?
-
Dependency Inversion Principle: Does a high level module have a direct dependency to a lower level module? Is it something that needs to be changed in some context? If so, would it be worth the trouble to create an abstraction for the module and invert the dependency?
Take a moment to look through the diagrams in the article Design Principles and Design Patterns. You will find that all of the OLID-principles contain the inverted dependency structure as a part of them. You can also see the same dependency inversion in the diagrams of the SRP article.
Recently I've seen many critical views of the SOLID-principles. For example, Kevlin Henney has given a talk, The SOLID Design Principles Deconstructed, and Lev Gorodinski has written on Object-oriented Design Patterns From a Functional Perspective. There are many other similar articles.
Kevlin Henney begins his talk by opening his dictionary to argue that the word "Principle" is not the correct word to characterize SOLID. Even more so, the theme of grabbing on to particular words or paragraphs of historical texts carries his talk. I think this is one of the ways in which not being a native English speaker is advantageous. I couldn't care less about whether SOLID would be called patterns, principles, laws of abstraction, idioms, monads or teddy bears. I also couldn't care less if there was a particular word or sentence that I could extract from old texts on SOLID and then use that to completely misrepresent it. As a programmer, I only care about whether SOLID is useful—and in my opinion it is.
Unlike Kevlin Henney, I do think there is something fundamental about SOLID. But that is not the particular principles themselves. It is the fundamental principle of abstraction. That you can decouple a client from a server by means of an abstract interface and parameterization. And as I argued, that is the focus of attention in SOLID. Abstraction is as fundamental as things get. But it is not easy. Coming up with good abstractions is hard. Understanding when an existing abstraction might be problematic is hard. Understanding when something needs an abstraction is hard. And that is where SOLID helps by coming up with different ways to question designs.
Now, perhaps Kevlin Henney's talk is just a joke and I'm a fool. In that case, shame on me. But Kevlin Henney seems to completely miss the point of the Open/Closed Principle. OCP is about strategically parameterizing a module with an abstraction to make that module invariant, or closed, with respect to an unbounded, or open, set of implementations of the abstraction. OCP is not analogous to version control. Kevlin, have you actually read RCM's article on the The Open-Closed Principle?
Update: In the beginning of his talk, Kevlin Henney says:
Who is familiar with Uncle Bob's SOLID-principles? [...] Because I'm just about to rip them apart. [...] On the other hand, I'm about to reduce this, because the Open/Closed Principle is a load of nonsense. [...]
At no point in his talk does Kevlin Henney say that there are two versions of the Open/Closed Principle:
I hope his audience also knows this, because Kevlin Henney's talk isn't about ripping apart RCM's SOLID-principles. Kevlin Henney's talk is about taking the names of SOLID-principles, ascribing different meanings to those names and then ripping those meanings apart.
Lev Gorodinski's article is about interpreting SOLID, among other things, from a functional programming perspective. Now, as you might know, I'm very much a proponent of functional programming. But I see things very differently. Functional programming does not automagically decouple your clients from your servers with well defined abstractions. Really, it does not.
In functional programming, the fundamental unit of abstraction is the function. Given that a function has a single input and a single output, functions naturally have a single responsibility.
SRP is about having a single reason for change.
In the course of developing a program, a particular function may need to be changed for a variety of reasons. For example, a function that computes a KD-tree for a set of 3D-shapes
val makeKDTree: seq<Shape> -> KDTree
might need to be changed to accommodate different kinds of 3D-shapes. Or it might need to be changed to use different heuristics for choosing the splitting planes as different heuristics may be preferable in different usage scenarios.
That is two reasons for change and the function for computing a KD-tree isn't even a particularly complex function—at least when properly factored.
In a functional language, functions can be substituted at will and as such, there is no need to “design” for extensibility. Functionality requiring parametrization is naturally declared as such. Instead of inventing a concept of a virtual method and inheritance, one can rely on an existing, elementary concept - the higher-order function.
Take a careful read of RCM's original article on the Open/Closed Principle. The very article contains an example where a table, rather than inheritance, is used to achieve closure. Really, OCP is not about inheritance. It is about strategically introducing abstraction. And, no Kevlin, OCP still isn't analogous to version control!
Also, functions cannot be substituted at will to achieve extensibility. You will, in general, need to carefully define the acceptable functions, in other words, give an interface or specify the properties of an abstraction, for the purposes of parameterization. For example, a higher-order sorting function expects certain properties from the ordering function it is given—an arbitrary function just won't work.
Functional languages favor parametric polymorphism with bounded quantification thereby avoiding some of the pitfalls of inheritance.
This I actually agree with. In particular if you just drop the bounded quantification. Parametric polymorphism is indeed easier to reason about than subtyping and gives you a lot of power. Whether or not parametric polymorphism is the sole property of FP is another matter.
Most ambitions of the Liskov substitution principle are effectively trivial in a functional language.
Even in FP you will need to carefully define the boundaries of abstractions. LSP was stated in the context of abstract datatypes, but I see LSP and Design by Contract as a much more general idea. I see it as the idea of carefully defining the boundaries, properties or contracts of abstractions. And, yes, you definitely need to do that in FP as well. Think about monad laws or something even as simple as the higher order sorting function mentioned earlier, for example.
Functional programming reduces the need for encapsulation by eschewing state and breeds composition at the core. There is no augmented concept of role-based interfaces because function roles are explicit at the onset. Functions are segregated by default.
Consider an immutable binary search tree type:
type BST<'k, 'v> =
| Leaf
| Branch of 'k * 'v * BST<'k, 'v> * BST<'k, 'v>
Now, why do you hide, or
encapsulate,
the representation of the BST<'k, 'v>
type even in FP?
Because functions manipulating search trees expect trees to have a particular ordering property. Functions on search trees, such as
val tryFind: BST<'k, 'v> -> 'k -> option<'v>
do not work correctly with arbitrarily constructed trees. In this case there
are two ways in which the BST<'k, 'v>
type can be accessed. Inside the
associated module, code can manipulate trees in arbitrary ways. Outside of the
module only functions published by the module can be used to construct search
trees.
In fact, the need for multiple ways to view datatypes in order to allow them to remain abstract, or encapsulated, is well recognized in the FP community. Here are a few papers on the subject:
-
Views: A way for pattern matching to cohabit with data abstraction
-
Extensible Pattern Matching Via a Lightweight Language Extension
So, in my opinion, the reality is actually quite the opposite.
What I particularly like about ML-style languages is the module systems that allow one to define signatures for modules. Signatures that leave the types abstract. And, in particular, I love the ability to define multisorted algebras or modules that simultaneously encapsulate more than a single abstract type. That is something that is often awkward, if at all possible, to express in many OO languages. In other words, I like ML-style languages, because ML-style signatures provide a fairly simple, but expressive, language for specifying encapsulated abstractions.
As a matter of course, the declarative and side-effect free nature of functional programming provide for dependency inversion. In object-oriented programming, high-level modules depend on infrastructure modules primarily to invoke side-effects. In functional programming, side-effects are more naturally triggered in response to domain behavior as opposed to being directly invoked by domain behavior. Thus dependencies become not merely inverted, but pushed to outer layers all together.
This is partly true. When a module is free of side-effects, there is definitely less reason to factor out dependencies of the module.
However, FP does not automatically make functions closed in the sense of the OCP. You will need to introduce abstractions to factor out dependencies to implementation choices that need to be open to vary.
Furthermore, purely functional modules aren't really sufficient. Consider programs that need to interact with and react to events from the outside world. The way such programs are implemented in purely functional languages is by means of implementing imperative machines that execute them. That doesn't really automatically change things compared to imperative programming. If you define a program that opens a dialog box in response to some event then that program is tied to the mechanism you used to open the dialog. To avoid the dependency to a particular mechanism, you will need to abstract and factor out the dependency just the same.
For historical reasons, the SOLID-principles were formulated in conjuction with OO. But if you look a little deeper into the SOLID-principles, you will see that the essence of the SOLID-principles is abstraction. The SOLID-principles provide ways to look at, come up with, carefully specify, understand and evaluate abstractions. When interpreted broadly, the SOLID-principles are as relevant in OO as they are in FP.