Architecting Brilliance: Unveiling the Power of SOLID Principles in Software Development.
The SOLID principles of object-oriented programming help make object-oriented designs more understandable, flexible, and maintainable.
They help developers work together easily and write code effectively.
SOLID is a mnemonic acronym for the five design principles of Object-Oriented class design. These principles are:
In this article, you will learn what these principles stand for and how they work using Dart examples. The examples should be fine even if you are not fully conversant with Dart because they also apply to other programming languages.
What is the Single-Responsibility Principle (SRP)?
According to the Single Responsibility Principle (SRP), a class should have a single reason to undergo modifications. This implies that a class should be responsible for only one task and perform a single function.
Let's examine a specific example. While it may be tempting to group similar classes, doing so actually goes against the Single Responsibility Principle. Wondering why?
The ValidatePerson class below has three methods: two validation methods, (ValidateName() and ValidateAge()), and a Display() method.
class ValidatePerson {
String name;
int age;
ValidatePerson(this.name, this.age);
bool validateName(String name) {
return name.length > 3;
}
bool validateAge(int age) {
return age > 18;
}
void display() {
if (validateName(name) && validateAge(age)) {
print('Name: $name and Age: $age');
} else {
print('Invalid');
}
}
}
The ValidatePerson class violates the SRP principle by performing two tasks: validating the person's name and age, and displaying some information using the Display() method. It is recommended to separate these responsibilities into distinct classes to ensure a more maintainable and scalable codebase.
To prevent this issue, it is important to segregate the code responsible for various actions and functions. By having each class dedicated to a single task, changes can be made more efficiently and with a clear purpose.
This means that the ValidatePerson class will only be responsible for validating a user, as seen below:
class ValidatePerson {
late String name;
late int age;
ValidatePerson(this.name, this.age);
bool validateName(String name) {
return name.length > 3;
}
bool validateAge(int age) {
return age > 18;
}
}
While the new class DisplayPerson will now be responsible for displaying a person, as you can see in the code block below:
class DisplayPerson {
late String name;
late int age;
late ValidatePerson validate;
DisplayPerson(this.name, this.age) {
validate = ValidatePerson(name, age);
}
void display() {
if (validate.validateName(name) && validate.validateAge(age)) {
print('Name: $name and Age: $age');
} else {
print('Invalid');
}
}
}
With this, you will have fulfilled the single-responsibility principle, meaning our classes now have just one reason to change. If you want to change the DisplayPerson class, it won’t affect the ValidatePerson class.
What is the Open-Closed Principle (OCP)?
The Open-Closed Principle may seem complex as it involves two directions. As defined by Bertrand Meyer on Wikipedia, the principle suggests that software entities like classes, modules, and functions should be open for extension while being closed for modification.
This definition can be confusing, but an example and further clarification will help you understand.
There are two primary attributes in the OCP:
OCP means that a class, module, function, and other entities can extend their behavior without modifying their source code. In other words, an entity should be extendable without modifying the entity itself. How?
For example, suppose you have an array of carColors, which contains a list of possible flavors. In the makeCar class, a make() method will check if a particular flavor exists and log a message.
const List<String> carColors = ['Black', 'Blue'];
class MakeCar {
late String color;
MakeCar(this.color);
void make() {
if (carColors.contains(color)) {
print('Congrats. Now you have a car.');
} else {
print('Epic fail. No Car Color found.');
}
}
}
The code above fails the OCP principle. Why? Well, because the code above is not open to an extension, meaning for you to add a new color, you would need to directly edit the carColors array. This means that the code is no longer closed to modification. Haha (that's a lot).
To fix this, you would need an extra class or entity to handle addition, so you no longer need to modify the code directly to make any extension.
List<String> carColors = ['Black', 'Blue'];
class MakeCar {
late String color;
MakeCar(this.color);
void make() {
if (carColors.contains(color)) {
print('Congrats. Now you have a car.');
} else {
print('Epic fail. No Car Color found.');
}
}
}
class AddCarColor {
late String color;
AddCarColor(this.color);
void add() {
carColors.add(color);
}
}
Here, we've added a new class — addCarColor – to handle addition to the carColors array using the add() method. This means your code is closed to modification but open to an extension because you can add new colors without directly affecting the array.
var addCarColor = AddCarColor('Yellow');
addCarColor.add();
makeCar.make();
Also, notice that SRP is in place because you created a new class.
What is the Liskov Substitution Principle (LSP)?
In 1987, the Liskov Substitution Principle (LSP) was introduced by Barbara Liskov in her conference keynote “Data Abstraction”. A few years later, she defined the principle like this:
领英推荐
“Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T”.
Honestly, that definition might not be what most software developers are looking for ??. Allow me to provide a more object-oriented programming-centric explanation.
The LSP states that subtypes should be substitutable for their base types. This means that if we have a class A that is a subtype of class B, we should be able to use an object of type A wherever an object of type B is expected, without affecting the correctness of the program.
In short, The child class should be able to do everything to do everything the parent class can do.
Let’s take an example of a class hierarchy that represents different types of animals. We can use the LSP to ensure that any subclass can be used interchangeably with its superclass.
Here’s an example of how to use the LSP in Dart:
abstract class Animal {
String name;
String speak();
}
class Dog extends Animal {
@override
String speak() => 'Woof!';
}
class Cat extends Animal {
@override
String speak() => 'Meow!';
}
class AnimalApp {
void run() {
final animals = [Dog(), Cat()];
animals.forEach((animal) => print('${animal.name} says ${animal.speak()}'));
}
}
In this example, we have an abstract Animal class with a name property and a speak method that will be implemented by its concrete subclasses. The AnimalApp class creates a list of animals and prints out their names and the sounds they make.
Thanks to the LSP, we can treat each animal as an Animal object, even though they are actually Dog and Cat objects. This makes the code more flexible and easier to maintain.
Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) states that “a client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use”. What does this mean?
Just as the term segregation means — this is all about keeping things separated, meaning separating the interfaces.
abstract class ShapeInterface {
double calculateArea();
double calculateVolume();
}
When any class implements this interface, all the methods must be defined even if you won't use them or if they don’t apply to that class.
abstract class ShapeInterface {
double calculateArea();
double calculateVolume();
}
class Square implements ShapeInterface {
@override
double calculateArea() {
// Implementation for calculating the area of a square
// ...
return 0.0;
}
@override
double calculateVolume() {
// ...
return 0.0;
}
}
class Cuboid implements ShapeInterface {
@override
double calculateArea() {
// Implementation for calculating the area of a cuboid
// ...
return 0.0;
}
@override
double calculateVolume() {
// Implementation for calculating the volume of a cuboid
// ...
return 0.0;
}
}
class Rectangle implements ShapeInterface {
@override
double calculateArea() {
// Implementation for calculating the area of a rectangle
// ...
return 0.0;
}
@override
double calculateVolume() {
// ...
return 0.0;
}
}
Upon reviewing the example provided, it is evident that the volume of a square or rectangle cannot be computed. It is mandatory to define all methods within the class implementing the interface, regardless of whether they are necessary or not.
To fix this, you would need to segregate the interface.
abstract class ShapeInterface {
double calculateArea();
}
abstract class ThreeDimensionalShapeInterface {
double calculateArea();
double calculateVolume();
}
You can now implement the specific abstract that works with each class.
abstract class ShapeInterface {
double calculateArea();
}
abstract class ThreeDimensionalShapeInterface {
double calculateArea();
double calculateVolume();
}
class Square implements ShapeInterface {
@override
double calculateArea() {
// Implementation for calculating the area of a square
// ...
return 0.0;
}
}
class Cuboid implements ThreeDimensionalShapeInterface {
@override
double calculateArea() {
// Implementation for calculating the area of a cuboid
// ...
return 0.0;
}
@override
double calculateVolume() {
// Implementation for calculating the volume of a cuboid
// ...
return 0.0;
}
}
class Rectangle implements ShapeInterface {
@override
double calculateArea() {
// Implementation for calculating the area of a rectangle
// ...
return 0.0;
}
}
Dependency Inversion Principle (DIP)
This principle aims to promote loosely coupling software modules so that high-level modules (which contain complex logic) are easily reusable and remain unaffected by changes in low-level modules (which provide utility features).
According to Wikipedia, this principle states that:
This means that we should define interfaces or abstract classes for our dependencies so that we can easily switch between different implementations without affecting the rest of the application.
Let’s take an example of a class that sends notifications. We can use the DIP to make this class more flexible, by defining an interface for its dependencies.
// Abstract class defining a notification service contract
abstract class NotificationService {
// Method to send a notification with a given message
Future<void> sendNotification(String message);
}
// Concrete implementation of NotificationService for sending email notifications
class EmailService implements NotificationService {
@override
Future<void> sendNotification(String message) async {
// Implementation to send email notification
// (Actual logic for sending email goes here)
}
}
// Concrete implementation of NotificationService for sending push notifications
class PushNotificationService implements NotificationService {
@override
Future<void> sendNotification(String message) async {
// Implementation to send push notification
// (Actual logic for sending push notification goes here)
}
}
// Class responsible for sending notifications using a given NotificationService
class NotificationSender {
final NotificationService _service;
// Constructor that takes a NotificationService instance
NotificationSender(this._service);
// Method to send a notification using the injected NotificationService
Future<void> send(String message) => _service.sendNotification(message);
}
In this example, we define a common interface, NotificationService, with a sendNotification method. There are two implementations, EmailService and PushNotificationService. The NotificationSender class relies on the NotificationService interface, avoiding direct dependencies on specific services like EmailService or PushNotificationService.
By relying on an interface rather than a specific implementation, the NotificationSender class becomes more adaptable. This design choice simplifies the process of switching between different notification services, eliminating the need to modify the NotificationSender class itself.
Conclusion :
When writing code, it is important to adhere to these principles as they facilitate collaboration among multiple people working on your project. These principles streamline the tasks of extending, modifying, testing, and refactoring your code. It is crucial to grasp their meanings, functions, and significance beyond just object-oriented programming.
S.O.L.I.D Principles are guidelines, not strict rules. Apply them sensibly to make your code easy to maintain and extend. Avoid excessive code fragmentation in the pursuit of SRP or S.O.L.I.D. Remember, the goal is maintainability, S.O.L.I.D is a tool, not the ultimate objective.
Have fun coding!??
Great share Sufiyan Sakkeer
Frontend Engineer at Tikanga Pvt. Ltd
9 个月????