Understanding the SOLID design principles
In the previous sections, the idea of structured work was introduced. The development pillars of APIE were elaborated on in detail using examples. You have gained a foundational understanding of the concept of class instances in terms of object-oriented principles and how we can create different types of specific objects:
Figure 1.11 – Vehicle N, where N is a positive integer number, represents an instance of the Vehicle class
Classes can be instantiated so that an instance becomes an object. The object must fit into free memory. We say that the object allocates memory space. When Java is considered, allocated memory is virtual space inside the physical system’s memory.
Just a small note – we previously discussed the existence of the JVM, an interpreter of compiled bytecode for the required platform (see Figure 1.3). We mentioned other JVM features, one of which is memory management. In other words, the JVM assumes responsibility for allocating virtual memory space. This virtual memory space can be used to allocate an instance of a class. This virtual memory and its fragmentation are taken care of by the JVM and an unused object cleans up the selected garbage collection algorithm, but this is beyond the scope of this book and would be the subject of further study (see Reference 1).
Every programmer, although it may not be obvious at first glance, plays the role of a software designer. The programmer creates the code by writing it. The code carries an idea that is semantically transformed into action depending on the text entered.
Over time, software development has gone through many phases and many articles have been written and published on software maintenance and reusability. One of the milestones in software development may be considered the year 2000 when Robert C. Martin published his paper on Design Principles and Design Patterns (see Reference 2). The paper reviews and examines techniques in the design and implementation of software development. These techniques were later simplified in 2004 into the mnemonic acronym SOLID.
The goal of the SOLID principles is to help software designers make software and its structure more sustainable, reusable, and extensible. In the following sections, we will examine each of the individual terms hidden after the initial letter in the abbreviation SOLID.
The single-responsibility principle (SRP) – the engine is just an engine
The first principle is a well-defined class goal. We can say that each class should have only one reason to exist. As in, it has the intention and responsibility for only one part of the functionality. The class should encapsulate this part of the program. Let’s put this in the context of an example. Imagine the previous example of a vehicle and its abstraction. We are now extending this class with the Engine
and VehicleComputer
classes, as shown:
Figure 1.12 – The Vehicle
class instance using Engine
and VehicleComputer
realization but an engine functionality does not interfere with the lights
The engine can start and stop, but the instance of the Engine
class cannot control vehicle lights, for example. The light control is the responsibility of the vehicle computer class instance.
The open-closed principle (OCP)
This principle states that the class or entity under consideration should be open to extension but closed to modifications. It goes hand in hand with the concepts already mentioned. Let’s put this in the context of an example where we consider the Car
and Truck
classes. Both classes inherit the Vehicle
interface. Both believe that vehicle entities have a move
method.
By not thinking about proper abstraction and without respecting the OCP, code can easily bear unexpected difficulties when classes are not easy to reuse or cannot be handled (see Example 1.5):
public interface Vehicle {} public class Car implements Vehicle{ public void move(){} } public class Truck implements Vehicle { public void move(){} } -- usage -- List<Vehicle> vehicles = Arrays.asList(new Truck(), new Car()); vehicles.get(0).move() // ERROR, NOT POSISBLE!
Example 1.5 – Although both are considered entities, Truck and Car inherit a Vehicle interface, the move method is compliant, and this causes an issue in extension or execution
The correction of the example at hand is very trivial in this case (see Example 1.6):
public interface Vehicle { void move(); // CORRECTION! } --- usage --- List<Vehicle> vehicles = Arrays.asList(new Truck(), new Car()); vehicles.get(0).move() // CONGRATULATION, ALL WORKS!
Example 1.6 – The Vehicle interface provides a move abstraction method
Obviously, as code evolves, non-compliance leads to unexpected challenges.
The Liskov Substitution Principle (LSP) – substitutability of classes
The previous sections dealt with inheritance and abstraction as two of the key pillars of OOP. It will come as no surprise to those of you who have read carefully that, given the class hierarchy of parent-child relationships, a child may be replaced or represented by its parent and vice versa (see Example 1.7). Let us look at the example of CarWash
, where you can wash any vehicle:
public interface Vehicle { void move(); } public class CarWash { public void wash(Vehicle vehicle){} } public class Car implements Vehicle{ public void move(){} } public class SportCar extends Car {} --- usage --- CarWash carWash = new CarWash(); carWash.wash(new Car()); carWash.wash(new SportCar());
Example 1.7 – A CarWash example where any Vehicle type can be substituted by appropriate instances of classes in the class hierarchy
This means that classes of a similar type can act analogously and replace the original class. This statement was first mentioned during a keynote address by Barbara Liskov in 1988 (see Reference 3). The conference focused on data abstraction and hierarchy. The statement was based on the idea of substitutability of class instances and interface segregation. Let’s look at interface segregation next.
The interface segregation principle (ISP)
This principle states that no instance of a class should be forced to depend on methods that are not used or in their abstractions. It also provides instructions on how to structure interfaces or abstract classes. In other words, it controls how to divide the intended methods into smaller, more specific entities. The client could use these entities transparently. To point out a malicious implementation, consider Car
and Bike
as children of the Vehicle
interface, which shares all the abstract methods (see Example 1.8):
public interface Vehicle { void setMove(boolean moving); boolean engineOn(); boolean pedalsMove(); } public class Bike implements Vehicle{ ... public boolean engineOn() { throw new IllegalStateException("not supported"); } ... } public class Car implements Vehicle { ... public boolean pedalsMove() { throw new IllegalStateException("not supported"); } } --- usage --- private static void printIsMoving(Vehicle v) { if (v instanceof Car) { System.out.println(v.engineOn());} if(v instanceof Bike) {System.out.println(v.pedalsMove());} }
Example 1.8 – Various implementations of inherited method abstraction
Some of you with a keen eye will already notice that such a software design direction negatively involves software flexibility through unnecessary actions that need to be considered (such as exceptions). The remedy is based on compliance with the ISP in a very transparent way. Consider two additional interfaces, HasEngine
and HasPedals
, with their respective functions (see Example 1.9). This step forces the printIsMoving
method to overload. The entire code becomes transparent to the client and does not require any special treatment to ensure code stability, with exceptions as an example (as seen in Example 1.8):
public interface Vehicle { void setMove(boolean moving); } public interface HasEngine { boolean engineOn(); } public interface HasPedals { boolean pedalsMove(); } public class Bike implements HasPedals, Vehicle {...} public class Car implements HasEngine, Vehicle {...} --- usage --- private static void printIsMoving(Vehicle v){ // no access to internal state } private static void printIsMoving(Car c) { System.out.println(c.engineOn()); } private static void printIsMoving(Bike b) { System.out.println(b.pedalsMove()); }
Example 1.9 – The functionality split into smaller units (interfaces) based on the purpose
Two interfaces, HasEngine
and HasPedals
, are introduced, which enforce method code overload and transparency.
The dependency inversion principle (DIP)
Every programmer, or rather software designer, will face the challenge of hierarchical class composition throughout their careers. The following DIP is a remarkably simple guide on how to approach it.
The principle suggests that a low-level class should not know about high-level classes. In the opposite direction, this means that the high-level classes, the classes that are above, should have no information about the basic classes at lower levels (see Example 1.10, with the SportCar
class):
public interface Vehicle {} public class Car implements Vehicle{} public class SportCar extends Car {} public class Truck implements Vehicle {} public class Bus implements Vehicle {} public class Garage { private List<Vehicle> parkingSpots = new ArrayList<>(); public void park(Vehicle vehicle){ parkingSpots.add(vehicle); } }
Example 1.10 – The garage implementation depends on vehicle abstraction, not concrete classes in a hierarchy
It also means that the implementation of a particular functionality should not depend on specific classes, but rather on their abstractions (see Example 1.10, with the Garage
class).