Understanding S.O.L.I.D principles

ยท

5 min read

Understanding S.O.L.I.D principles

S.O.L.I.D principles are a set of five fundamental guidelines in software design that aim to enhance the quality, maintainability, and extensibility of code.

These principles provide a structured framework to create robust and flexible software systems, ensuring that the codebase remains manageable and adaptable as it evolves.

Let's get started with understanding what S.O.L.I.D principles mean

Single Responsibility Principle (SRP): "Separation of concerns"

  • This principle emphasizes that a class or module should have only one reason to change, meaning it should have a single, well-defined responsibility.

  • Let's say we want to implement a Journal, we want to be able to, create this journal and add entries to this journal with proper numbering of entries

  • Now suppose we want to add saving functionality to this Journal , we can resort to adding a save() function in the SAME class , but this would violate the SRP principle , it also leads to tight-coupling of the saving logic with the entry logic

  • If something in the saving logic is changed we would again have to modify the whole class -which is a bad practice

  • Since saving is a separate concern it must be handled separately

    • now every single class has a single reason to change

    • Other advantages of SRP include Enhanced code readability, Improved maintainability, Ease of testing, Modularity, Code reusability, Easier debugging, Flexible evolution, Reduced complexity, Team collaboration, Documentation and onboarding, Code quality and Adaptation to change

Open/Closed Principle (OCP): "open to extension but closed for modification"

  • The Open/Closed Principle advocates that software entities, such as classes or modules, should be open for extension but closed for modification. This encourages the use of inheritance, interfaces, and abstract classes to build upon existing functionality without altering the existing codebase.

  • By following OCP, developers can introduce new features or behaviours without risking unintended side effects in the existing code, thus enhancing code stability and minimizing regressions

  • Suppose we want to create filtering functionality for certain products on amazon on some criteria, we would want to do something like the below implementation

  • This type of implementation is not ideal as it causes a cartesian explosion and doesn't scale well as more and more criteria are added

  • A more robust implementation would be using the Specification Pattern (Enterprise Pattern)

  • We start by creating a specification interface:

  • Now a color specification can be specified as

  • Similarly, a Size specification can be specified as follows

  • Now that we have a way to handle our specifications we create a Filter Interface

  • Now concrete Implementation of this filter would look like this:

  • In the problem setup we saw how the addition of multiple criteria was causing an explosion of functions in our class-state space explosion, let see how that is resolved here

Now our specifications can be combined without modification, but with extension

Liskov Substitution Principle (LSP): "Substypes should be substitutable for base types."

  • Objects in a program should be replaceable with instances of their subtypes w/o altering the correctness of the program

  • Subclass should extend the capability of the parent class and not narrow it down.

  • For example, consider we have a rectangle class and a utility that we intend to use to set the rectangle height to 10 leaving the width unchanged

  • now we decide to extend a square from this rectangle, needless to say passing a square (which is also a valid rectangle as per implementation) in the same utility would give faulty results

  • A possible resolution here would be removing the inheritance between Rectangle and Square and instead, creating separate classes for Rectangle and Square without any inheritance relationship.

Interface Segregation

  • We should create interfaces such that our implementor only implements what they need

  • Suppose we want to create an abstraction for a printer/fax/scanner or any composition of these three

  • A better approach would be to segregate the interfaces, then classes only implement the interfaces they want

  • This also makes them easy to compose and reuse

Dependency Inversion Principle

IDEA1: High-level modules should not depend on low-level modules. // Both should depend on abstractions.

IDEA2: Abstractions should not depend on details. Details should depend on abstractions.

  • Suppose we want to model relationships between people

  • We create a low-level module that stores our relationships- this is a low-level module

  • Now if we want to perform research on the data we would need a high-level module,

  • This high-level module for researching has to have the data - the bad thing to do and the thing which will violate the dependency inversion principle is to take this data directly from the low-level module

  • In the above code if the implementation of a low-level module changes ie relationships become a vector of vector instead of a vector of tuples, or it becomes a map of some type the high-level module will break because they are tightly coupled

  • One of the solutions here is moving the children finding functionality to the low-level module itself, another is introducing an abstraction

  • Using the above two solutions. We get the same results but in a much safer way without violating DIP

NOTE: Clarity regarding abstractions vs. interfaces

  • If you want to enforce that a derived class must override a virtual function, you can make the virtual function a pure virtual function by using the = 0 syntax in its declaration. This makes the base class an abstract class, and any derived class must provide an implementation for the pure virtual function to be instantiated.

    \=> An abstract class is created by defining at least one pure virtual function in the class.

    \=> An interface can be achieved by using a class with all pure virtual functions and no member variables. An interface defines a contract or a set of methods that a class must implement, but it does not provide any implementation itself.

ย