Introduction
Object-Oriented Programming (OOP) is a programming paradigm based on the concept of "objects", which are instances of classes. OOP principles are essential for creating robust, reusable, and maintainable software.
Core Principles
1. Encapsulation
Encapsulation is the principle of bundling the data (variables) and the methods (functions) that operate on the data into a single unit, known as a class. It helps to protect the data from outside interference and misuse.
Example of Encapsulation
class Employee:
def __init__(self, name, salary):
self.__name = name
self.__salary = salary
def get_name(self):
return self.__name
def set_salary(self, salary):
self.__salary = salary
def get_salary(self):
return self.__salary
In this example, the Employee
class encapsulates the properties name
and salary
, and provides methods to access and modify these properties.
2. Inheritance
Inheritance is a mechanism where a new class inherits properties and behaviors (methods) from an existing class. This promotes code reuse and establishes a natural hierarchy between classes.
Example of Inheritance
class Person:
def __init__(self, name):
self.name = name
def greet(self):
print(f"Hello, my name is {self.name}")
class Employee(Person):
def __init__(self, name, salary):
super().__init__(name)
self.salary = salary
def show_salary(self):
print(f"My salary is {self.salary}")
In this example, the Employee
class inherits from the Person
class and adds additional properties and methods.
3. Polymorphism
Polymorphism allows objects of different classes to be treated as objects of a common superclass. It is often implemented through method overriding, where a subclass provides a specific implementation of a method already defined in its superclass.
Example of Polymorphism
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Bark"
class Cat(Animal):
def speak(self):
return "Meow"
animals = [Dog(), Cat()]
for animal in animals:
print(animal.speak())
In this example, the Dog
and Cat
classes override the speak
method of the Animal
class. The speak
method is called on each object in the animals
list, demonstrating polymorphism.
4. Abstraction
Abstraction involves hiding the complex implementation details and showing only the necessary features of an object. It helps in reducing programming complexity and effort.
Example of Abstraction
from abc import ABC, abstractmethod
class Vehicle(ABC):
@abstractmethod
def start_engine(self):
pass
class Car(Vehicle):
def start_engine(self):
print("Car engine started")
class Motorcycle(Vehicle):
def start_engine(self):
print("Motorcycle engine started")
vehicles = [Car(), Motorcycle()]
for vehicle in vehicles:
vehicle.start_engine()
In this example, the Vehicle
class is an abstract class with an abstract method start_engine
. The Car
and Motorcycle
classes provide concrete implementations of this method.
Introduction
SOLID is a set of design principles that help developers create more understandable, flexible, and maintainable software. The principles are:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
1. Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change. This means that a class should only have one job or responsibility.
Example of SRP
class Invoice:
def __init__(self, items):
self.items = items
def calculate_total(self):
total = sum(item.price for item in self.items)
return total
def print_invoice(self):
for item in self.items:
print(f"{item.name}: {item.price}")
print(f"Total: {self.calculate_total()}")
# Refactored version adhering to SRP
class Invoice:
def __init__(self, items):
self.items = items
def calculate_total(self):
return sum(item.price for item in self.items)
class InvoicePrinter:
def __init__(self, invoice):
self.invoice = invoice
def print_invoice(self):
for item in self.invoice.items:
print(f"{item.name}: {item.price}")
print(f"Total: {self.invoice.calculate_total()}")
In this example, the Invoice
class is responsible for calculating the total, while the InvoicePrinter
class is responsible for printing the invoice.
2. Open/Closed Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means you should be able to add new functionality without changing existing code.
Example of OCP
class Discount:
def apply(self, price):
return price
class PercentageDiscount(Discount):
def __init__(self, percent):
self.percent = percent
def apply(self, price):
return price * (1 - self.percent / 100)
class FixedAmountDiscount(Discount):
def __init__(self, amount):
self.amount = amount
def apply(self, price):
return price - self.amount
In this example, we can add new types of discounts by creating new classes that inherit from Discount
without modifying the existing discount classes.
3. Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This means that subclasses should extend the behavior of the superclass without changing its behavior.
Example of LSP
class Bird:
def fly(self):
pass
class Sparrow(Bird):
def fly(self):
print("Sparrow flying")
class Ostrich(Bird):
def fly(self):
raise Exception("Ostrich can't fly")
# Refactored version adhering to LSP
class Bird:
def move(self):
pass
class Sparrow(Bird):
def move(self):
print("Sparrow flying")
class Ostrich(Bird):
def move(self):
print("Ostrich running")
In this example, the Ostrich
class violates LSP because it cannot fly. The refactored version replaces fly
with move
, which allows different birds to implement appropriate movement behaviors.
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use. This means that a class should not be required to implement interfaces it does not use.
Example of ISP
class Worker:
def work(self):
pass
def eat(self):
pass
class Robot(Worker):
def work(self):
print("Robot working")
def eat(self):
raise Exception("Robot doesn't eat")
# Refactored version adhering to ISP
class Workable:
def work(self):
pass
class Eatable:
def eat(self):
pass
class Human(Workable, Eatable):
def work(self):
print("Human working")
def eat(self):
print("Human eating")
class Robot(Workable):
def work(self):
print("Robot working")
In this example, the Robot
class violates ISP because it is forced to implement the eat
method. The refactored version splits the interfaces into Workable
and Eatable
, so that Robot
only implements Workable
.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Example of DIP
class LightBulb:
def turn_on(self):
print("LightBulb: turned on")
def turn_off(self):
print("LightBulb: turned off")
class Switch:
def __init__(self, bulb):
self.bulb = bulb
def operate(self, action):
if action == "on":
self.bulb.turn_on()
elif action == "off":
self.bulb.turn_off()
# Refactored version adhering to DIP
class Switchable:
def turn_on(self):
pass
def turn_off(self):
pass
class LightBulb(Switchable):
def turn_on(self):
print("LightBulb: turned on")
def turn_off(self):
print("LightBulb: turned off")
class Fan(Switchable):
def turn_on(self):
print("Fan: turned on")
def turn_off(self):
print("Fan: turned off")
class Switch:
def __init__(self, device):
self.device = device
def operate(self, action):
if action == "on":
self.device.turn_on()
elif action == "off":
self.device.turn_off()
In this example, the Switch
class depends on the LightBulb
class, which violates DIP. The refactored version introduces the Switchable
interface, which both LightBulb
and Fan
implement. The Switch
class now depends on the abstraction Switchable
.
Practical Exercises
- Refactor a given class to adhere to the Single Responsibility Principle.
- Create a new feature by extending a class following the Open/Closed Principle.
- Identify and refactor a class hierarchy that violates the Liskov Substitution Principle.
- Split a large interface into smaller interfaces to adhere to the Interface Segregation Principle.
- Refactor a dependency in your code to follow the Dependency Inversion Principle.
Introduction
Writing clean code is essential for creating maintainable, readable, and efficient software. Clean code practices ensure that the codebase is understandable, reduces technical debt, and facilitates easier debugging and enhancement.
1. Meaningful Names
Choosing meaningful names for variables, functions, classes, and other identifiers makes the code more readable and self-explanatory.
Example of Meaningful Names
# Bad naming
def calc(x):
return x * 2
# Good naming
def calculate_area(radius):
return radius * 2 * 3.14159
In this example, the function name calculate_area
clearly indicates its purpose, unlike the ambiguous name calc
.
2. Functions Should Do One Thing
Functions should be small and focused on a single task. This makes them easier to understand, test, and maintain.
Example of Single Responsibility in Functions
# Bad example
def process_order(order):
validate_order(order)
save_order(order)
notify_user(order)
# Good example
def validate_order(order):
# validation logic
def save_order(order):
# save logic
def notify_user(order):
# notification logic
In this example, the single function process_order
is split into three smaller functions, each handling a specific task.
3. Use Comments Wisely
Comments should be used to explain why something is done, not what is done. Clean code should be self-explanatory as much as possible.
Example of Using Comments Wisely
# Bad example
i = 0 # Initialize i to 0
# Good example
# Resetting the user login attempt counter
login_attempts = 0
In this example, the good comment explains the reason behind the action, while the bad comment is redundant and explains what is already clear from the code.
4. Consistent Coding Style
Adhering to a consistent coding style, such as following naming conventions, indentation, and formatting, makes the code more predictable and readable.
Example of Consistent Coding Style
# Bad example
def myFunction ( ):
print("Hello World")
# Good example
def my_function():
print("Hello, World!")
In this example, the good code follows a consistent naming convention, proper indentation, and spacing.
5. Avoid Magic Numbers
Magic numbers are unnamed numerical constants that appear in the code without explanation. Use named constants instead.
Example of Avoiding Magic Numbers
# Bad example
if score > 9000:
print("It's over 9000!")
# Good example
MAX_POWER_LEVEL = 9000
if score > MAX_POWER_LEVEL:
print("It's over 9000!")
In this example, the use of the named constant MAX_POWER_LEVEL
makes the code more readable and easier to maintain.
6. Handle Errors Gracefully
Proper error handling ensures that the software can handle unexpected situations without crashing or producing incorrect results.
Example of Graceful Error Handling
# Bad example
def read_file(file_path):
f = open(file_path)
data = f.read()
return data
# Good example
def read_file(file_path):
try:
with open(file_path, 'r') as f:
return f.read()
except FileNotFoundError:
return "File not found"
except IOError:
return "Error reading file"
In this example, the good code includes error handling to manage file reading errors gracefully.
7. Avoid Duplication
Duplicated code can lead to inconsistencies and make maintenance harder. DRY (Don't Repeat Yourself) principle should be followed.
Example of Avoiding Duplication
# Bad example
def calculate_discount(price):
return price * 0.1
def calculate_tax(price):
return price * 0.2
# Good example
def calculate_percentage(price, percentage):
return price * percentage
def calculate_discount(price):
return calculate_percentage(price, 0.1)
def calculate_tax(price):
return calculate_percentage(price, 0.2)
In this example, the good code refactors the common logic into a separate function to avoid duplication.
Practical Exercises
- Refactor a piece of code by renaming variables, functions, and classes to more meaningful names.
- Break down a large function into smaller, single-responsibility functions.
- Review a codebase and add comments to explain why certain decisions were made, while removing redundant comments.
- Adopt a consistent coding style in a given code snippet and format it accordingly.
- Identify magic numbers in a codebase and replace them with named constants.
- Implement proper error handling in a function that currently lacks it.
- Refactor duplicated code into a single reusable function or module.
Introduction
Refactoring is the process of restructuring existing computer code without changing its external behavior. Its purpose is to improve the nonfunctional attributes of the software, such as readability, maintainability, and scalability.
1. Extract Method
Extract Method involves moving a fragment of code from an existing method into a new method with a name that explains the purpose of the method.
Example of Extract Method
# Before refactoring
def print_owing(invoice):
outstanding = 0.0
print("*************************")
print("****** Customer Owes *****")
print("*************************")
for item in invoice:
outstanding += item.amount
print(f"Amount owed is {outstanding}")
# After refactoring
def print_banner():
print("*************************")
print("****** Customer Owes *****")
print("*************************")
def calculate_outstanding(invoice):
outstanding = 0.0
for item in invoice:
outstanding += item.amount
return outstanding
def print_owing(invoice):
print_banner()
outstanding = calculate_outstanding(invoice)
print(f"Amount owed is {outstanding}")
In this example, the banner printing code and the calculation of the outstanding amount are extracted into separate methods, improving readability and maintainability.
2. Inline Method
Inline Method involves replacing a method call with the method’s content. This technique is useful when a method body is as clear as the method name.
Example of Inline Method
# Before refactoring
def get_rating(driver):
return more_than_five_late_deliveries(driver)
def more_than_five_late_deliveries(driver):
return driver.number_of_late_deliveries > 5
# After refactoring
def get_rating(driver):
return driver.number_of_late_deliveries > 5
In this example, the content of the more_than_five_late_deliveries
method is inlined into the get_rating
method.
3. Extract Class
Extract Class involves moving part of the code from an existing class into a new class to make the code easier to understand and manage.
Example of Extract Class
# Before refactoring
class Person:
def __init__(self, name, office_area_code, office_number):
self.name = name
self.office_area_code = office_area_code
self.office_number = office_number
def get_telephone_number(self):
return f"({self.office_area_code}) {self.office_number}"
# After refactoring
class TelephoneNumber:
def __init__(self, area_code, number):
self.area_code = area_code
self.number = number
def get_telephone_number(self):
return f"({self.area_code}) {self.number}"
class Person:
def __init__(self, name, office_telephone):
self.name = name
self.office_telephone = office_telephone
def get_telephone_number(self):
return self.office_telephone.get_telephone_number()
In this example, the telephone number-related code is extracted into a new TelephoneNumber
class, simplifying the Person
class.
4. Move Method
Move Method involves moving a method from one class to another. This is useful when a method uses more features of another class than the class it is currently in.
Example of Move Method
# Before refactoring
class Account:
def __init__(self, bank, amount):
self.bank = bank
self.amount = amount
def calculate_interest(self):
return self.bank.interest_rate * self.amount
class Bank:
def __init__(self, interest_rate):
self.interest_rate = interest_rate
# After refactoring
class Account:
def __init__(self, bank, amount):
self.bank = bank
self.amount = amount
class Bank:
def __init__(self, interest_rate):
self.interest_rate = interest_rate
def calculate_interest(self, amount):
return self.interest_rate * amount
In this example, the calculate_interest
method is moved from the Account
class to the Bank
class where it belongs.
5. Replace Temp with Query
Replace Temp with Query involves replacing temporary variables with method calls to improve readability and reduce duplication.
Example of Replace Temp with Query
# Before refactoring
def get_price(order):
base_price = order.quantity * order.item_price
discount_factor = 0.98 if base_price > 1000 else 0.99
return base_price * discount_factor
# After refactoring
def get_base_price(order):
return order.quantity * order.item_price
def get_price(order):
return get_base_price(order) * get_discount_factor(order)
def get_discount_factor(order):
return 0.98 if get_base_price(order) > 1000 else 0.99
In this example, the temporary variables base_price
and discount_factor
are replaced with queries, improving readability.
Practical Exercises
- Identify a long method in a given codebase and refactor it using the Extract Method technique.
- Find a method that can be inlined and refactor it using the Inline Method technique.
- Refactor a class that handles multiple responsibilities by extracting some of its code into a new class using the Extract Class technique.
- Identify a method that uses more features of another class and refactor it using the Move Method technique.
- Refactor a function with temporary variables by replacing them with method calls using the Replace Temp with Query technique.
Introduction
Design by Contract (DbC) is a programming methodology where software designers define precise and verifiable interface specifications for software components. These specifications include assertions such as preconditions, postconditions, and invariants, which must hold true during the execution of the software.
1. Preconditions
Preconditions are conditions that must be true before a method or function is executed. They specify the requirements that the caller must meet before invoking the method.
Example of Preconditions
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
assert amount > 0, "Withdraw amount must be positive"
assert self.balance >= amount, "Insufficient funds"
self.balance -= amount
return self.balance
In this example, the preconditions ensure that the withdrawal amount is positive and that the account has sufficient funds before proceeding with the withdrawal.
2. Postconditions
Postconditions are conditions that must be true after a method or function has executed. They specify the guarantees that the method provides to the caller.
Example of Postconditions
class BankAccount:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
assert amount > 0, "Deposit amount must be positive"
initial_balance = self.balance
self.balance += amount
assert self.balance == initial_balance + amount, "Postcondition failed: incorrect balance after deposit"
return self.balance
In this example, the postconditions ensure that the deposit amount is positive and that the account balance is correctly updated after the deposit.
3. Invariants
Invariants are conditions that must always be true for a class instance, both before and after any method call. They ensure the object's consistency throughout its lifecycle.
Example of Invariants
class BankAccount:
def __init__(self, balance):
self.balance = balance
self._check_invariants()
def _check_invariants(self):
assert self.balance >= 0, "Invariant failed: balance must be non-negative"
def withdraw(self, amount):
assert amount > 0, "Withdraw amount must be positive"
assert self.balance >= amount, "Insufficient funds"
self.balance -= amount
self._check_invariants()
return self.balance
def deposit(self, amount):
assert amount > 0, "Deposit amount must be positive"
self.balance += amount
self._check_invariants()
return self.balance
In this example, the invariant ensures that the account balance is always non-negative. The _check_invariants
method is called after each operation that modifies the balance to ensure this condition holds.
4. Benefits of Design by Contract
- Improved code reliability and robustness.
- Clearer documentation of code behavior.
- Early detection of bugs and incorrect usage.
- Facilitates debugging and testing.
Practical Exercises
- Add preconditions to a method in a given codebase to ensure the input parameters meet certain criteria.
- Add postconditions to a method to verify the method's output and state changes.
- Identify and enforce invariants in a class to maintain its consistent state throughout its lifecycle.
- Refactor an existing codebase to apply Design by Contract principles and verify their correctness.
Introduction
This lab exercise is designed to help you apply the concepts covered in Capsule 1, including Object-Oriented Design, SOLID principles, Clean Code practices, and Design by Contract. You will be refactoring a small application and ensuring that it adheres to these principles.
Exercise Overview
You are given a simple banking application that currently has several design flaws. Your task is to refactor the codebase to make it more maintainable, readable, and robust by applying the principles you have learned.
Initial Code (Before Refactoring)
// BankAccount class with multiple responsibilities and design issues
class BankAccount {
constructor(owner, balance) {
this.owner = owner;
this.balance = balance;
}
// Method to withdraw money
withdraw(amount) {
if (amount <= 0) {
console.log("Invalid amount");
return;
}
if (amount > this.balance) {
console.log("Insufficient funds");
return;
}
this.balance -= amount;
console.log(`Withdrawn: ${amount}, New Balance: ${this.balance}`);
}
// Method to deposit money
deposit(amount) {
if (amount <= 0) {
console.log("Invalid amount");
return;
}
this.balance += amount;
console.log(`Deposited: ${amount}, New Balance: ${this.balance}`);
}
// Method to print account balance
print_balance() {
console.log(`Account Balance: ${this.balance}`);
}
}
// Sample usage
const account = new BankAccount("John Doe", 1000);
account.deposit(500);
account.withdraw(200);
account.print_balance();
account.withdraw(1500);
Step 1: Apply Object-Oriented Design Principles
Refactor the BankAccount
class to adhere to Object-Oriented Design principles:
- Encapsulate the balance so it is not directly accessible from outside the class.
- Use meaningful method names that clearly describe their purpose.
Expected Refactoring Outcome:
class BankAccount {
constructor(owner, initialBalance) {
this.owner = owner;
this._balance = initialBalance;
}
// Method to withdraw money
withdraw(amount) {
if (amount <= 0) throw new Error("Invalid amount");
if (amount > this._balance) throw new Error("Insufficient funds");
this._balance -= amount;
}
// Method to deposit money
deposit(amount) {
if (amount <= 0) throw new Error("Invalid amount");
this._balance += amount;
}
// Method to get the current balance
getBalance() {
return this._balance;
}
// Method to print account balance
printBalance() {
console.log(`Account Balance: ${this.getBalance()}`);
}
}
Step 2: Apply SOLID Principles
Refactor the code to adhere to the SOLID principles:
- Single Responsibility Principle (SRP): Split responsibilities by creating separate classes for different concerns.
- Open/Closed Principle (OCP): Ensure that the code can be extended without modifying existing code.
- Liskov Substitution Principle (LSP): Refactor any inheritance to adhere to LSP.
- Interface Segregation Principle (ISP): If applicable, ensure that classes only implement methods they actually use.
- Dependency Inversion Principle (DIP): Refactor dependencies to depend on abstractions rather than concrete classes.
Expected Refactoring Outcome:
class Transaction {
constructor(account, amount) {
this.account = account;
this.amount = amount;
}
execute() {
throw new Error("Method 'execute' must be implemented.");
}
}
class Withdrawal extends Transaction {
execute() {
this.account.withdraw(this.amount);
}
}
class Deposit extends Transaction {
execute() {
this.account.deposit(this.amount);
}
}
class BankAccount {
constructor(owner, initialBalance) {
this.owner = owner;
this._balance = initialBalance;
}
withdraw(amount) {
if (amount <= 0) throw new Error("Invalid amount");
if (amount > this._balance) throw new Error("Insufficient funds");
this._balance -= amount;
}
deposit(amount) {
if (amount <= 0) throw new Error("Invalid amount");
this._balance += amount;
}
getBalance() {
return this._balance;
}
printBalance() {
console.log(`Account Balance: ${this.getBalance()}`);
}
}
// Sample usage
const account = new BankAccount("John Doe", 1000);
const deposit = new Deposit(account, 500);
const withdrawal = new Withdrawal(account, 200);
deposit.execute();
withdrawal.execute();
account.printBalance();
Step 3: Apply Clean Code Practices
Refactor the code to make it clean and readable:
- Use meaningful names for variables, methods, and classes.
- Ensure that each function does one thing and one thing only.
- Remove any magic numbers by introducing named constants.
- Apply proper error handling.
Expected Refactoring Outcome:
class BankAccount {
constructor(owner, initialBalance) {
this.owner = owner;
this._balance = initialBalance;
}
withdraw(amount) {
this._validateAmount(amount);
if (amount > this._balance) throw new Error("Insufficient funds");
this._balance -= amount;
}
deposit(amount) {
this._validateAmount(amount);
this._balance += amount;
}
getBalance() {
return this._balance;
}
printBalance() {
console.log(`Account Balance: ${this.getBalance()}`);
}
_validateAmount(amount) {
if (amount <= 0) throw new Error("Amount must be greater than zero");
}
}
Step 4: Apply Design by Contract
Refactor the code to enforce Design by Contract principles:
- Define preconditions for methods to ensure that inputs are valid.
- Define postconditions to ensure that the method's results are valid.
- Implement invariants to ensure that the object's state is always consistent.
Expected Refactoring Outcome:
class BankAccount {
constructor(owner, initialBalance) {
this.owner = owner;
this._balance = initialBalance;
this._checkInvariants();
}
withdraw(amount) {
this._validateAmount(amount);
if (amount > this._balance) throw new Error("Insufficient funds");
this._balance -= amount;
this._checkInvariants();
}
deposit(amount) {
this._validateAmount(amount);
this._balance += amount;
this._checkInvariants();
}
getBalance() {
return this._balance;
}
printBalance() {
console.log(`Account Balance: ${this.getBalance()}`);
}
_validateAmount(amount) {
if (amount <= 0) throw new Error("Amount must be greater than zero");
}
_checkInvariants() {
if (this._balance < 0) throw new Error("Invariant violation: balance must be non-negative");
}
}
Submission
Submit your refactored code along with a short document explaining the changes you made and how they adhere to the principles of Object-Oriented Design, SOLID, Clean Code, and Design by Contract.