- SOLID, DRY and Clean Code with FIRST tests
- Contents
- About SOLID and DRY
- SOLID and DRY Principles in Python
- 1. Single Responsibility Principle (SRP): Each class should have only one responsibility.
- 2. Open/Closed Principle (OCP): Classes should be open for extension but closed for modification.
- 3. Liskov Substitution Principle (LSP): Subclasses should be replaceable with their base classes.
- 4. Interface Segregation Principle (ISP): Don’t force classes to implement interfaces they don’t use.
- 5. Dependency Inversion Principle (DIP): High-level modules should depend on abstractions, not concrete implementations.
- 6. Don't Repeat Yourself (DRY): Avoid repeating code; use shared logic instead.
- How to Enforce SOLID Principles in Development
- About Clean Code
- Clean Code Principles
- Clean Code Examples in Python
- Clean Code in comments and docstrings
- What is FIRST?
- Python Code Examples for FIRST Principles
- TDD is included
SOLID is a collection of five principles introduced by Robert C. Martin (Uncle Bob) to enhance the structure and maintainability of object-oriented code.
- Single Responsibility Principle (SRP): A class should have only one responsibility.
- Open/Closed Principle (OCP): Entities should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Subtypes should be replaceable by their base types.
- Interface Segregation Principle (ISP): Don’t force clients to depend on interfaces they don’t use.
- Dependency Inversion Principle (DIP): High-level modules should depend on abstractions, not concrete implementations.
Together, these principles promote flexible, scalable, and easily maintainable code.
DRY (Don't Repeat Yourself) is a principle aimed at reducing code duplication by ensuring that each piece of knowledge or logic is expressed only once. Repetition leads to redundancy, increasing the risk of inconsistencies and errors during updates or maintenance. By following DRY, developers create more efficient, maintainable, and modular code, ensuring that changes need to be made in only one place when requirements evolve.
class ReportGenerator:
def generate(self, data):
return f"Report: {data}"
class ReportSaver:
def save(self, report):
with open('report.txt', 'w') as f:
f.write(report)
class Shape:
def area(self):
pass
class Circle(Shape):
def area(self):
return "Circle area"
class Square(Shape):
def area(self):
return "Square area"
class Bird:
def fly(self):
pass
class Sparrow(Bird):
def fly(self):
return "Flying"
class Ostrich(Bird): # Violation of LSP
def fly(self):
raise Exception("Can't fly")
4. Interface Segregation Principle (ISP): Don’t force classes to implement interfaces they don’t use.
class Printer:
def print(self):
pass
class Scanner:
def scan(self):
pass
class MultiFunctionPrinter(Printer, Scanner):
def print(self):
return "Printing"
def scan(self):
return "Scanning"
5. Dependency Inversion Principle (DIP): High-level modules should depend on abstractions, not concrete implementations.
class Database:
def connect(self):
pass
class MySQLDatabase(Database):
def connect(self):
return "Connected to MySQL"
class Application:
def __init__(self, db: Database):
self.db = db
def run(self):
return self.db.connect()
def calculate_area(width, height):
return width * height
print(calculate_area(5, 10))
print(calculate_area(3, 6))
To enforce the SOLID principles during development, you can use the following methods and tools:
-
Code Reviews: Regular code reviews help identify and correct sections where SOLID principles are violated. Experienced developers can point out problematic design or code structure.
-
Automated Static Code Analysis Tools:
- Pylint, Flake8, SonarQube: These tools analyze the code and warn of violations or bad practices, such as a class having too many responsibilities (SRP) or incorrect handling of inheritance (LSP).
-
Unit Testing: Writing good unit tests forces developers to create modular, testable code. Difficulty in testing a piece of code often indicates a violation of one of the SOLID principles.
-
Refactoring Tools: IDEs and coding tools like PyCharm or VSCode support refactoring, which helps to enforce SOLID principles without breaking too much of the codebase.
-
Development Guidelines and Best Practices: Establishing shared guidelines and practices that specifically encourage adherence to SOLID principles helps maintain clean code and proper architecture.
-
Design Patterns: Using proven design patterns (e.g., Factory, Strategy, Dependency Injection) promotes the observance of SOLID principles, especially Open/Closed and Dependency Inversion.
-
CI/CD with Tests: Tools like Jenkins or GitLab CI can run automated code checks and unit tests during commits to ensure the code aligns with the SOLID principles.
These methods and tools help developers create structured, maintainable code that adheres to the SOLID principles.
Clean Code refers to code that is easy to understand, maintain, and extend. The principles outlined by Robert C. Martin (also) emphasize the readability and simplicity of the code. Clean code should follow certain principles that enhance the quality of the development process, reduce errors, and facilitate future modifications.
- Simplicity: The code should reflect the simplest solution to the problem without unnecessary complexity.
- Meaningful Names: Variables, functions, and class names should be self-explanatory, making it clear what the code is doing.
- Small Functions: Functions should be short and focused on doing only one thing.
- Don’t Repeat Yourself (DRY): Avoid repeating the same code; reuse logic instead.
- Minimizing Side Effects: Functions should only modify variables they are responsible for and should avoid hidden side effects.
- Readable Code over Comments: Well-written code should document itself, requiring fewer comments.
Avoid complex code and opt for simple solutions.
Bad Example:
def calculate_total_price(price, tax_rate):
if tax_rate > 0:
total = price + (price * tax_rate)
else:
total = price
return total
Good Example (Simplified):
def calculate_total_price(price, tax_rate):
return price + (price * tax_rate)
Use names that clearly describe the purpose of the variable or function.
Bad Example:
def f(x):
return x * x
Good Example:
def calculate_square(number):
return number * number
Each function should focus on a single responsibility.
Bad Example:
def process_data(data):
clean_data = [d.strip() for d in data]
result = [int(d) for d in clean_data if d.isdigit()]
return sum(result)
Good Example:
def clean_data(data):
return [d.strip() for d in data]
def filter_numeric_data(clean_data):
return [int(d) for d in clean_data if d.isdigit()]
def process_data(data):
return sum(filter_numeric_data(clean_data(data)))
Avoid duplicating code, instead use shared logic.
Bad Example:
def get_area_of_square(side):
return side * side
def get_area_of_rectangle(width, height):
return width * height
Good Example:
def calculate_area(width, height=None):
if height is None:
height = width
return width * height
Avoid modifying external variables or state.
Bad Example:
counter = 0
def increment_counter():
global counter
counter += 1
Good Example:
def increment(counter):
return counter + 1
The code should be self-explanatory, reducing the need for comments.
Bad Example:
def add(a, b):
# adds a and b
return a + b
Good Example:
def add_numbers(first_number, second_number):
return first_number + second_number
- Easier to maintain and test.
- More understandable and accessible to other developers in the team.
- Reduces the likelihood of errors and simplifies debugging.
Applying Clean Code principles requires constant attention but makes the development process much smoother in the long run.
According to Clean Code, comments and docstrings should be used sparingly and only when truly necessary. The philosophy behind Clean Code emphasizes that well-written code should be self-explanatory, meaning the structure, logic, and naming conventions should make the code easily understandable without the need for excessive comments. Here's the good approach regarding comments and docstrings according to Clean Code principles:
-
Comments Are Often a Failure: Robert C. Martin suggests that comments often indicate a failure to make the code itself clear. If you need a comment to explain what the code does, the code might need refactoring to be more understandable.
-
Good Code Should Be Self-Explanatory: By using clear, meaningful names for variables, functions, and classes, as well as breaking down complex logic into smaller, focused functions, the need for comments is reduced. The goal is that another developer can read the code and understand what it does without relying on comments.
-
Comments Should Not Explain "What" the Code Does: If a comment explains what the code does, it usually means the code could be improved. Instead, comments should focus on explaining why certain decisions were made or on clarifying complex business logic that can't be simplified in the code itself.
-
Avoid Redundant or Obsolete Comments: Comments that repeat what the code does or become outdated as the code evolves are counterproductive. They can mislead developers or create confusion. Keeping comments in sync with code changes can be difficult, so it's better to write clean, clear code instead.
-
Use Comments for Clarifications or Warnings: Comments are useful for explaining non-obvious decisions, highlighting potential pitfalls, or providing important contextual information about external constraints (e.g., reasons for an optimization). They are also helpful for warning other developers about potential risks.
-
Docstrings Should Be Brief and Precise: While comments should be minimal, docstrings (especially in Python) are important for documenting public APIs—functions, classes, and modules. A good docstring briefly describes the purpose of the function or class, its arguments, return values, and exceptions (if applicable).
-
Use Docstrings to Explain Purpose and Usage: Unlike comments, which may describe more complex internal logic, docstrings are intended to explain how and why to use the function or class. They serve as part of the API documentation and should provide enough context for users of the code.
-
Docstrings Should Focus on the "What" and "How": Docstrings should not explain what the code internally does but should explain the purpose and how to use the function, class, or module.
def calculate_discount(price, discount_rate):
# Cap the discount to a maximum of 50%
if discount_rate > 0.5:
discount_rate = 0.5
return price - (price * discount_rate)
Here, the comment explains why the discount is capped, which is not immediately obvious from the code itself.
def calculate_area(width, height):
"""
Calculate the area of a rectangle.
Args:
width (float): The width of the rectangle.
height (float): The height of the rectangle.
Returns:
float: The area of the rectangle.
"""
return width * height
The docstring clearly explains the purpose of the function, its arguments, and the return value without describing the internal implementation.
Bad Example:
def add_numbers(a, b):
# This function adds two numbers
return a + b
This comment is redundant because the function name and code are already self-explanatory.
- Use Meaningful Names: Choose names for variables, functions, and classes that describe their purpose and eliminate the need for comments.
- Keep Functions Small and Focused: Small, focused functions often make comments unnecessary because each function has a clear purpose.
- Write Docstrings for Public APIs: Docstrings are especially important for functions and classes that others will use, providing usage guidance and context.
- Explain Intent in Comments: When comments are needed, focus on explaining the intent behind the code, not the mechanics of how it works.
- Avoid Redundancy: Don't repeat the code in comments. Instead, use comments to provide context or clarification for decisions that are not obvious.
In short, Clean Code promotes the use of clear, self-documenting code that minimizes the need for comments and leverages concise, informative docstrings where appropriate, especially in public-facing APIs.
FIRST is an acronym that outlines key principles for writing effective unit tests. These principles ensure that unit tests are reliable, easy to maintain, and valuable during the development process. By following the FIRST principles, developers can ensure their tests are efficient and provide fast feedback.
FIRST stands for:
- Fast: Unit tests should execute quickly, providing immediate feedback to developers. Slow tests can disrupt the development process and discourage frequent testing.
- Independent: Tests should not rely on the outcome of other tests. Each test should run independently, ensuring that one failure doesn't cascade into multiple failures.
- Repeatable: Unit tests should consistently yield the same result, no matter how often they are executed or the environment in which they run.
- Self-validating: A unit test should automatically determine whether it passes or fails without human interpretation. It should return a clear true/false result.
- Timely: Unit tests should be written as soon as possible, preferably before the production code (as per Test-Driven Development).
Write lightweight tests that focus on specific functions without external dependencies (e.g., databases or networks).
def add_numbers(a, b):
return a + b
def test_add_numbers():
assert add_numbers(2, 3) == 5 # This test runs instantly
Ensure each test operates independently of others, avoiding shared state.
def test_first_operation():
result = add_numbers(2, 3)
assert result == 5
def test_second_operation():
result = add_numbers(4, 6)
assert result == 10
Each test operates on its own data, ensuring independence.
Tests should produce the same result every time, regardless of external factors.
def test_repeatable():
assert add_numbers(10, 5) == 15 # This will always return the same result
The test should automatically check correctness and return clear pass/fail results.
def test_self_validating():
assert add_numbers(1, 1) == 2 # No manual checking, the assert evaluates correctness
Write tests before or during the development of the function, ensuring they accompany the code.
# TDD Example: Write the test before implementing the function
def test_timely():
assert add_numbers(3, 7) == 10 # Fail first, then implement the function
By adhering to the FIRST principles, unit tests remain fast, independent, reliable, and serve as a strong foundation for building high-quality software.
The "Timely" principle in FIRST is essentially one of the key principles of TDD (Test-Driven Development). It means that tests are written on time, i.e., before or during the development of the code. This perfectly aligns with the TDD methodology, which dictates that development starts with writing tests and then building the code based on the tests.
The three steps of TDD are:
- Write a test that initially fails.
- Write the minimum code necessary to pass the test.
- Refactor the code while keeping the test passing.
The Timely principle emphasizes that testing is not an afterthought but an early and integral part of the development process, exactly as TDD prescribes. Therefore, we can indeed say that the "Timely" principle is equivalent to the TDD approach.