12. The SOLID Principles of Object-Oriented Design

One of the most common statements that many developers make regarding object-oriented programming is that a primary advantage of OOP is that it models the real world. I admit that I use these words a lot when I discuss classical object-oriented concepts. According to Robert Martin (in at least one lecture that I viewed on YouTube), the idea that OO is closer to the way we think is simply marketing. Instead, he states that OO is about managing dependencies by inverting key dependencies to prevent rigid code, fragile code, and non-reusable code.

For example, in classical object-oriented programming courses, the practice often models the code directly to real-life situations. For example, if a dog is-a mammal, then this relationship is an obvious choice for inheritance. The strict has-a and is-a litmus test has been part of the OO mindset for years.

However, as we have seen throughout this book, trying to force an inheritance relationship can cause design problems (remember the barkless dog?). Is trying to separate barkless dogs from barking dogs, or flying birds from flightless birds, a smart inheritance design choice? Was this all put in place by object-oriented marketers? OK; forget the hype. As we saw in the previous chapter, perhaps focusing on a strict has-a and is-a decision is not necessarily the best approach. Perhaps we should focus more on decoupling the classes.

In the lecture I mentioned previously, Robert Martin, often referred to as Uncle Bob, defines these three terms to describe non-reusable code:

  • Rigidity—When a change to one part of a program can break another part

  • Fragility—When things break in unrelated places

  • Immobility—When code cannot be reused outside its original context

SOLID was introduced to address these problems and strive to attain these goals. It defines five design principles that Robert Martin introduced to “make software designs more understandable, flexible, and maintainable.” According to Robert Martin, though they apply to any object-oriented design, the SOLID principles can also form a core philosophy for methodologies such as agile development or adaptive software development. The SOLID acronym was introduced by
Michael Feathers.

The five SOLID principles are

  • SRP—Single Responsibility Principle

  • OCP—Open/Close Principle

  • LSP—Liskov Substitution Principle

  • IPS—Interface Segregation Principle

  • DIP—Dependency Inversion Principle

This chapter focuses on covering these five principles and relates them to the classical object-oriented principles that have been in place for decades. My goal in covering SOLID is to explain the concepts in very simple examples. There is a lot of content online, including several very good YouTube videos. Many of these videos target developers, not necessarily students new to programming.

As I have attempted to do with all the examples in this book, my intent is not to get overly complicated but to distill the examples to the lowest common denominator for educational purposes.

The SOLID Principles of Object-Oriented Design

In Chapter 11, “Avoiding Dependencies and Highly Coupled Classes,” we discussed some of the fundamental concepts leading up to our discussion of the five SOLID principles. In this chapter, we dive right in and cover each of the SOLID principles in more detail. All SOLID definitions are from the Uncle Bob site: http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod.

1) SRP: Single Responsibility Principle

The Single Responsibility Principle states that a class should have only a single reason to change. Each class and module in a program should focus on a single task. Thus, don’t put methods that change for different reasons in the same class. If the description of the class includes the word “and,” you might be breaking the SRP. In other words, every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated in the class.

Creating a shape hierarchy is one of the classic illustrations of inheritance. It is used often as a teaching example, and I use it a lot throughout this chapter (as well as the book). In this example, a Circle class inherits from an abstract Shape class. The Shape class provides an abstract method called calcArea() as the contract for the subclass. Any class that inherits from Shape must provide its own implementation of calcArea():

abstract class Shape{
    protected String name;
    protected double area;
    public abstract double calcArea();
}

In this example, we have a Circle class that inherits from Shape and, as required, provides its implementation of calcArea():

class Circle extends Shape{
    private double radius;

    public Circle(double r) {
        radius = r;
    }
    public double calcArea() {
        area = 3.14*(radius*radius);
        return (area);
    };
}

Caution

In this example, we are only going to include a Circle class to focus on the Single Responsibility Principle and keep the example as simple as possible.

A third class called CalculateAreas sums the areas of different shapes contained in a Shape array. The Shape array is of unlimited size and can contain different shapes, such as squares and triangles.

