The SOLID principles - Let's get to know OOP better!
One step ahead of yesterday
The Story
When I was training a kata in codewars I had this question which is "What is the difference between these two lines?" the code below.
1. Map<Integer, String> charCounterMapping = new HashMap<>();
2. HashMap<Integer, String> charCountMapping = new HashMap<>();
So, I looked everywhere on the internet for the answer and nothing could give me the answer. Then I went to my good friend who is a brilliant Java developer, @wikum. So, he explained me the reason in two steps.
The practical reason
The theoretical reason - The SOLID principles
In this article I am going to focus on the second step which is the theoretical reason or the SOLID principles that every programmer should know about.
The SOLID principles
Simply put, these are five design principles that we should follow when we are developing something using Object Oriented Programming. Also these principles will help us automate our thinking process and make more understandable, flexible and maintainable software.
The SOLID acronyms stands for these five principles below,
Single Responsibility Principle ( SRP )
Open/Closed Principle ( OCP )
Liskov Substitution Principle ( LSP )
Interface Segregation Principle ( ISP )
Dependency Inversion Principle ( DIP )
So, let us go through each principle and understand them simple and clear.
1. Single Responsibility Principle ( SRP )
This principle says a class should have only one reason to change, meaning it should have only one job or responsibility.
Let me explain you further more,
A class with multiple responsibilities is harder to understand, maintain, and test.
In a class of multiple responsibilities, changes in one responsibility can affect the other, leading to tighter coupling and increased risk of bugs.
So, it is always good to assign one class with only a one responsibility.
Now, let us see an example to understand this.
public class Student {
private String name;
private int age;
// Handling student details
public void setName(String name) { this.name = name; }
public String getName() { return name; }
public void setAge(int age) { this.age = age; }
public int getAge() { return age; }
// Handling student report
public void printReport() {
// Code to print student report
}
}
In the above class we are using it to two things,
To create a class
To print the report
So, this is not what we want. We want to make it more simpler like below.
public class Student {
private String name;
private int age;
public void setName(String name) { this.name = name; }
public String getName() { return name; }
public void setAge(int age) { this.age = age; }
public int getAge() { return age; }
}
public class ReportPrinter {
public void printStudentReport(Student student) {
// Code to print student report
}
}
Here, I separated it to two classes and each class only have one job.
2. Open / Closed principle
This principle says the software entity(class, function etc.) should be open to extension but should be closed for modification.
Let me put this simply for you,
Changing existing code when modifying will change the entire code and it will lead to conflicts.
Instead only add an extension or something new.
Let me give you an example,
public class AreaCalculator {
public double calculateCircleArea(double radius) {
return Math.PI * radius * radius;
}
public double calculateSquareArea(double side) {
return side * side;
}
}
Now, let us try changing this according to the rule.
// Define a Shape interface with a method to calculate the area
public interface Shape {
double calculateArea();
}
// Implement the Shape interface for Circle
public class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// Implement the Shape interface for Square
public class Square implements Shape {
private double side;
public Square(double side) {
this.side = side;
}
@Override
public double calculateArea() {
return side * side;
}
}
// The AreaCalculator class now works with any Shape
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
I have implemented this rule using an interface which allows me to maintain a one method to get the area. So, this will only add an extension if you want to find an area of another shape other than circle and square.
3. Liskov Substitution Principle
This principle suggests that sub-types must be substitutable for their base types without changing the correctness of the program.
In other words A class should inherit its characteristics without being able to change them. I will explain this with the following example.
Imagine a Penguintoy,
public class BirdToy {
public void fly() {
System.out.println("Flying...");
}
}
public class PenguinToy extends BirdToy {
@Override
public void fly() {
}
}
The Problem here is Penguins cannot fly. So, the correct way of doing this is,
public abstract class BirdToy {
// Bird toy properties and methods
}
public class FlyingBirdToy extends BirdToy {
public void fly() {
System.out.println("Flying...");
}
}
public class PenguinToy extends BirdToy {
// Penguins can't fly, but they can have other behaviours
}
This way we can create a Penguin toy without having the fly method.
Abstract class does not contain the fly method.
Only the FlyingBird class contains the fly method. So, it will lead to only the classes inherit from FlyingBird class to have the fly method.
4. Interface Segregation Principle
It says No client should be forced to depend on methods it does not use. This means that interfaces should be small and specific to clients.
Let me explain this to using an example.
Imagine you have a toy that can walk and swim. But some toys can only walk, and others can only swim.
I will write the common and wrong implementation for this.
public interface Toy {
void walk();
void swim();
}
public class WalkingToy implements Toy {
@Override
public void walk() {
System.out.println("Walking...");
}
@Override
public void swim() {
throw new UnsupportedOperationException("This toy can't swim!");
}
}
According to above code every toy must have the ability to walk and swim. So, it's wrong.
So, How we can avoid this? The correct way of doing this is,
interface WalkingToy {
void walk();
}
interface SwimmingToy {
void swim();
}
class SimpleWalkingToy implements WalkingToy {
@Override
public void walk() {
System.out.println("Walking...");
}
}
class SimpleSwimmingToy implements SwimmingToy {
@Override
public void swim() {
System.out.println("Swimming...");
}
}
class SimplyWalkingSwimmingToy implements SwimmingToy, WalkingToy {
@Override
public void walk() {
System.out.println("I can walk");
}
@Override
public void swim() {
System.out.println("I can swim");
}
}
Here I have created two interfaces for two different behaviours. One for Walking and one for Swimming.
By using this two interfaces I could create only walking toy, only swimming toy as well as the toy that could both swim and walk.
5. Dependency Inversion Principle
This principle suggests that 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.
Think of it this way. Imagine you have a toy switch that turns on a light. But you want the switch to turn on any toy, not just a light.
So, you would implement it this way.
public class LightToy {
public void turnOn() {
System.out.println("Light on!");
}
}
public class ToySwitch {
private LightToy light;
public ToySwitch(LightToy light) {
this.light = light;
}
public void press() {
light.turnOn();
}
}
Why this is wrong?
The ToySwitch class (a high-level module) directly depends on the LightToy class (a low-level module). This makes the ToySwitch class less flexible and harder to extend or modify because it's tightly coupled to the LightToy class.
So, In case we need to extend this ToySwitch to turn on a another type of toy we want be able to use it.
So, let us correct this code.
public interface SwitchableToy {
void turnOn();
}
public class LightToy implements SwitchableToy {
@Override
public void turnOn() {
System.out.println("Light on!");
}
}
public class MusicToy implements SwitchableToy {
@Override
public void turnOn() {
System.out.println("Music playing!");
}
}
public class ToySwitch {
private SwitchableToy toy;
public ToySwitch(SwitchableToy toy) {
this.toy = toy;
}
public void press() {
toy.turnOn();
}
}
In the above code I changed the ToySwitch class to depend on the interface rather than the lower level class.
In order to understand it further more look the below implementation.
public class Main {
public static void main(String[] args) {
SwitchableToy lightToy = new LightToy();
ToySwitch lightSwitch = new ToySwitch(lightToy);
lightSwitch.press(); // Output: Light on!
SwitchableToy musicToy = new MusicToy();
ToySwitch musicSwitch = new ToySwitch(musicToy);
musicSwitch.press(); // Output: Music playing!
}
}
So, this is all about the SOLID principles. Using these principles will help to develop more maintainable, understandable and most importantly testable code.
Summary
This article explores the theoretical reasons behind different coding practices, specifically focusing on the SOLID principles in Object-Oriented Programming. These principles include Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Each principle is explained with examples, helping developers create more understandable, flexible, and maintainable software.