Improve Object Oriented Design in Python
In this tutorial, we will learn how to improve the object-oriented design in Python. When we write classes and design the interaction in Python, we follow a set of instructions that helps to build better object-oriented code. Object-oriented design is one of the popular and widely accepted sets of standards. It is also known as the solid principle. In C++ and Java, these principles are widely used. However, it may be new to you that Python also follows the SOLID principle. You can consider applying these principles in our OOD.
SOLID is an acronym that groups the five core principles that apply to object-oriented design. These are given below -
We will explain each principle in detail with suitable examples. We will also learn how to apply them in Python. These principles will help to gain a strong understanding of writing more straightforward, organized, scalable, and reusable object-oriented code. Let's start with the first principle.
Single-Responsibility Principle (SRP)
The single responsibility principle states that - A class should have only one reason to change. The meaning of this statement is a class should have only one responsibility, as expressed through its methods. If a class is responsible for more than one task, it is good to be separate those tasks into separate classes. This principle follows the concept of separation of concerns, which suggests that you should split your programs into different sections. Each section must resolve a separate concern.
To explain the single-responsibility principle, we use the following example.
In the above example, we have created a FileHandler class which is responsible for .read() and .write() methods to manage the files. It also deals with the ZIP archives by providing the .compress() and .decompress() methods.
This class doesn't follow the single-responsibility principle because it has two reasons for changing its internal implementation. We can make it more robust by splitting the class into two smaller, more dedicated classes, each with own specific concern.
In the modified code example, the FileHandler class has the responsibility of managing a file, which includes reading from and writing to the file. The ZipFileHandler class, on the other hand, has the responsibility of compressing and decompressing a file using the ZIP format. By splitting the functionality into separate classes, we make them smaller, more manageable, and easier to understand.
Applying the SRP doesn't mean that a class should have only one method, but rather that it should have a single overarching responsibility or purpose. It's important to use your judgment and determine what establishes a coherent responsibility for a class in your specific codebase.
Open-Close Principle (OCP)
The open-close principle refers that - A class should be open for extension, but closed for modification.
Let's understand the following example.
The Shape class takes the shape_type as an argument that can be either "rectangle" or circle". We also define the **kwargs to take the specific set of attribute. If we pass the rectangle then we need to pass the height and width keyword arguments so that we can construct a proper rectangle. If we set the self type as circle, we must pass a radius argument to construct a circle. Shape also has a .calculate_area() method that computes the area of the current shape according to its .shape_type.
The current implementation of the Shape class violates the Open-Closed Principle, as it requires modification of existing code to add a new shape (e.g., a square).
The current implementation of the Shape class raises concerns, particularly concerning to the Open-Closed Principle. At first glance, it becomes evident that modifying the class is necessary to add a new shape, such as a square.
The Open-Closed Principle states that software entities should be open for extension but closed for modification. In other words, we should be able to introduce new functionality without altering existing code.
To adhere to the Open-Closed Principle, a more flexible and extensible design is required. One possible approach is to utilize inheritance and polymorphism. Rather than a single `Shape` class handling different shape types, we can create separate classes for each shape, all implementing a common interface or base class.
This design allows for easy extension by adding new shape classes that inherit from the common interface or base class. Each shape class can then implement its own initialization and area calculation logic, encapsulating the unique properties and behavior of that particular shape.
By employing inheritance and polymorphism, we can achieve a more modular, maintainable, and extensible codebase that aligns with the principles of object-oriented design.
We can resolve this concern using the following example -
The above code looks more appropriate and follows the OCP. This class provides the required interface (API) for any shape that we would like to define. That interface consists of a .shape_type attribute and a .calculate_area() method that you must override in all the subclasses.
Liskov Substitution Principle (LSP)
This principle states that - Subtype must be substitutable for base types. In the provided code, the Shape class serves as the base class, while Circle, Rectangle, and Square are its subclasses. By following this structure, any code that works with the Shape class can seamlessly work with its subclasses as well.
For example, if you have a piece of code that operates on a Shape object, such as calling the calculate_area() method, you can substitute that Shape object with an instance of Circle, Rectangle, or Square without any issues. The code will continue to function correctly, as each subclass implements its own version of the calculate_area() method based on its specific shape.
In practice, the principle of substitutability, as applied in object-oriented design, focuses on ensuring that subclasses behave consistently with their base class. This principle aims to avoid breaking expectations of code that calls the same methods on different objects.
By adhering to this principle, when you create subclasses, such as `Circle`, Rectangle, or Square, they should behave in a way that is consistent with the base class Shape. This means that calling the same methods, such as calculate_area(), on different objects of these classes should produce expected results and not cause any unexpected behavior.
By making subclasses adhere to the behavior and interface defined by the base class, you can ensure that existing code, which works with the base class, can seamlessly work with the subclasses without any issues or surprises. This promotes code reliability, maintainability, and helps maintain a consistent and predictable codebase.
Since a square is a special case of a rectangle with equal sides, you can derive a Square class from the Rectangle class to reuse the code. In doing so, you can inherit the properties and methods of the Rectangle class, while also customizing the behavior specific to a square.
To ensure that the sides of the square are always equal, you can override the setter methods for the width and height attributes. This allows you to synchronize the values of both sides so that when one side changes, the other side automatically adjusts to match.
The Square class is defined as a subclass of the Rectangle class, which means it inherits the properties and methods of the Rectangle class.
In the __init__ method, super().__init__(side, side) is called to invoke the initialization of the Rectangle superclass with the side argument passed twice. By doing so, the Square object is initialized with equal values for both width and height, creating a square shape.
The __setattr__ method is overridden to customize the behavior when an attribute is set on a Square object. It allows for synchronization of the width and height attributes, ensuring that they always have equal values.
When an attribute is set using the self.attribute = value syntax, super().__setattr__(key, value) is called to invoke the base class's __setattr__ method. This ensures that the attribute is set in the appropriate manner for the parent class.
Following that, a check is performed to determine if the modified attribute is either "width" or "height". If so, the width and height attributes in the object's __dict__ are explicitly set to the same value (value). This ensures that any modification to one side of the square also updates the other side to maintain equal values.
By overriding the __setattr__ method, the Square class enforces the constraint that the width and height must always be equal, thereby preserving the square shape and its inherent characteristics.
However, this violates the Liskov substitution principle because we can't replace instance of Rectangle with their square counterparts. We can solve this problem by creating a base class for Rectangle and Square to extend.
With the modifications, Shape becomes a type that can be substituted using polymorphism with either Rectangle or Square. They are now sibling classes instead of a parent-child relationship. It's important to note that Rectangle and Square have distinct sets of attributes, different initializer methods, and the potential to implement additional separate behaviors.
Despite these differences, both Rectangle and Square share a common interface through the Shape base class. This common interface ensures that both shapes have the ability to calculate their respective areas.
Interface Segregation Principle (ISP)
The interface Segregation Principle's main idea is that clients should not be forced to depend upon methods that they do not use. Interfaces belong to clients, not to hierarchies.
In this context, clients refer to classes and subclasses that interact with other classes. Interfaces consist of the methods and attributes that these classes rely on to perform their functionality. If a class does not utilize certain methods or attributes, it is a good practice to segregate them into more specific classes.
Let's understand the following example -
The above class structure allows us to create different machines with different sets of functionalities and make the design more flexible and extensible.
Dependency Inversion Principle (DIP)
This principle states that - Abstraction should not depend upon details. Details should depend upon abstraction.
It may sound a bit complex. Following is the example to make it clearer. We have a Frontend class to display data to the user in a friendly way. The app currently fetches its data from a database. The code will be as below -
In the above example, the FrontEnd class has a dependency on the BackEnd class. Both classes are tightly coupled. This couple may be the cause of the scalability issues. If there a modification comes like we want to read the data from the RestAPI instead of the database, how would we do that?
The probable solution would be adding a new method to BackEnd to retrieve the data from the RESTAPI. However, that is required modification to the FrontEnd as well.
We can solve this issue using the dependency inversion principle (DIP) to make classes depend upon abstraction rather than on concrete implementation like BackEnd. Below is the simplified example.
In this version, we've introduced a DataSource interface, which is an abstraction representing the source from which we can fetch data. The Database class implements this DataSource interface.
The Frontend class now depends on the DataSource interface instead of the concrete Database class. This adheres to the Dependency Inversion Principle, as the high-level Frontend class depends on an abstraction (the interface) rather than a specific implementation.
This approach allows you to easily introduce other data sources (e.g., APIs, files) that conform to the DataSource interface without modifying the Frontend class, promoting flexibility and maintainability.
This tutorial include the brief detail about the SOLID principle (Single Responsibility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle) are fundamental guidelines for writing maintainable, flexible, and scalable code. By adhering to these principles, you can create code that is easier to understand, extend, and maintain over time.
Throughout the examples provided earlier, we demonstrated how each principle can be applied in Python to refactor and improve code. We identified design issues, refactored the code to conform to SOLID principles, and showcased the benefits of doing so.