class CalculateAreas {
    Shape[] shapes;
    double sumTotal=0;
    public CalculateAreas(Shape[] sh){
        this.shapes = sh;
    }
    public double sumAreas() {
        sumTotal=0;
        for (inti=0; i<shapes.length; i++) {
            sumTotal = sumTotal + shapes[i].calcArea();
        }
        return sumTotal;
    }
    public void output() {
        System.out.println("Total of all areas = " + sumTotal);
    }
}

Note that the CalculateAreas class also handles the output for the application, which is problematic. The area calculation behavior and the output behavior are coupled—contained in the same class.

We can verify that this code works with the following test application called TestShape:

public class TestShape {
    public static void main(String args[]) {

        System.out.println("Hello World!");

        Circle circle = new Circle(1);

        Shape[] shapeArray = new Shape[1];
        shapeArray[0] = circle;

        CalculateAreas ca = new CalculateAreas(shapeArray);

        ca.sumAreas();
        ca.output();
    }
}

Now with the test application in place, we can focus on the issue of the Single Responsibility Principle. Again, the issue is with the CalculateAreas class and that this class contains behaviors for summing the various areas as well as the output.

The fundamental point (and problem) here is this: If you want to change the functionality of the output() method, it requires a change to the CalculateAreas class regardless of whether the method for summing the areas changes. For example, if at some point we want to present the output to the console in HTML rather than in simple text, we must recompile and redeploy the code that sums the area because the responsibilities are coupled.

According to the Single Responsibility Principle, the goal is that a change to one method would not affect the other method, thus preventing unnecessary recompilations. “A class should have one, and only one, reason to change—a single responsibility to change.”

To address this, we can put the two methods in separate classes, one for the original console output and one for the newly included HTML output:

class CalculateAreas {
    Shape[] shapes;
    double sumTotal=0;

    public CalculateAreas(Shape[] sh){
        this.shapes = sh;
    }

    public double sumAreas() {
        sumTotal=0;

        for (inti=0; i<shapes.length; i++) {

            sumTotal = sumTotal + shapes[i].calcArea();

        }

        return sumTotal;
    }
}
class OutputAreas {
    double areas=0;
    public OutputAreas(double a){
        this.areas = a;
    }
    public void console() {
        System.out.println("Total of all areas = " + areas);
    }

    public void HTML() {
        System.out.println("<HTML>");
        System.out.println("Total of all areas = " + areas);
        System.out.println("</HTML>");
    }
}

Now, using the newly written class, we can add functionality for HTML output without impacting the code for the area summing:

public class TestShape {
    public static void main(String args[]) {

        System.out.println("Hello World!");

        Circle circle = new Circle(1);

        Shape[] shapeArray = new Shape[1];
        shapeArray[0] = circle;

        CalculateAreas ca = new CalculateAreas(shapeArray);

        CalculateAreas sum = new CalculateAreas(shapeArray);
        OutputAreasoAreas = new OutputAreas(sum.sumAreas());

        oAreas.console();    // output to console
        oAreas.HTML();       // output to HTML

    }
}

The main point here is that you can now send the output to various destinations depending on requirements. If you want to add another output possibility, such as JSON, you can add it to the OutputAreas class without having to change the CalculateAreas class. As a result, you can redistribute the CalculateAreas class independently without having to do anything to the other classes.

2) OCP: Open/Close Principle

The Open/Close Principle states that you should be able to extend a class’s behavior, without modifying it.

Let’s revisit the shape example yet again. In the following code, we have a class called ShapeCalculator that accepts a Rectangle object, calculates the area of that object, and then returns that value. It is a simple application but it works only for rectangles.

class Rectangle{
    protected double length;
    protected double width;

    public Rectangle(double l, double w) {
        length = l;
        width = w;
    };
}
class CalculateAreas {

    private double area;

