Good Engineering Principles
General Engineering Principles
-
DRY (Don't Repeat Yourself):
-
Avoid duplication of code by abstracting common functionalities into reusable components or functions. This principle emphasizes the importance of modularity and maintainability.
-
-
KISS (Keep It Simple, Stupid):
-
Strive for simplicity in design and implementation. Complex solutions should be avoided in favor of simpler, more straightforward ones whenever possible.
-
-
YAGNI (You Aren't Gonna Need It):
-
Avoid adding unnecessary features or functionality to the software based on speculation about future needs. Only implement features that are necessary based on current requirements.
-
-
Separation of Concerns (SoC):
-
Divide the software into distinct sections, with each section addressing a separate concern or responsibility. This principle promotes modularity, maintainability, and reusability.
-
It promotes loose coupling between components, which enhances maintainability and flexibility.
-
-
Single Source of Truth (SSOT):
-
Store each piece of information in the system in a single, authoritative location. This ensures consistency and reduces the risk of data inconsistencies.
-
-
Fail-Fast Principle:
-
Identify and report errors as soon as they occur, rather than allowing them to propagate and potentially cause more significant issues later on. This principle helps in diagnosing and fixing problems quickly.
-
Law of Demeter / Principle of Least Knowledgh:Knowledge:
A module should have limited knowledge about other modules. This encourages encapsulation and loose coupling by restricting the interaction between objects.
Violates Law of Demeter due to nesting:
data = {
"containers": [
{"scoops": [{"flavor": "chocolate"}, {"flavor": "vanilla"}]},
{"scoops": [{"flavor": "strawberry"}, {"flavor": "mint"}]}
]
}
flavor = data["containers"][0]["scoops"][0]["flavor"]
Fix:
- Create a class that represents the container structure. And,
- Provide methods to access our inner data.
class Scoop:
def __init__(self, flavor:str):
self.flavor = flavor
def get_flavor(self):
return self.flavor
class Container:
def __init__(self):
self.scoops = []
def add_scoop(self,flavor:str):
self.scoops.append(Scoop(flavor))
def get_flavor_of_scoop(self, index:int):
return self.scoops[index].get_flavor()
data = Container()
data.add_scoop("chocolate")
data.add_scoop("vanilla")
flavor = data.get_flavor_of_scoop(0)
Object Oriented Design Principles
Solid Principles
Single Responsibility Principle (SRP):
- A class should have only one reason to change.
- The following handles both read/write of files and encryption, which violates SRP
class FileManager:
def __init__(self, file_path):
self.file_path = file_path
def read_file(self):
pass
def write_file(self, data):
pass
def encrypt_data(self, data):
pass
def decrypt_data(self, data):
pass
- In this refactored version, the `FileManager` class now focuses solely on file management operations.
class FileManager
def __init__(self, file_path):
self.file_path = file_path
def read_file(self):
pass
def write_file(self, data):
pass
class DataEncryptor:
def encrypt_data(self, data):
pass
def decrypt_data(self, data):
pass
Open/Closed Principle (OCP):
The function animal_sound does not conform to the open-closed principle because it cannot be closed against new kinds of animals. If we add a new animal, Snake, We have to modify the animal_sound function. You see, for every new animal, a new logic is added to the animal_sound function. This is quite a simple example. When your application grows and becomes complex, you will see that the if statement would be repeated over and over again in the animal_sound function each time a new animal is added, all over the application. Goal is to decrease amount of if else statements
class Animal:
def __init__(self, name: str):
self.name = name
def get_name(self) -> str:
pass
animals = [
Animal('lion'),
Animal('mouse')
]
def animal_sound(animals: list):
for animal in animals:
if animal.name == 'lion':
print('roar')
elif animal.name == 'mouse':
print('squeak')
animal_sound(animals):
The Animal class has been enhanced with the addition of the make_sound method. Each animal class extends the Animal class and provides its own implementation of the make_sound method, defining how it produces its unique sound.
In the animal_sound function, we iterate through the array of animals and simply invoke their respective make_sound methods. By following this design, the animal_sound function remains unchanged even when new animals are introduced. We only need to include the new animal in the animal array. This adherence to the Open-Closed Principle ensures that the code is extensible without requiring modifications to existing code.
class Animal:
def __init__(self, name: str):
self.name = name
def get_name(self) -> str:
pass
def make_sound(self):
pass
class Lion(Animal):
def make_sound(self):
return 'roar'
class Mouse(Animal):
def make_sound(self):
return 'squeak'
class Snake(Animal):
def make_sound(self):
return 'hiss'
def animal_sound(animals: list):
for animal in animals:
print(animal.make_sound())
animal_sound(animals):
Liskov Substitution Principle:
This implementation violates the Liskov Substitution Principle because you can't seamlessly replace instances of Rectangle with their Square counterparts.
Imagine someone expects a rectangle object in their code. Naturally, they would assume that it exhibits the behavior of a rectangle, including separate width and height attributes. Unfortunately, the Square class in your codebase violates this assumption by altering the expected behavior defined by the object's interface.
To address this issue and apply the Liskov Substitution Principle, let's introduce a base Shape class and make both Rectangle and Square inherit from it:
class Rectangle
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height:
class Square(Rectangle)
def __init__(self, side):
super().__init__(side, side)
def __setattr__(self, key, value):
super().__setattr__(key, value)
if key in ("width", "height"):
self.__dict__["width"] = value
self.__dict__["height"] = value:
By adhering to the Liskov Substitution Principle and introducing a common base class (Shape), you ensure that objects of different subclasses can be seamlessly interchanged wherever the superclass is expected. Both Rectangle and Square are now siblings, each with their own set of attributes, initializer methods, and potentially more separate behaviors. The only shared aspect between them is the ability to calculate their respective areas
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def calculate_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side ** 2d
Interface Segregation Principle (ICP)
The Interface Segregation Principle revolves around the idea that clients should not be forced to rely on methods they do not use. To achieve this, the principle suggests creating specific interfaces or classes tailored to the needs of individual clients.
In this example, the base class Printer defines an interface that its subclasses are required to implement. However, the OldPrinter subclass doesn't utilize the fax() and scan() methods because it lacks support for these functionalities.
Unfortunately, this design violates the ISP as it forces OldPrinter to expose an interface that it neither implements nor requires.
from abc import ABC, abstractmetho
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
@abstractmethod
def fax(self, document):
pass
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print(f"Printing {document} in black and white...")
def fax(self, document):
raise NotImplementedError("Fax functionality not supported")
def scan(self, document):
raise NotImplementedError("Scan functionality not supported")
class ModernPrinter(Printer):
def print(self, document):
print(f"Printing {document} in color...")
def fax(self, document):
print(f"Faxing {document}...")
def scan(self, document):
print(f"Scanning {document}...")d
In this revised design, the base classes—Printer, Fax, and Scanner—provide distinct interfaces, each responsible for a single functionality. The OldPrinter class only inherits the Printer interface, ensuring that it doesn't have any unused methods. On the other hand, the NewPrinter class inherits from all the interfaces, incorporating the complete set of functionalities. This segregation of the Printer interface enables the creation of various machines with different combinations of functionalities, enhancing flexibility and extensibility.
Dependency Inversion Principle (DIP)
from abc import ABC, abstractmetho
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
class Fax(ABC):
@abstractmethod
def fax(self, document):
pass
class Scanner(ABC):
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print(f"Printing {document} in black and white...")
class NewPrinter(Printer, Fax, Scanner):
def print(self, document):
print(f"Printing {document} in color...")
def fax(self, document):
print(f"Faxing {document}...")
def scan(self, document):
print(f"Scanning {document}...")d
The Dependency Inversion Principle focuses on managing dependencies between classes. It states that dependencies should be based on abstractions rather than concrete implementations. In other words, high-level modules should not depend on low-level modules, but both should depend on abstractions. Additionally, abstractions should not rely on implementation details; instead, details should depend on abstractions.
class PaymentProcessor
def process_payment(self, payment):
if payment.method == 'credit_card':
self.charge_credit_card(payment)
elif payment.method == 'paypal':
self.process_paypal_payment(payment)
def charge_credit_card(self, payment):
# Charge credit card
def process_paypal_payment(self, payment):
# Process PayPal payment
In this updated code, we introduced the PaymentMethod abstract base class, which declares the process_payment() method. The PaymentProcessor class now depends on the PaymentMethod abstraction through its constructor, rather than directly handling the payment logic. The specific payment methods, such as CreditCardPayment and PayPalPayment, implement the PaymentMethod interface and provide their own implementation for the process_payment() method.
By following this structure, the PaymentProcessor class is decoupled from the specific payment methods, and it depends on the PaymentMethod abstraction. This design allows for greater flexibility and easier extensibility, as new payment methods can be added by creating new classes that implement the PaymentMethod interface without modifying the PaymentProcessor class.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def process_payment(self, payment):
pass
class PaymentProcessor:
def __init__(self, payment_method):
self.payment_method = payment_method
def process_payment(self, payment):
self.payment_method.process_payment(payment)
class CreditCardPayment(PaymentMethod):
def process_payment(self, payment):
# Code to charge credit card
class PayPalPayment(PaymentMethod):
def process_payment(self, payment):
# Code to process PayPal payment
Composition Over Inheritance:
Prefer composition (building objects by assembling smaller, reusable components) over inheritance (creating new classes by extending existing ones). This approach leads to more flexible and maintainable code.
- Inheritance reduces encapsulation: we want our classes and modules to be loosely coupled to the rest of the codebase.
- A child class, instead, is strongly coupled to its parent. When a parent changes, the change will ripple through all of its children and might break the codebase.
- Testability: Reduced encapsulation results in classes being harder to test.
Composition involves using other classes to build more complex classes, there is no parent/child relationship exists in this case. Objects are composed of other objects, through a has-a relationship, not a belongs-to relationship. This means that we can combine other objects to reach the behavior we would like, thus avoid the subclasses explosion problem. In Python, we can leverage a couple of mechanisms to achieve composition.
Testing Principles
-
Testing Principles:
- Test-Driven Development (TDD): Write tests before writing code to ensure that the code meets the requirements.
- Unit Testing: Test individual units (e.g., functions, methods, or classes) of code in isolation to ensure they behave as expected.
- Integration Testing: Test the interactions between different units or modules to ensure they work together correctly.
- Continuous Integration (CI) and Continuous Deployment (CD): Automate the process of building, testing, and deploying software to ensure that changes are integrated and deployed smoothly.