I don't think there's really a simple answer that can do a substantially better job than the statements of the principles and practices that you've already encountered.
I know you're aware of this on some level, but I think what contributes to the confusion is that the word "interface" got a very specific connotation thanks to the popularity of Java, and later C#. There, "interface" denotes an "interface type", a language-specific notion declared via the interface
keyword.
But the ideas underlying these heuristics and principles are applicable across languages, and in fact, many of them are not fundamentally OO-specific (although some of them are often expressed in OO terms).
An interface has another meaning that's more fundamental, and that predates the one stated above. An interface is the client-facing "API" of a component. A concrete class has an interface - it's the set of its public methods and properties. A function's "interface" is its signature (return type + parameters). A concrete or an abstract base class, by virtue of having an interface (as any other class does), defines a polymorphic top-level interface for it's descendants. The purely abstract class (or the Java/C# interface
) is just a special case of that. A C# delegate (or a function-typed variable, or a function pointer) can be seen as a polymorphic interface for a family of functions. The aggregate root in DDD defines an interface for the whole aggregate. A module's interface is comprised of a number of classes and/or free functions and (parameter or return) types meant to work together. And so on.
Interfaces are about defining how other code should interact with an object (or some other construct), and about separating that "contract" from the internals, with respect to client code.
The principles / heuristics you listed (SOLID, "program to an interface", loose coupling) don't use the term "interface" in the narrower Java/C# sense.
These are not absolutes - there's a bit of an art to it
"Always 'program to an interface, not an implementation"
"Program to an interface, not an implementation" is largely attributed to the authors of the 1994 Design Patterns book; the idea itself probably predates it. There they introduce it by discussing the benefit of programing against an interface (in the traditional sense of the word) defined by an abstract class.
Note that the original statement doesn't contain "always" - it's not a command, it's advice. It exists in a larger context of design considerations, and you have to make a judgement-call regarding the extent to which you want to apply it.
The "SOLID principles", of which the last three (if not four!) at least are only applicable in the presence of interfaces.
I'd like to point out some things. The DIP does not actually use the word "interface". The term used is "abstraction". There's a reason for that: an interface is just one kind of abstraction - one that you'll commonly make use of, but not the only one.
For example, consider the Strategy Pattern - you implement an overall algorithm that defines, dependency inversion–style, an abstraction (what's called a required interface) for specific strategies. Client code then either has to pick an existing strategy to inject, or to provide its own implementation of that interface, in order to make use of the algorithm. Now consider many of the LINQ methods in C# - let's take the Where
as an example. Where
defines a generic filtering algorithm and it requires a predicate lambda that provides an externally injected filtering strategy. Your code has to either pick an existing predicate or roll its own. You see how, in terms of the overall structure, it's exactly the same as the strategy pattern? Yet the abstract strategy interface here is not a traditional interface at all.
This also provides an example of programming to an interface, and of the dependency inversion principle. Microsoft engineers that implemented this method had no way of knowing what kind of collection you'd be filtering, or how you'd want to filter it. Their code has to call your code, but cannot depend on it. Instead, both their code and your code depend on two key abstractions - one is the IEnumerable<T>
, the other one is a Func<T, bool>
and its associated predicate semantics.
One could ostensibly argue it's also an example of the interface segregation principle. In a silly hypothetical scenario, you could imagine a generic filtering algorithm that required an IFilterableEnumerable<T>
, where, to use the library, you'd have to wrap a collection into this "augmented enumerable" that, when iterated over, can tell the library code if the element should be kept or not. That would be so cumbersome to use, and much less flexible. Instead, the Where
method takes as its arguments two separate things - it segregates its dependencies into two concepts with clearly defined roles and a narrower set of responsibilities (remember, it's an extension method for IEnumerable<T>
- which is actually just a static method that takes an IEnumerable<T>
as its first parameter). Note also that this segregation does not prevent you to use our hypothetical IFilterableEnumerable<T>
for both parameters. Again, a silly example, but you'll encounter such overly bundled interfaces "in the wild".
Similarly, many functions in C++ <algorithm>
library rely on a number of abstractions such as iterators, execution policies, and various other things like predicates and comparers. All those define/provide interfaces in this broader sense.
Now, what I've been talking about so far is all library code, but I think you can see how you can apply these same principles in your own code when you have a layered or a modularized ("componentized") architecture, where you want to control the coupling and the direction of dependencies.
But wait, there's more
In the same vain, LSP is not about Java/C# interface
-s at all. It isn't fundamentally about interfaces in the broader sense either. And it isn't about inheritance per se (although that will often be the mechanism for subtyping).
As originally stated by Barbara Liskov and Jeannette Wing, it's about types and their behavioral specification, and it defines what it means for something to be a subtype. In today's context, that something can be a derived class, or a lambda/function injected into some wider context, or a JavaScript object that doesn't inherit anything at all, but can still be used polymorphically because of duck-typing, or any such thing.
Essentially, LSP states that something can be considered a subtype of some other type if it can be shown that it exactly adheres to the abstract behavioral specification associated with that other type. The abstract behavior does not need to have a preexisting implementation to be well-defined; it's not about what the code actually does line by line, it's about what a sensible implementation should look like in a given context.
For example, suppose you write some library code, meant to be called by others, that makes use of the IComparer<T>
interface via dependency injection. The key point is that not all of the semantics of this type are encoded in the interface itself; the languages we use are typically not expressive enough for that. This is why we write documentation, and things like unit tests (this is the sense in which unit tests are a runnable specification).
Your code will expect any implementation of IComparer<T>
that's passed to it to exhibit certain sensible behaviors, the exact nature of which will depend on what your code is actually meant to be used for. E.g, it might require that, if an implementation's Compare
method indicates that a < b and b < c, it should also indicate that a < c, for any a, b and c. In fact, suppose your code relies on this, and some other such "sensibility constraints", and that you've described this in your library's documentation. This defines an abstract behavioral specification for an IComparer<T>
with respect to your library code - even though IComparer<T>
by itself has no implementation (and does nothing in the literal sense).
If someone calls your library with an implementation that doesn't adhere to what you've specified, they've broken LSP with respect to your library (or with respect to that specification). Their code is not substitutable for an IComparer<T>
in that context. The behavior of your code when given a non-compliant IComparer<T>
implementation is undefined. If they choose to pass such an implementation to your code, it may produce unexpected results, or it may crash. Or it may work and do exactly what they wanted by chance - until you release the next version, and an entirely internal change breaks their code.
Note also that nothing in this scenario inherently requires the comparer abstraction to be an interface - it could, say, be a concrete class that provides a default implementation, that you could inherit from, and override. The substitutability terminology fits better in this context. (Yes, this would be less flexible since C# only allows a single base class, but I'm talking in principle.) In fact, in some languages and circumstances, the abstract type, if simple, might not have a direct representation in the code at all - e.g., the singature of the compareFunction
parameter used by JavaScript's Array.sort
is only specified in the documentation, along with the associated semantics (whereas in TypeScript or C# you'd have an explicit type for the function).
Where is all the well-designed code?
"If [... proprietary code ...] is where the most "well-designed" code can be found"
I doubt that. To me, it looks like the situation is a little different. It's not that superbly designed code is hidden behind closed source, it's that it really is not that common - because of a number of factors.
One is that these principles are not understood by the overall coding community as well as you might think. This is hard stuff, deeper and more involved than it looks on the surface. This state of affairs is perhaps not that surprising because of the boom that the software engineering industry has experienced. The huge influx of new people meant that at any point in time the percentage of really experienced people was tiny - and that it was hard to proliferate knowledge with the appropriate amount of depth (as this requires both reach and time). It also means that we keep reinventing the wheel. If you try to find information online, there's a lot of confusion and inconsistencies, and misconceptions that you have to wade through to get to the good bits.
The other one is just the practicalities and pressures of everyday business. When you need to ship the product, and you don't really see a path towards a more elegant design, and the deadline is looming - you ship the product. What should be done instead is a broad and difficult topic that has been and will continue to be the subject of many discussions and differing opinions.
And, ultimately, even the best programmers are just mortal humans - they are not going to produce stellar code all the time and in every circumstance. And not all projects require the same level of design (e.g. a one-off tool that you'll never update again is not going to benefit from an elaborate layered architecture).
It comes down to developing a deeper understanding
"However, if a data member of a class I'm making is a vector
(very common!), it has to be created somewhere, and it "feels silly" to always dependency inject a purely internal storage buffer that nobody on the outside necessarily needs to know explicitly, so then it's simplest to write my Whatsit
's constructor to simply construct an std::vector
directly. But then the Whatsit
class already "knows" now about std::vector
, the concrete class, and so it seems kind of silly to be trying to keep treating it only as IVector
when the "cat's already out of the bag", so to speak."
You're absolutely right. You would not always and systematically dependency-inject things just for the sake of it. This is why you have to know (or decide, or design) what your class is for. What its contract is - what's client facing, and what's internal. If the std::vector
is something that's purely there to support the internal details of your implementation, then by all means, create it internally. If your class (or a method, or a free function) provides some higher level functionality that needs to be reusable with different kinds of collections, then inject a pair of iterators. Heck, you can have a scenario where you inject the iterators, but also create an std::vector
internally to maintain a local copy of some range, or to use as a temp storage, or whatever.
One thing that I do know is an antipattern is where you always write a class and identical interface, e.g. every class is intentionally paired 1:1 in a doublet IWhatsit
/Whatsit
.
Yes - and, notice, there's a theme here: if you systematically follow a practice without understanding the reasoning behind it, you end up creating spaghetti code in a systematic way - with a very consistent look to it.