    public double calcArea(Rectangle r) {

        area = r.length * r.width;

        return area;

    }
}
public class OpenClosed {
    public static void main(String args[]) {

        System.out.println("Hello World");

        Rectangle r = new Rectangle(1,2);

        CalculateAreas ca = new CalculateAreas ();

        System.out.println("Area = "+ ca.calcArea(r));

    }
}

The fact that this application works only for rectangles brings us to a constraint that illustrates the Open/Closed Principle: If we want to add a Circle to the CalculateArea class (change what it does), we must change the module itself. Obviously, this is at odds with the Open/Closed Principle, which stipulates that we should not have to change the module to change what it does.

To comply with the Open/Closed Principle, we can revisit our tried and true shape example, where an abstract class called Shape is created and then all shapes must inherit from the Shape class, which has an abstract method called getArea().

At this point, we can add as many different classes as we want without having to change the Shape class itself (for example, a Circle). We can now say that the Shape class is closed.

The following code implements this solution for a rectangle and a circle, and allows for the creation of unlimited shapes:

abstract class Shape {
    public abstract double getArea();
}
class Rectangle extends Shape
{
    protected double length;
    protected double width;

    public Rectangle(double l, double w) {
        length = l;
        width = w;
    };
    public double getArea() {
        return length*width;
    }

}
class Circle extends Shape
{
    protected double radius;

    public Circle(double r) {
        radius = r;
    };
    public double getArea() {
        return radius*radius*3.14;
    }
}
class CalculateAreas {
    private double area;

    public double calcArea(Shape s) {
        area = s.getArea();
        return area;
    }
}
public class OpenClosed {
    public static void main(String args[]) {

        System.out.println("Hello World");

        CalculateAreas ca = new CalculateAreas();

        Rectangle r = new Rectangle(1,2);

        System.out.println("Area = " + ca.calcArea(r));

        Circle c = new Circle(3);

        System.out.println("Area = " + ca.calcArea(c));

    }
}

Note that in this implementation, the CalculateAreas() method does not have to change when you add a new Shape.

You can scale your code without having to worry about legacy code. At its core, the Open/Closed Principle states that you should extend your code via subclasses and the original class does not need to be changed. However, the word extension is problematic in several discussions relating to SOLID. As we will cover in detail, if we are to favor composition over inheritance, how does this affect the Open/Closed Principle?

When following one of the SOLID principles, code may also comply with one of the other SOLID principles. For example, when designing to follow the Open/Closed Principle, the code may also comply with the Single Responsibility Principle.

3) LSP: Liskov Substitution Principle

The Liskov Substitution Principle states that the design must provide the ability to replace any instance of a parent class with an instance of one of its child classes. If a parent class can do something, a child class must also be able to do it.

Let’s examine some code that might look reasonable but violates the Liskov Substitution Principle. In the following code, we have the typical abstract class called Shape. Rectangle then inherits from Shape and overrides its abstract method calcArea(). Square, in turn, inherits from Rectangle.

abstract class Shape{
    protected double area;

    public abstract double calcArea();
}
class Rectangle extends Shape{
    private double length;
    private double width;

    public Rectangle(double l, double w){
        length = l;
        width = w;
    }
    public double calcArea() {
        area = length*width;
        return (area);
    };

}
class Square extends Rectangle{
    public Square(double s){
        super(s, s);
    }
}

public class LiskovSubstitution {
    public static void main(String args[]) {

        System.out.println("Hello World");

        Rectangle r = new Rectangle(1,2);

        System.out.println("Area = " + r.calcArea());

        Square s = new Square(2);

        System.out.println("Area = " + s.calcArea());

    }
}

So far so good: a rectangle is-a shape so everything looks fine. Because a square is-a rectangle we are still fine—or are we?

Now we enter into a somewhat philosophical discussion: Is a square really a rectangle? Many people would say yes. However, while the square may well be a specialized type of a rectangle, it does have different properties than a rectangle. A rectangle is a parallelogram (opposite sides are congruent), as is a square. Yet, a square is also a rhombus (all sides are congruent), whereas a rectangle is not. Therefore, there are some differences.

