Unit III
1. Introduction to Encapsulation and Data Hiding**
Encapsulation is one of the four fundamental principles of Object-Oriented Programming
(OOP). It refers to bundling data (attributes) and methods (functions) that operate on the
data into a single unit (class).
### **Key Concepts:**
- **Data Hiding:** Restricting direct access to certain components of an object to prevent
accidental modification.
- **Access Control:** Defining how attributes and methods can be accessed (public, private,
protected).
---
2. Access Specifiers in Python**
Python does not enforce strict access control like Java or C++, but it follows naming
conventions to indicate access levels.
| Access Specifier | Naming Convention | Accessibility |
|-----------------|------------------|--------------|
| **Public** | `variable_name` | Accessible anywhere (inside/outside class). |
| **Protected** | `_variable_name` (single underscore) | Accessible within the class and
its subclasses (but still accessible outside). |
| **Private** | `__variable_name` (double underscore) | Accessible only within the class
(name mangling makes it harder to access outside). |
### **Example:**
```python
class Employee:
def __init__(self, name, salary, department):
self.name = name # Public
self._salary = salary # Protected
self.__department = department # Private
def display(self):
print(f"Name: {self.name}")
print(f"Salary: {self._salary}")
print(f"Department: {self.__department}")
emp = Employee("Alice", 50000, "HR")
emp.display()
# Accessing attributes:
print(emp.name) # Works (Public)
print(emp._salary) # Works (Protected, but discouraged)
print(emp.__department) # Error (Private, due to name mangling)
```
### **Name Mangling in Private Members**
Python renames private attributes as `_ClassName__variable` to make them harder to
access externally.
```python
print(emp._Employee__department) # Works (but should be avoided)
```
---
3. Implementing Encapsulation for Robust Code**
Encapsulation helps in:
- **Controlled Access:** Use getters/setters to modify attributes safely.
- **Data Validation:** Prevent invalid data assignments.
- **Maintainability:** Changes in internal implementation do not affect external code.
### **Example with Getters/Setters:**
```python
class BankAccount:
def __init__(self, account_holder, balance):
self.__account_holder = account_holder
self.__balance = balance
# Getter for balance
def get_balance(self):
return self.__balance
# Setter for balance (with validation)
def set_balance(self, amount):
if amount >= 0:
self.__balance = amount
else:
print("Balance cannot be negative!")
account = BankAccount("Bob", 1000)
print(account.get_balance()) # 1000
account.set_balance(1500) # Valid update
account.set_balance(-500) # Rejected
```
### **Using `@property` for Better Encapsulation**
Python provides a cleaner way to implement getters/setters using `@property`.
```python
class BankAccount:
def __init__(self, account_holder, balance):
self.__account_holder = account_holder
self.__balance = balance
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, amount):
if amount >= 0:
self.__balance = amount
else:
print("Balance cannot be negative!")
account = BankAccount("Bob", 1000)
print(account.balance) # 1000 (getter)
account.balance = 1500 # setter
account.balance = -500 # rejected
```
---
4. Introduction to Abstraction**
Abstraction focuses on hiding complex implementation details and exposing only essential
features.
### **Key Concepts:**
- **Abstract Classes:** Classes that cannot be instantiated (must be subclassed).
- **Interfaces:** Define a contract for methods that must be implemented (Python uses
abstract classes for this).
---
5. Implementing Abstract Classes in Python**
Python’s `abc` module helps create abstract classes.
### **Example:**
```python
from abc import ABC, abstractmethod
class Shape(ABC): # Abstract class
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
def perimeter(self):
return 2 * 3.14 * self.radius
# shape = Shape() # Error (cannot instantiate abstract class)
circle = Circle(5)
print(circle.area()) # 78.5
```
### **Interfaces in Python**
Python does not have explicit interfaces, but abstract classes can serve a similar purpose.
```python
from abc import ABC, abstractmethod
class DatabaseInterface(ABC):
@abstractmethod
def connect(self):
pass
@abstractmethod
def disconnect(self):
pass
class MySQLDatabase(DatabaseInterface):
def connect(self):
print("MySQL connected")
def disconnect(self):
print("MySQL disconnected")
db = MySQLDatabase()
db.connect()
```
---
## **6. Benefits of Encapsulation and Abstraction**
- **Security:** Prevents unauthorized access.
- **Modularity:** Easier to maintain and update.
- **Flexibility:** Implementation can change without affecting external code.
- **Reusability:** Abstract classes/interfaces promote consistent design.
---
## **Summary**
| Concept | Description |
|---------|-------------|
| **Encapsulation** | Bundling data and methods, controlling access. |
| **Public Members** | Accessible anywhere. |
| **Protected Members** | Convention: `_var` (accessible in class/subclasses). |
| **Private Members** | Convention: `__var` (name mangling prevents direct access). |
| **Getters/Setters** | Controlled access to attributes. |
| **Abstraction** | Hiding complexity, exposing only essentials. |
| **Abstract Classes** | Cannot be instantiated, must implement abstract methods. |
---
Unit IV
# **Lecture Notes: Composition, Aggregation, and Their Role in OOP Design**
1. Introduction to Composition and Aggregation**
In Object-Oriented Programming (OOP), **composition** and **aggregation** are two
ways to establish relationships between classes. They help in designing modular, reusable,
and maintainable systems.
### **Key Concepts:**
- **Composition:** A "whole-part" relationship where the part cannot exist without the
whole (strong dependency).
- **Aggregation:** A "whole-part" relationship where the part can exist independently
(weak dependency).
- **Inheritance vs. Composition/Aggregation:** Inheritance defines an "is-a" relationship,
while composition/aggregation define "has-a" relationships.
---
2. Composition in Python**
In **composition**, the lifetime of the part is controlled by the whole. If the whole is
destroyed, the part is also destroyed.
### **Example: A `Car` composed of an `Engine`**
```python
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
print("Engine started")
class Car:
def __init__(self, model, horsepower):
self.model = model
self.engine = Engine(horsepower) # Composition (Engine cannot exist without Car)
def start(self):
print(f"{self.model} is starting...")
self.engine.start()
car = Car("Tesla Model S", 500)
car.start()
```
**Output:**
```
Tesla Model S is starting...
Engine started
```
### **Characteristics of Composition:**
✔ The `Engine` is created when the `Car` is created.
✔ If the `Car` is deleted, the `Engine` is also deleted.
✔ Strong ownership (the part belongs exclusively to the whole).
---
3. Aggregation in Python**
In **aggregation**, the part can exist independently of the whole. The whole only "uses"
the part.
### **Example: A `University` aggregating `Professor` objects**
```python
class Professor:
def __init__(self, name):
self.name = name
def teach(self):
print(f"{self.name} is teaching")
class University:
def __init__(self, name):
self.name = name
self.professors = [] # Aggregation (Professors can exist outside University)
def add_professor(self, professor):
self.professors.append(professor)
def display_professors(self):
print(f"Professors at {self.name}:")
for prof in self.professors:
print(f"- {prof.name}")
# Professors can exist independently
prof1 = Professor("Dr. Smith")
prof2 = Professor("Dr. Johnson")
university = University("MIT")
university.add_professor(prof1)
university.add_professor(prof2)
university.display_professors()
```
**Output:**
```
Professors at MIT:
- Dr. Smith
- Dr. Johnson
```
### **Characteristics of Aggregation:**
✔ The `Professor` objects exist independently.
✔ The `University` only holds references to them.
✔ No strong ownership (the part can belong to multiple wholes).
---
4. Composition vs. Aggregation vs. Inheritance**
| Feature | Composition | Aggregation | Inheritance |
|---------|------------|------------|------------|
| **Relationship** | "Has-a" (strong) | "Has-a" (weak) | "Is-a" |
| **Lifetime Dependency** | Part dies with whole | Part can exist independently | Child
inherits from parent |
| **Flexibility** | Less flexible (tight coupling) | More flexible (loose coupling) | Can lead to
rigid hierarchies |
| **Example** | `Car` has an `Engine` | `University` has `Professors` | `Dog` is an `Animal` |
---
5. When to Use Composition vs. Aggregation?**
### **Use Composition When:**
- The part cannot exist without the whole (e.g., `Room` cannot exist without a `House`).
- You need strong control over the lifecycle of components.
### **Use Aggregation When:**
- The part can exist independently (e.g., `Student` can exist without a `Class`).
- You need flexibility in object relationships.
### **Prefer Composition Over Inheritance When:**
- You want to avoid deep inheritance hierarchies.
- You need to change behavior at runtime (composition allows swapping parts).
---
6. Advantages and Disadvantages**
### **Composition:**
✔ **Pros:**
- Strong encapsulation (parts are hidden inside the whole).
- Better control over object lifecycle.
- More flexible than inheritance.
✖ **Cons:**
- Can lead to complex structures if overused.
- Requires careful management of dependencies.
### **Aggregation:**
✔ **Pros:**
- More flexible (objects can be shared).
- Promotes loose coupling.
✖ **Cons:**
- Less control over parts (they can be modified externally).
- May lead to unintended side effects if not managed properly.
### **Inheritance:**
✔ **Pros:**
- Easy to implement (code reuse).
- Models "is-a" relationships well.
✖ **Cons:**
- Can lead to rigid hierarchies.
- Tight coupling between parent and child classes.
---
7. Designing Complex Systems with Composition & Aggregation**
### **Example: A `Computer` System**
```python
class CPU:
def __init__(self, cores):
self.cores = cores
class RAM:
def __init__(self, size):
self.size = size
class Storage:
def __init__(self, capacity):
self.capacity = capacity
class Computer:
def __init__(self, cpu_cores, ram_size, storage_capacity):
self.cpu = CPU(cpu_cores) # Composition (CPU is part of Computer)
self.ram = RAM(ram_size) # Composition (RAM is part of Computer)
self.storage = Storage(storage_capacity) # Composition
def specs(self):
print(f"CPU Cores: {self.cpu.cores}")
print(f"RAM: {self.ram.size}GB")
print(f"Storage: {self.storage.capacity}TB")
# Aggregation Example: A `Network` of Computers
class Network:
def __init__(self):
self.computers = [] # Aggregation (Computers can exist independently)
def add_computer(self, computer):
self.computers.append(computer)
def display_specs(self):
for comp in self.computers:
comp.specs()
# Usage
pc1 = Computer(8, 16, 1)
pc2 = Computer(4, 8, 2)
network = Network()
network.add_computer(pc1)
network.add_computer(pc2)
network.display_specs()
```
**Output:**
```
CPU Cores: 8
RAM: 16GB
Storage: 1TB
CPU Cores: 4
RAM: 8GB
Storage: 2TB
```
---
8. Best Practices**
1. **Favor Composition Over Inheritance** (to avoid deep hierarchies).
2. **Use Aggregation for Shared Resources** (e.g., a `Library` has `Books`).
3. **Avoid Circular Dependencies** (e.g., `A` depends on `B`, and `B` depends on `A`).
4. **Encapsulate Parts Properly** (use private/protected members where needed).
---
## **Summary**
| Concept | Description | Example |
|---------|------------|---------|
| **Composition** | Strong "has-a" relationship, part dies with whole. | `Car` has an
`Engine`. |
| **Aggregation** | Weak "has-a" relationship, part can exist independently. | `University`
has `Professors`. |
| **Inheritance** | "Is-a" relationship, promotes code reuse. | `Dog` is an `Animal`. |
| **When to Use?** | Composition for tight control, aggregation for flexibility. | Prefer
composition over inheritance. |
---
Unit V
# **Lecture Notes: Advanced OOP Concepts, Design Patterns, and SOLID Principles in
Python**
## **1. Advanced OOP Concepts in Python**
### **1.1 Static Methods (`@staticmethod`)**
- **Definition:** A method that belongs to the class rather than an instance.
- **Key Features:**
- Does not require `self` or `cls` as the first parameter.
- Cannot modify class or instance state.
- Called using the class name (e.g., `ClassName.method()`).
- **Use Case:** Utility functions that do not depend on instance/class state.
```python
class MathUtils:
@staticmethod
def add(a, b):
return a + b
print(MathUtils.add(5, 3)) # Output: 8
```
---
### **1.2 Class Methods (`@classmethod`)**
- **Definition:** A method bound to the class (not the instance) and takes `cls` as the first
parameter.
- **Key Features:**
- Can modify class state (e.g., class variables).
- Often used as **factory methods** (alternative constructors).
- **Use Case:** Creating instances with different initialization logic.
```python
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def from_birth_year(cls, name, birth_year):
age = 2024 - birth_year
return cls(name, age) # Creates a new Person instance
person = Person.from_birth_year("Alice", 1990)
print(person.age) # Output: 34 (assuming current year is 2024)
```
---
### **1.3 Properties (`@property`)**
- **Definition:** Allows controlled access to instance attributes via getters/setters.
- **Key Features:**
- Encapsulates attribute access.
- Enforces validation before setting values.
- **Use Case:** When attribute modification requires logic (e.g., validation).
```python
class Circle:
def __init__(self, radius):
self._radius = radius # Protected attribute
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value > 0:
self._radius = value
else:
raise ValueError("Radius must be positive")
circle = Circle(5)
print(circle.radius) # Output: 5 (getter)
circle.radius = 10 # Valid (setter)
circle.radius = -1 # Raises ValueError
```
---
## **2. Introduction to Design Patterns**
Design patterns are reusable solutions to common software design problems. They are
categorized into:
| Category | Purpose | Examples |
|----------|---------|----------|
| **Creational** | Object creation mechanisms | Singleton, Factory, Builder |
| **Structural** | Class/object composition | Adapter, Decorator, Facade |
| **Behavioral** | Object interaction & responsibility | Observer, Strategy, Command |
---
## **3. Implementing Design Patterns in Python**
### **3.1 Creational Patterns**
#### **Singleton Pattern**
- **Purpose:** Ensures only one instance of a class exists.
- **Implementation:** Use a class variable to store the instance.
```python
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # Output: True (same instance)
```
#### **Factory Pattern**
- **Purpose:** Creates objects without specifying the exact class.
- **Implementation:** Use a factory method to decide which class to instantiate.
```python
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
def get_pet(pet_type):
pets = {"dog": Dog(), "cat": Cat()}
return pets.get(pet_type, None)
pet = get_pet("dog")
print(pet.speak()) # Output: Woof!
```
---
### **3.2 Structural Patterns**
#### **Adapter Pattern**
- **Purpose:** Allows incompatible interfaces to work together.
- **Implementation:** Create an adapter class to bridge two interfaces.
```python
class EuropeanSocket:
def voltage(self):
return 230
class USASocket:
def voltage(self):
return 120
class Adapter:
def __init__(self, socket):
self.socket = socket
def voltage(self):
return f"Adapted {self.socket.voltage()}V"
euro_socket = EuropeanSocket()
adapter = Adapter(euro_socket)
print(adapter.voltage()) # Output: Adapted 230V
```
#### **Decorator Pattern**
- **Purpose:** Dynamically adds responsibilities to objects.
- **Implementation:** Wrap an object with a decorator class.
```python
class Coffee:
def cost(self):
return 5
class MilkDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 2
coffee = Coffee()
coffee_with_milk = MilkDecorator(coffee)
print(coffee_with_milk.cost()) # Output: 7
```
---
### **3.3 Behavioral Patterns**
#### **Observer Pattern**
- **Purpose:** Notifies multiple objects when state changes.
- **Implementation:** Use a subject-observer mechanism.
```python
class NewsAgency:
def __init__(self):
self._subscribers = []
def subscribe(self, subscriber):
self._subscribers.append(subscriber)
def notify(self, news):
for sub in self._subscribers:
sub.update(news)
class Subscriber:
def update(self, news):
print(f"Received news: {news}")
agency = NewsAgency()
sub1 = Subscriber()
agency.subscribe(sub1)
agency.notify("Python 4.0 released!") # Output: Received news: Python 4.0 released!
```
#### **Strategy Pattern**
- **Purpose:** Encapsulates interchangeable algorithms.
- **Implementation:** Define a family of algorithms and make them interchangeable.
```python
class PaymentStrategy:
def pay(self, amount):
pass
class CreditCardPayment(PaymentStrategy):
def pay(self, amount):
print(f"Paid ${amount} via Credit Card")
class PayPalPayment(PaymentStrategy):
def pay(self, amount):
print(f"Paid ${amount} via PayPal")
class ShoppingCart:
def __init__(self, strategy):
self._strategy = strategy
def checkout(self, amount):
self._strategy.pay(amount)
cart = ShoppingCart(PayPalPayment())
cart.checkout(100) # Output: Paid $100 via PayPal
```
---
## **4. SOLID Design Principles**
SOLID principles promote maintainable and scalable OOP design.
| Principle | Description | Python Example |
|-----------|-------------|----------------|
| **S (Single Responsibility)** | A class should have only one reason to change. | Separate
`User` (data) and `UserDB` (storage). |
| **O (Open-Closed)** | Classes should be open for extension but closed for modification. |
Use inheritance/ composition instead of modifying existing code. |
| **L (Liskov Substitution)** | Subclasses should be substitutable for their parent classes. |
Ensure child classes do not break parent behavior. |
| **I (Interface Segregation)** | Clients should not be forced to depend on unused
interfaces. | Split large interfaces into smaller ones. |
| **D (Dependency Inversion)** | Depend on abstractions, not concrete implementations. |
Use dependency injection. |
---
## **5. Best Practices for Maintainable Python OOP Code**
1. **Follow SOLID Principles** (avoid god classes, favor composition).
2. **Use Design Patterns Wisely** (don’t over-engineer).
3. **Write Unit Tests** (ensure code correctness).
4. **Document with Docstrings** (improve readability).
5. **Use Type Hints** (for better IDE support).
6. **Avoid Global State** (use dependency injection).
---
## **Summary**
| Topic | Key Takeaways |
|-------|--------------|
| **Static/Class Methods** | `@staticmethod` (no `self`/`cls`), `@classmethod` (factory
methods). |
| **Properties** | Controlled attribute access using `@property`. |
| **Design Patterns** | Singleton, Factory, Adapter, Observer, Strategy. |
| **SOLID Principles** | Single Responsibility, Open-Closed, Liskov Substitution, etc. |
| **Best Practices** | Testable, modular, and documented code. |
---