Programming Principles III: Writing SOLID code

Programming Principles III: Writing SOLID code

Looking back on our journey, we've delved into a diverse collection of programming principles. From KISS, DRY, YAGNI, Clean Code, Fail Fast, SoC, LoD, LoLA, to TDA, these principles have illuminated the path to crafting code that is not only maintainable but also elegant. If you haven't had the chance to read the previous articles in this series, you can find the link here.

In this last article, we will delve into the SOLID principles and how they play a pivotal role in enhancing the maintainability and extensibility of code.

What is the SOLID Principles?

The SOLID principles are a set of five design principles that are significantly used in Object-Oriented Programming(OOP) to create software that is maintainable, flexible, and scalable. Introduced by Robert C. Martin in the early 2000s, these principles are widely regarded as best practices for achieving clean, modular, and efficient software.

While the SOLID principles were originally formulated with OOP in mind, their underlying concepts can be highly beneficial and applicable in a wide range of programming paradigms including Functional Programming and Procedural Programming.

Now, let's delve into what the SOLID acronym stands for:

1. Single Responsibility Principle(SRP)

The Single Responsibility Principle emphasizes that a class should have only one reason to change. In other words, a class should have a single, well-defined responsibility or function. This principle closely aligns with the Separation of Concerns (SoC) principle we explored in the previous article.

To better grasp the concept of SRP, let's consider an example within the context of a basic banking application and explore how SRP can be effectively applied.

Violation of SRP:

class BankAccount {
  constructor(accountNumber) {
    this.accountNumber = accountNumber;
    this.balance = 0;
  }

  deposit(amount) {
    this.balance += amount;
  }

  withdraw(amount) {
    this.balance -= amount;
  }

  sendNotification(message, recipient) {
    // Send an email notification to the recipient
    // Violates SRP by handling both account management and notification
  }
}
        

In this code, the BankAccount class is responsible for both managing the account (deposits and withdrawals) and sending notifications. This violates the SRP because it has two distinct responsibilities.

Adherence to SRP:

class BankAccount {
  constructor(accountNumber) {
    this.accountNumber = accountNumber;
    this.balance = 0;
  }

  deposit(amount) {
    this.balance += amount;
  }

  withdraw(amount) {
    this.balance -= amount;
  }
}

class NotificationService {
  sendNotification(message, recipient) {
    // Send an email notification to the recipient
  }
}
        

In the refactored code, we've separated the responsibility of sending notifications into a separate NotificationService class, while the BankAccount class now solely focuses on managing the bank account. This adheres to the SRP as each class has a single, well-defined responsibility.


2. Open-Closed Principle(OCP)

This principle states that a software entity such as a class, a module, or a function should be open for extension but closed for modification.

Expanding on the OCP Principle:

  • Open for Extension: This aspect of the OCP encourages you to design your code in a way that allows you to add new functionalities by creating new code entities, such as subclasses or additional modules, without altering the existing, working code. This promotes code reuse and extensibility.
  • Closed For Modification: This aspect emphasizes that once a module is considered stable and functional, you should avoid making changes to its source code. Changing the code of a working module can introduce bugs, affect the stability of the existing features, and require extensive testing.

Let's look at a simple example of a class that calculates the area of different shapes to illustrate how OCP is applied:

Violation of OCP:

class Shape {
  constructor(type, data) {
    this.type = type;
    this.data = data;
  }

  calculateArea() {
    if (this.type === 'circle') {
      return Math.PI * this.data.radius ** 2;
    } else if (this.type === 'rectangle') {
      return this.data.width * this.data.height;
    }
  }
}        

In this code, the Shape class violates the OCP because every time you want to add a new shape (e.g., a triangle), you need to modify the calculateArea method, which should be closed for modification.

Adherence to OCP:

class Shape {
  calculateArea() {
    throw new Error("calculateArea method must be implemented in subclasses");
  }
}

