From Novice to Ninja: Mastering SOLID Principles in Object-Oriented Programming
Nayan Patidar
Posted on March 17, 2024
From theory to practice, SOLID Principles are the cornerstone of Object-Oriented Programming (OOP). Understanding their importance is key as they shape the foundation of your projects. Let's explore their real-world significance and how they guide the development process.
What does SOLID even mean?
SOLID is a coding practice that professionals follow to make code strong and easy to understand. It tackles issues like reusing code, fixing bugs, and keeping the code clean. Essentially, it's a set of principles ensuring that your code remains flexible, maintainable, and straightforward for smoother collaboration among developers. Now let's discuss each SOLID principle one by one with examples.
1] S - Single Responsibility Principle
It emphasizes assigning a specific class or function a single responsibility, ensuring it has only one reason to change.
For example: In a music player, using SRP might involve having distinct functions for playing and stopping the music. This separation allows each function to handle its task independently, making the code more modular and easier to maintain. Each class is responsible for a single, well-defined action, aligning with the principle.
public class MusicPlayer {
private String song;
private boolean isPlaying;
public MusicPlayer(String song) {
this.song = song;
this.isPlaying = false;
}
// Separate method for playing the song
public void play() {
System.out.println("Playing: " + song);
isPlaying = true;
}
// Separate method for stopping the song
public void stop() {
System.out.println("Stopping: " + song);
isPlaying = false;
}
public static void main(String[] args) {
MusicPlayer player = new MusicPlayer("Song Name");
//Here Both the play and stop can be called accordingly
player.play();
player.stop();
}
}
2] O - Open/ Closed Principle
The Open/Closed Principle (OCP) allows extending classes or functions with new features without modifying the existing code.
For example: Consider a scenario where our code initially drew rectangles, but requirements changed to circles. Rather than modifying the existing rectangle-drawing code, we adhere to the Open/Closed Principle. We create a new class for circles, extending a more general 'Shape' class. If the need to draw rectangles arises again, we can do so without altering the original code. This ensures system extensibility, allowing the addition of new shapes without modifying existing code.
class Shape {
public void draw() {
System.out.println("Drawing a shape");
}
}
// Extension without modifying the original class
class Rectangle extends Shape {
@Override
public void draw() {
System.out.println("Drawing a rectangle");
}
}
// Another extension without modifying the original class
class Circle extends Shape {
@Override
public void draw() {
System.out.println("Drawing a circle");
}
}
public class Main {
public static void main(String[] args) {
drawShape(new Rectangle()); // Using the Rectangle
drawShape(new Circle()); // Using the Circle
}
// Method to draw a shape
public static void drawShape(Shape shape) {
shape.draw();
}
}
3] L - Liskov Substitution Principle
It might sound difficult but take it easy !!! Simply what is means is that if a program is designed to work with a certain type of object, it should also work seamlessly with any of its subtypes.
Let's understand it with an example: Now consider there is a base class called Shape which is extended by two other classes Rectangle and Square respectively. Now because these classes are extended by the base class, you can give the objects of the subclasses instead of the main shape class and then return access to the property accordingly.
// Main Base Class
class Shape {
public double area() {
return 0;
}
}
// Subclass
class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double area() {
return length * width;
}
}
// Subclass
class Square extends Shape {
private double side;
public Square(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
public class Main {
public static void main(String[] args) {
calculateArea(new Rectangle(5, 10));
calculateArea(new Square(4));
}
public static void calculateArea(Shape shape) {
System.out.println("Area: " + shape.area());
}
}
4] I - Interface Segregation Principle
Again here the full form is somewhat complex, but don't panic! This Principle in simple words suggests that a class should only be required to implement the interfaces that are necessary for its specific functionality, and it should not be forced to implement interfaces with additional, unnecessary methods.
// Original large interface
interface Document {
void print();
void scan();
}
interface Printable {
void print();
}
interface Scannable {
void scan();
}
// Class implementing printable interface as it has the
// functionality of only printing and not scanning
class Printer implements Printable {
@Override
public void print() {
System.out.println("Printing the document");
}
}
// Class implementing scannable interface as it has the
// functionality of only scanning and not printing
class Scanner implements Scannable {
@Override
public void scan() {
System.out.println("Scanning the document");
}
}
class Copier implements Printable, Scannable {
@Override
public void print() {
System.out.println("Printing the document");
}
@Override
public void scan() {
System.out.println("Scanning the document");
}
}
5] D - Dependency Inversion Principle
Here we go again! Jargon! What the definitions say is 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.
Abstraction (Engine):
The Engine interface represents the abstraction, defining the common methods start() and stop().Low-level Module (CarEngine):
The CarEngine class implements the Engine interface, representing the low-level module.High-level Module (Car):
The Car class is the high-level module that depends on the Engine abstraction. It receives an instance of Engine through the constructor, following the DIP by depending on an abstraction.Client Code (Main class):
In the client code, an object of the low-level module (CarEngine) is created. This low-level module is then passed to the high-level module (Car) during instantiation, demonstrating dependency injection. The drive() method of the high-level module is called, which in turn invokes the start() and stop() methods of the Engine abstraction.
interface Engine {
void start();
void stop();
}
// Low-level module representing a car engine
class CarEngine implements Engine {
@Override
public void start() {
System.out.println("Car engine started");
}
@Override
public void stop() {
System.out.println("Car engine stopped");
}
}
// High-level module (Engine)
class Car {
private Engine carEngine;
public Car(Engine carEngine) {
this.carEngine = carEngine;
}
public void drive() {
carEngine.start();
System.out.println("Car is moving");
carEngine.stop();
}
}
public class Main {
public static void main(String[] args) {
Engine carEngine = new CarEngine(); // Low-level module
Car car = new Car(carEngine); // High-level module
car.drive();
}
}
So this is all for this blog. Will write more about such things !!
Posted on March 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
March 17, 2024