Object-oriented is a methodology that uses a language feature known as objects as its fundamental unit of expression. The acronym SOLID identifies foundational principles differentiating between an object-oriented design and a design that simply uses objects.
#ObjectOriented uses #Objects but using #Objects isn’t always #ObjectOriented.
– #Programming #Architecture #ObjectOrientedDesign – @bgribaudo (original Tweet)
Recently, I read “Uncle Bob” (Robert C.) Martin’s exposition of the SOLID in his book Agile Software Development, Principles, Patterns, and Practices (Pearson, 2002, ISBN: 978-0135974445). Below is a combination of notes from his explanation and thoughts of my own that came as I contemplated what Uncle Bob wrote.
These reflections are not a comprehensive overview or complete discussion of the SOLID principles. If you’d like to read more about these maxims, try searching for “SOLID object-oriented design” (or something similar) and you should find a number of articles on the topic—and, of course, you just might want to read the relevant chapters from Uncle Bob’s book or its more recent revision and adaptation to C#: Agile Principles, Patterns, and Practices in C# (Robert C. Martin & Micah Martin, Prentice Hall, 2006, ISBN: 978-0131857254).
Single Responsibility Principle (SRP)
A class should have only one reason to change. (p. 95)
A “reason to change” is a responsibility. A responsibility defines a focus for the class. This, in turn, establishes a vector or axis of, or motive for, change. Changes are made to help a class better fulfill its responsibilities.
The motivations behind why changes are made provides a feedback mechanism. Since a class should only have a single responsibility, changes made to a class should all be a part of enabling the class to fulfill that single responsibility. If changes are being made that can be divided into groups targeting different responsibilities, we have feedback that our class is taking care of more than one responsibility.
Context
Responsibility can be defined at different levels of scope, from all-encompassing to minutely specific. The motivations behind the changes made also provides feedback on proper scoping. If the changes align along the same axis but group into different sub-focuses, it may be that the class is scoped too broadly. That is, it may be fulfilling a single responsibility, but that one responsibility is made up of sub-responsibilities which, in the current context, should be promoted to separate, full-fledged responsibilities. Each of these should then be handled by a different class.
“An axis of change is an axis of change only if the changes actually occur.”
For example, consider a string class provided by a programming framework. Likely the class addresses many different facets of working with strings. Should each of these be considered a separate responsibly or is it appropriate to group them under the broadly-scoped umbrella of “responsible for general string-related behavior?” If the class is relatively stable as far as changes go, the broad responsibility definition may be appropriate. However, if changes occur and those changes focus on certain sub-aspects of the more generally-stated responsibility, it’s likely that those sub-aspects should be elevated to full-fledged responsibilities and so handled by separate classes.
Cohesion
The Single Responsibility Principle guides us in dividing responsibilities—including the data and behavior necessary to fulfill those responsibilities—into cohesive units.
Open-Closed Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. (p. 99)
This definition is ambiguous. It’s been misinterpreted to the extreme of believing that source code, once written, should never be modified except for bug fixes. Uncle Bob offers clarification in An Open and Closed Case. Jon Skeet shares his take on the confusion in The Open Closed Principle in Review.
In essence, the Open-Closed Principle proposes that (my wording):
It should be possible to extend the behavior of an entity without modifying either the existing entity or consumers of the entity.
In the case of classes, inheritance makes it possible to extend the entity (the class) without modifying the entity. However, by itself, inheritance does not guarantee that parent class or consumer modifications will be unnecessary when extension occurs.
Existing Entity
In order for a parent class to be extendable without modification (open for extension, closed for modification), the parent must be designed in such a way that it does not rely on details about its children. Why? If the parent knows about its descendants, it may be necessary to change the parent when descendants are added or when their behavior changes. The possibility that descendants may necessitate parental modification forces the parent to be open to modification—something this principle seeks to avoid.
To illustrate, imagine an abstract Logger class with children FileLogger and ConsoleLogger. When Logger’s Log(string message)
method is called, a switch statement calls this.WriteToLogFile(message)
if the current instance is a FileLogger and this.DisplayOnScreen(message)
when it is a ConsoleLogger.
public void Log(string message) { switch(this) { case FileLogger l: l.WriteToLogFile(message); break; case ConsoleLogger l: l.DisplayOnScreen(message); break; // Adding new child classes necessitates adding new case statements here } }
Extending Logger by adding a new child class or renaming a child class method referenced in Log(string message)
requires modifying the parent. With this design, in order for Logger to be open for extension, it also must be open to modification—a violation of the Open-Closed Principle.
Consumers of the Entity
Also, extending a class should not force consumers of the class to be modified. This implies that descendant classes must be substitutable for their parents, a topic addressed by the Liskov Substitution Principle (discussed later).
Context
The Open-Closed Principle can be applied to contexts other than classes. For example, extending behavior encapsulated in a module (e.g. a package, assembly, jar file, etc.) by creating a new module that references the base module ideally shouldn’t necessitate modifying the base module.
In general, no matter how “closed” an entity is, there will always be some kind of change against which it is not closed. There is no model that is natural to all contexts!
Liskov Substitution Principle (LCP)
Subtypes must be substitutable for their base types. (p. 111)
In other words, if a piece of code expects an instance of a particular class, it also should be usable with descendants of the expected class. In order for this to be possible, the child class must honor the behavior contract established by its parent.
For example, imagine a class hierarchy consisting of parent class Rectangle, which exposes properties Height and Width, and child class Square. Since a geometric square is a rectangle whose width and height are equal, class Square needs to ensure that this constraint is followed. To do this, Square could override Rectangle’s Height so that it sets both width and height to the new height value and Width to set both dimensions to the new width value.
class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } } class Square : Rectangle { public override int Width { get => base.Width; set { base.Width = value; base.Height = value; } } public override int Height { get => base.Height; set { base.Height = value; base.Width = value; } } }
This approach make Square self-consistent—that is, Square ensures that its constraints are honored. However, Square is inconsistent with the behavior expectations established by its parent. A consumer of a Rectangle that calls Height = 5
then Width = 10
expects that accessing Height and Width will return 5 and 10, respectively. However, when an instance of Square is used as a Rectangle, the same sequence of calls will produce 10 and 10.
Square, which claims to be a Rectangle, violates the implied behavior contract set by its parent. It’s not reasonable to expect the consumer to know about and accommodate this discrepancy in behavior. After all, the consumer asks for a Rectangle and is given an object that claims to be a Rectangle. It’s reasonable to assume that what it is given is compatible with the behavior contract of what it asked for. In this case, Rectangle’s implied contract is that Height sets and returns height and Width sets and returns width.
Design by Contract says it this way: “A routine redeclaration [in a derivative] may only replace the original precondition by one equal or weaker, and the original postcondition by one equal or stronger” (Myer)
.
Behavior Is the Context
“A model, viewed in isolation, cannot be meaningfully validated.”
The model under validation—in this case, Square—must be viewed “in terms of the reasonable assumptions made by the users of that design.”
“Is-A” is often used to identify subclasses. However, by itself, “is-a” is too broad. Properly used, it pertains to behavior. In order to determine whether “Square is a Rectangle,” we need to evaluate whether Square’s behavior satisfies the explicit (if any) and implicit contracts of behavior established by Rectangle. In the context of the above Rectangle, Square is not a Rectangle. However, Square might be a wonderful Rectangle in the context of a different Rectangle design.
Overriding base class methods so that they do nothing (i.e. making them degenerate) may—but does not always—indicate that the overriding class is not substitutable for its parent.
Latent OCP Violation
A violation of LSP is a latent violation of Open-Closed Principle. With LSP-violating code, extending the parent class by sub-classing opens up the possibility that the consumer, the parent or both will need to be modified to make sub-classing successful.
Interface Segmentation Principle (ISP)
Clients should not be forced to depend on methods that they do not use. (p. 137)
Often, a consumer is only interested in a subset of what a class exposes. Instead of the consumer depending on the full range of what the class offers, limit the dependency to what the consumer actually uses.
This principle’s emphasis is not “use interfaces,” as in encouraging use of the language construct known as an interface. Rather, its focus is behavior dependency: consumers should depend only on the behavior they need.
In duck-typed languages, compliance to the Interface Segmentation Principle is intrinsic because consumers automatically depend only on what they use. In non-duck-typed languages, interfaces (or sometimes abstract classes) are typically the means used to achieve ISP segmentation. However, simply using interfaces doesn’t guarantee compliance with this principle; those interfaces also must be limited to the actual behavioral needs of the consumer.
Imagine class Door with methods Open()
, Close()
, Lock()
, Unlock()
, etc. A consumer which only needs to Lock()
and Unlock()
doors should depend only on those two methods, not on Door’s entire signature. This could be achieved by declaring an ILockable interface consisting of those two methods.
Reusability
The consumer can now be reused with any class that implements the required behavior (ILockable). As time goes on, it might become desirable for the consumer to unlock windows as well as doors. As long as Window also implements ILockable, the consumer can be used to lock and unlock windows without requiring modification. Since the consumer depends only on the exact behavior it uses, it can be used with any implementation that supplies the exact functionality it needs. It does not impose any unnecessary requirements.
Cognitive Complexity
The Interface Segmentation Principle also reduces the cognitive complexity associated with understanding the consumer. With ISP-compliant code, the definitions of the interfaces expected by the consumer tell the developer the full set of behavior used by the consumer—not the full set of behavior that possibly could be used, as would be the case if the consumer depended on broadly-scoped interfaces or on concrete classes, but rather the full set of behavior that actually is being used.
Unrelated Changes = Design Problem
Clients exert forces on the interfaces they use (e.g. a client’s needs may dictate that a method be added to an interface). Changes to an interface can affect clients even if the particular change isn’t relevant to the particular client. When unrelated changes have impact, a design issue exists. Interface segmentation establishes a boundary which helps guard clients from changes that are unrelated to the client’s needs.
Dependency Inversion Principle (DIP)
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions. (p. 127)
“Inversion” in the name comes from how dependencies tend to flow in the opposite direction compared to designs created using traditional development methods like Structured Analysis and Design. Traditionally, policy depends on detail. Instead, detail should depend on policy.
High-level entities (which contain the important policy decisions and the business model of the application—in essence, its identity) should avoid dependence on lower-level entities that are likely to change. Higher-level entities should influence and be independent from those at lower-levels; the lower-level entities should not take precedence over the higher ones.
If a higher-level entity depends on a lower-level entity and that lower-level entity changes, the higher-level entity may be forced to change because of the lower-level entity.
To remove the need for direct dependencies between high- and low-level entities, have the higher-level entity depend on abstractions (such as an interfaces) that the lower-level entity implements.
Where should shared abstraction live?
The logical binding between consumer and interface is stronger than the physical binding between the interface and its implementers. It doesn’t make sense to deploy the consumer without the interface but it is valid to deploy the interface without any implementers. The logical binding’s strength encourages interfaces to live with the consumer, not with the implementer.
This also makes sense from the perspective of the Interface-Segmentation Principle. When that principle is applied, interfaces express expectations of the consumer so are tightly coupled to the consumer and should only change if the consumer’s needs change.
Of course, not every interface is specific to a particular consumer (or to a closely related set of consumers). A shared interface can live separate from both consumers and implementers. High-level consumers and low-level implementers then depend on a stand-alone interface which neither of them owns and so is independent from both of them.
Context
The Dependency Inversion Principle protects high-level entities from low-level influence. However, a low-level entity with low volatility will exert very little ongoing pressure on its consumers. From the prospective of this principle, it’s safe for consumers to directly depend on stable low-level entities (e.g. directly depend on the concrete low-level class).
“Resisting premature abstraction is as important as abstraction itself.” (p. 109)