class Circle extends Shape {
  constructor(radius) {
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Triangle extends Shape {
  constructor(base, height) {
    this.base = base;
    this.height = height;
  }

  calculateArea() {
    return (this.base * this.height) / 2;
  }
}        

In this modified code, we've created subclasses like Circle, Rectangle, and Triangle, each responsible for its own area calculation.

This adheres to the OCP because you can easily add new shapes by creating new subclasses without modifying the existing code. The Shape class is open for extension but closed for modification.


3. Liskov Substitution Principle (LSP)

This principle states that subclasses should be substitutable for their base classes without changing the correctness of the program. In simpler terms, it means that if you have a base class and you create a subclass from it, you should be able to use the subclass wherever you use the base class, and everything should still work as expected. The subclass should respect the same rules and behaviors as the base class.

Let's explore this principle further with an example.

Violation of LSP:

class Bird {
    fly() {
        console.log('Bird is flying');
    }
}

class Sparrow extends Bird {
  // Sparrows can fly 
}

//Violation of LSP
class Penguin extends Bird {
    // Penguins don't fly, but we've changed the method name to indicate that.
    canFly() {
        console.log('Penguin cannot fly');
    }
}

const bird = new Bird();
const sparrow = new Sparrow();
const penguin = new Penguin();

bird.fly(); // Outputs: "Bird is flying"
sparrow.fly(); // Outputs: "Bird is flying"
penguin.canFly(); // Outputs: "Penguin cannot fly"
        

In this code, the Penguin class violates the Liskov Substitution Principle by changing the method name from fly to canFly. Consequently, substituting a Penguin object for a Bird object can result in unexpected behavior due to the inconsistency in method names between the base class and the derived class. The LSP encourages maintaining a consistent contract when inheriting from base classes, including method names and signatures.

Adherence to LSP:

class Bird {
    fly() {
        console.log('Bird is flying');
    }
}

class Sparrow extends Bird {
  // Sparrows can fly 
}

class Penguin extends Bird {
    // Penguins don't fly, but this method must be implemented.
    fly() {
        console.log('Penguin cannot fly');
    }
}

const bird = new Bird();
const sparrow = new Sparrow();
const penguin = new Penguin();

bird.fly(); // Outputs: "Bird is flying"
sparrow.fly(); // Outputs: "Bird is flying"
penguin.fly(); // Outputs: "Penguin cannot fly"        

In this modified code, the Penguin class does not violate the LSP because it doesn't change the method's signature (name and parameters) but uses method overriding to provide a behavior appropriate for penguins, which is to indicate that they cannot fly. The code still maintains the principle that you can use a subclass (Penguin) wherever the base class (Bird) is expected, and it behaves as expected without unexpected side effects.


4. Interface Segregation Principle (ISP)

This principle emphasizes that clients (classes that use interfaces) should not be forced to depend on interfaces they don't use. Instead, each client should have access to smaller, client-specific interfaces that contain only the methods and properties relevant to their needs.

Let's expand on the ISP with an example.

Violation of ISP:

class Machine {
  print() {
    // Print something
  }

  scan() {
    // Scan something
  }
}

class Printer extends Machine {
  print() {
    // Implementation for printing
  }
}

const myPrinter = new Printer();
myPrinter.print(); // This works
myPrinter.scan(); // Empty implementation        

This code a violation of ISP because it forces the Printer class to implement a method it doesn't need (the scan method) due to its inheritance from the Machine class.

Adherence to ISP:

class Printable {
  print() {
   // Implementation for printing
  }
}

class Scannable {
  scan() {
   // Implementation for scanning
  }
}

class Printer extends Printable {
  print() {
    // Implementation for printing
  }
}

const myPrinter = new Printer();
myPrinter.print();         

In modified code, we've created separate classes (Printable and Scannable) for specific functionalities. The Printer class implements the Printable class, and it doesn't need to implement the methods it doesn't use, which aligns with the Interface Segregation Principle.


5. Dependency Inversion Principle (DIP)

This principle suggests that high-level modules(main business logic) should not depend directly on low-level modules but should both depend on abstractions such as interfaces or abstract classes. Much like the Law of Demeter discussed in a previous article, this principle fosters loose coupling and flexibility in software design.

Let's look at an example to further understand DIP

Violation of DIP:

class Engine {
  start() {
    // Implementation to start the engine
  }
}

class Car {
  constructor() {
    this.engine = new Engine(); // Direct instantiation of a low-level module
  }

  drive() {
    this.engine.start(); // Dependence on a specific low-level module
  }
}

const myCar = new Car();
myCar.drive();
        

In this code, the Car class directly depends on the Engine class by instantiating it. This is a violation of DIP because the high-level module (Car) should not directly depend on low-level modules (Engine).

Adherence to DIP:

class Engine {
  start() {
    // Implementation to start the engine
  }
}

class Car {
  constructor(engine) {
    this.engine = engine;
  }

  drive() {
    this.engine.start(); // Depend on an abstraction (Engine) instead of a specific low-level module
  }
}

const engine = new Engine();
const myCar = new Car(engine);
myCar.drive();
        

In this modified code, the Car class depends on the Engine class through dependency injection. It no longer directly instantiates the low-level module, and it depends on an abstraction (the Engine class) instead of a specific low-level module, which aligns with the Dependency Inversion Principle.


In Conclusion,

The SOLID principles are essential guidelines in software design and object-oriented programming, encompassing the Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). These principles promote code quality, maintainability, and flexibility.

By embracing the SOLID principles, developers can create software that is more comprehensible, adaptable, and extensible, leading to enhanced software design and improved overall quality.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了