The geometry is not really the issue when it comes to OO design. The issue is how we build rectangles and squares. Here is the constructor for the Rectangle class:

public Rectangle(double l, double w){
    length = l;
    width = w;
}

The constructor obviously requires two parameters. However, the Square constructor requires just one, even though its parent class, Rectangle, is expecting two.

class Square extends Rectangle{
    public Square(double s){
    super(s, s);
   }

In actuality, the functionality to compute area is subtly different for the two classes. In fact, the Square is kind of faking the Rectangle out by passing it the same parameter twice. This may seem like an acceptable workaround, but it really is something that may confuse someone maintaining the code and could very well cause unintended maintenance headaches down the road. This is an inconsistency at minimum and, perhaps, a questionable design decision. When you see a constructor calling another constructor, it might be a good idea to pause and reconsider the design—it might not be a proper child class.

How do you address this specific dilemma? Simply put, a square is not a substitute for a rectangle and should not be a child class. Thus, they should be separate classes.

 abstract class Shape {
    protected double area;

    public abstract double calcArea();
}

class Rectangle extends Shape {

    private double length;
    private double width;

    public Rectangle(double l, double w) {
        length = l;
        width = w;
    }

    public double calcArea() {
        area = length*width;
        return (area);
    };
}

class Square extends Shape {
    private double side;

    public Square(double s){
        side = s;
    }
    public double calcArea() {
        area = side*side;
        return (area);
    };
}
public class LiskovSubstitution {
    public static void main(String args[]) {

        System.out.println("Hello World");

        Rectangle r = new Rectangle(1,2);

        System.out.println("Area = " + r.calcArea());

        Square s = new Square(2);

        System.out.println("Area = " + s.calcArea());

    }
}

4) IPS: Interface Segregation Principle

The Interface Segregation Principle states that it is better to have many small interfaces than a few larger ones.

In this example, we are creating a single interface that includes multiple behaviors for a Mammal, eat() and makeNoise():

interface IMammal {
    public void eat();
    public void makeNoise();
}
class Dog implements IMammal {
    public void eat() {
        System.out.println("Dog is eating");
    }
    public void makeNoise() {
        System.out.println("Dog is making noise");
    }
}
public class MyClass {
    public static void main(String args[]) {

        System.out.println("Hello World");

        Dog fido = new Dog();
        fido.eat();
        fido.makeNoise();
    }
}

Rather than creating a single interface for Mammal, we can create separate interfaces for all the behaviors:

interface IEat {
    public void eat();
}
interface IMakeNoise {
    public void makeNoise();
}
class Dog implements IEat, IMakeNoise {
    public void eat() {
        System.out.println("Dog is eating");
    }
    public void makeNoise() {
        System.out.println("Dog is making noise");
    }
}
public class MyClass {
    public static void main(String args[]) {

        System.out.println("Hello World");

        Dog fido = new Dog();
        fido.eat();
        fido.makeNoise();
    }
}

In reality, we are decoupling the behaviors from the Mammal class. Thus, rather than creating a single Mammal entity via inheritance (actually interfaces) we are moving to a composition-based design, similar to the strategy taken in the previous chapter.

In short, by using this approach, we can build Mammals with composition rather than being forced to utilize behaviors contained in a single Mammal class. For example, suppose someone discovers a Mammal that doesn’t eat but instead absorbs nutrients through its skin. If we were inheriting from a single Mammal class that contains the eat() behavior, the new mammal would not need this behavior. However, if we separate all the behaviors into separate, single interfaces, we can build each mammal in exactly the way it presents itself.

5) DIP: Dependency Inversion Principle

The Dependency Inversion Principle states that code should depend on abstractions. It often seems like the terms dependency inversion and dependency injection are used interchangeably; however, here are some key terms to understand as we discuss this principle:

  • Dependency inversion—The principle of inverting the dependencies

  • Dependency injection—The act of inverting the dependencies

  • Constructor injection—Performing dependency injection via the constructor

  • Parameter injection—Performing dependency injection via the parameter of a method, like a setter

