0% found this document useful (0 votes)
2 views18 pages

Python Notes Unit 3-5 (1)

The document covers key concepts of encapsulation, data hiding, abstraction, composition, and aggregation in Object-Oriented Programming (OOP) with Python. It explains access specifiers, the use of getters/setters, and the benefits of encapsulation and abstraction for code security and maintainability. Additionally, it discusses design patterns, including creational, structural, and behavioral patterns, and their implementation in Python.

Uploaded by

vkkanojia079
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
2 views18 pages

Python Notes Unit 3-5 (1)

The document covers key concepts of encapsulation, data hiding, abstraction, composition, and aggregation in Object-Oriented Programming (OOP) with Python. It explains access specifiers, the use of getters/setters, and the benefits of encapsulation and abstraction for code security and maintainability. Additionally, it discusses design patterns, including creational, structural, and behavioral patterns, and their implementation in Python.

Uploaded by

vkkanojia079
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd
You are on page 1/ 18

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. |

---

You might also like