The goal of dependency inversion is to couple to something abstract rather than concrete.

Although at some point you obviously have to create something concrete, we strive to create a concrete object (by using the new keyword) as far up the chain as possible, such as in the main() method. Perhaps a better way of thinking of this is to revisit the discussion presented in Chapter 8,
“Frameworks and Reuse: Designing with Interfaces and Abstract Classes,” where we discuss loading classes at runtime, and in Chapter 9, “Building Objects and Object-Oriented Design,” where we talk about decoupling and creating small classes with limited responsibilities.

In the same vein, one of the goals of the Dependency Inversion Principle is to choose objects at runtime, not at compile time. (You can change the behavior of your program at runtime.) You can even write new classes without having to recompile old ones (in fact, you can write new classes and inject them).

Much of the foundation for this discussion was put forth in Chapter 11, “Avoiding Dependencies and Highly Coupled Classes.” Let’s build on that as we consider the Dependency Inversion Principle.

Step 1: Initial Example

For the first step in this example, we revisit yet again one of the classical object-oriented design examples used throughout this book, that of a Mammal class, along with a Dog and a Cat class that inherit from Mammal. The Mammal class is abstract and contains a single method called makeNoise().

abstract class Mammal
{
    public abstract String makeNoise();
}

The subclasses, such as Cat, use inheritance to take advantage of Mammal’s behavior, makeNoise():

class Cat extends Mammal
{
    public String makeNoise()
    {
       return "Meow";
    }
}

The main application then instantiates a Cat object and invokes the makeNoise() method:

Mammal cat = new Cat();;

System.out.println("Cat says " + cat.makeNoise());

The complete application for the first step is presented in the following code:

 public class TestMammal {
    public static void main(String args[]) {

        System.out.println("Hello World
");

        Mammal cat = new Cat();;
        Mammal dog = new Dog();

        System.out.println("Cat says " + cat.makeNoise());
        System.out.println("Dog says " + dog.makeNoise());

    }
}
abstract class Mammal
{
    public abstract String makeNoise();
}
class Cat extends Mammal
{
    public String makeNoise()
    {
        return "Meow";
    }
}
class Dog extends Mammal
{
    public String makeNoise()
    {
        return "Bark";
    }
}
Step 2: Separating Out Behavior

The preceding code has a potentially serious flaw: It couples the mammals and the behavior (makingNoise). There may be a significant advantage to separating the mammal behaviors from the mammals themselves. To accomplish this, we create a class called MakingNoise that can be used by all mammals as well as non-mammals.

In this model, a Cat, Dog, or Bird can then extend the MakeNoise class and create their own noise-making behavior specific to their needs, such as the following code fragment for a Cat:

abstract class MakingNoise
{
        public abstract String makeNoise();
}
class CatNoise extends MakingNoise
{
    public String makeNoise()
    {
        return "Meow";
    }
}

With the MakingNoise behavior separated from the Cat class, we can use the CatNoise class in place of the hard coded behavior in the Cat class itself, as the following code fragment illustrates:

abstract class Mammal
{
    public abstract String makeNoise();
}
class Cat extends Mammal
{
    CatNoise behavior = new CatNoise();
    public String makeNoise()
    {
        return behavior.makeNoise();
    }
}

The following is the complete application for the second step:

public class TestMammal {
    public static void main(String args[]) {

        System.out.println("Hello World
");

        Mammal cat = new Cat();;
        Mammal dog = new Dog();

        System.out.println("Cat says " + cat.makeNoise());
        System.out.println("Dog says " + dog.makeNoise());

    }
}

abstract class MakingNoise
{
    public abstract String makeNoise();
}
class CatNoise extends MakingNoise
{
    public String makeNoise()
    {
        return "Meow";
    }
}
class DogNoise extends MakingNoise
{
    public String makeNoise()
    {
        return "Bark";
    }
}
abstract class Mammal
{
    public abstract String makeNoise();
}

class Cat extends Mammal
{
    CatNoise behavior = new CatNoise();
    public String makeNoise()
    {
        return behavior.makeNoise();
    }
}
class Dog extends Mammal
{
    DogNoise behavior = new DogNoise();
    public String makeNoise()
    {
        return behavior.makeNoise();
    }
}

The problem is that although we have decoupled a major part of the code, we still haven’t reached our goal of dependency inversion because the Cat is still instantiating the Cat noise-making behavior.

CatNoise behavior = new CatNoise();

The Cat is coupled to the low-level module CatNoise. In other words, the Cat should not be coupled to CatNoise but to the abstraction for making noise. In fact, the Cat class should not instantiate its noise-making behavior but instead receive the behavior via injection.

Step 3: Dependency Injection

In this final step, we totally abandon the inheritance aspects of our design and examine how to utilize dependency injection via composition. You do not need inheritance hierarchies, which is one of the major reasons why the concept of composition over inheritance is gaining momentum. You compose a subtype rather than creating a subtype from a hierarchical model.

To illustrate, in the original implementation, the Cat and the Dog basically contain the same exact code; they simply return a different noise. As a result, a significant percentage of the code is redundant. Thus, if you had many different mammals, there would be a lot of noise-making code. Perhaps a better design is to take the code to make noise out of the mammal.

The major leap here would be to abandon the specific mammals (Cat and Dog) and simply use the Mammal class as shown here:

class Mammal
{
    MakingNoise speaker;

    public Mammal(MakingNoisesb)
    {
        this.speaker = sb;
    }
    public String makeNoise()
    {
        return this.speaker.makeNoise();
    }
}

Now we can instantiate a Cat noise-making behavior and provide it to the Animal class, to make a mammal that behaves like a Cat. In fact, you can always assemble a Cat by injecting behaviors rather than using the traditional techniques of class building.

Mammal cat = new Mammal(new CatNoise());

The following is the complete application for the final step:

public class TestMammal {
    public static void main(String args[]) {

        System.out.println("Hello World
");

        Mammal cat = new Mammal(new CatNoise());
        Mammal dog = new Mammal(new DogNoise());

        System.out.println("Cat says " + cat.makeNoise());
        System.out.println("Dog says " + dog.makeNoise());

    }
}
class Mammal
{
MakingNoise speaker;

    public Mammal(MakingNoisesb)
    {
        this.speaker = sb;
    }
    public String makeNoise()
    {
        return this.speaker.makeNoise();
    }
}
interface MakingNoise
{
    public String makeNoise();
}
class CatNoise implements MakingNoise
{
    public String makeNoise()
    {
        return "Meow";
    }
}
class DogNoise implements MakingNoise
{
    public String makeNoise()
    {
        return "Bark";
    }
}

When discussing dependency injection, when to actually instantiate an object is now a key consideration. Even though the goal is to compose objects via injection, you obviously must instantiate objects at some point. As a result, the design decisions revolve around when to do this instantiation.

As stated earlier in this chapter, the goal of dependency inversion is to couple to something abstract rather than concrete, even though you obviously must create something concrete at some point. Thus, one simple goal is to create a concrete object (by using new) as far up the chain as possible, such as in the main() method. Always evaluate things when you see a new keyword.

Conclusion

This concludes the discussion of SOLID. The SOLID principles are one of the most influential sets of object-oriented guidelines used today. What is interesting about studying these principles is how they relate to the fundamental object-oriented encapsulation, inheritance, polymorphism, and composition, specifically in the debate of composition over inheritance.

For me, the most interesting point to take away from the SOLID discussion is that nothing is cut and dried. It is obvious from the discussion on composition over inheritance that even the age-old fundamental OO concepts are open for reinterpretation. As we have seen, a bit of time, along with the corresponding evolution in various thought processes, is good for innovation.

References

Martin, Robert, et al. Agile Software Development, Principles, Patterns, and Practices. 2002. Boston: Pearson Education, Inc.

Martin, Robert, et al. Clean Code. 2009. Boston: Pearson Education, Inc.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.222.110.194