Python Phase 2: OOP & Advanced Features

Python Phase 2: OOP & Advanced Features
Welcome to Phase 2 of the Python Learning Roadmap! In this phase, you'll master object-oriented programming (OOP) and Python-specific advanced features that will help you write clean, maintainable, and professional Python code.
What You'll Learn
✅ Classes and objects in Python
✅ Inheritance and method overriding
✅ Property decorators and special methods
✅ Dataclasses (Python 3.10+)
✅ List/dict/set comprehensions
✅ Generator expressions
✅ Decorators basics
✅ Context managers
✅ Type hints fundamentals
Prerequisites
Before starting this phase, you should be comfortable with:
- Python syntax and basic data types (Phase 1)
- Functions and control flow
- Lists, dictionaries, and basic data structures
- File I/O and exception handling
1. Classes and Objects
Classes are blueprints for creating objects. They encapsulate data (attributes) and behavior (methods) together.
Basic Class Syntax
class User:
"""Represents a user in the system."""
def __init__(self, username: str, email: str) -> None:
"""Initialize a new user."""
self.username = username
self.email = email
self.is_active = True # Default value
def deactivate(self) -> None:
"""Deactivate this user account."""
self.is_active = False
def __str__(self) -> str:
"""String representation for users."""
status = "active" if self.is_active else "inactive"
return f"User({self.username}, {status})"
# Create instances
user1 = User("alice", "alice@example.com")
user2 = User("bob", "bob@example.com")
print(user1) # User(alice, active)
user1.deactivate()
print(user1) # User(alice, inactive)Special Methods (Magic Methods)
Python provides special methods that let you customize object behavior:
class BankAccount:
"""Represents a bank account."""
def __init__(self, account_number: str, balance: float = 0.0) -> None:
self.account_number = account_number
self.balance = balance
def __str__(self) -> str:
"""User-friendly string representation."""
return f"Account {self.account_number}: ${self.balance:.2f}"
def __repr__(self) -> str:
"""Developer-friendly representation (for debugging)."""
return f"BankAccount('{self.account_number}', {self.balance})"
def __eq__(self, other: object) -> bool:
"""Check equality based on account number."""
if not isinstance(other, BankAccount):
return NotImplemented
return self.account_number == other.account_number
def __add__(self, amount: float) -> "BankAccount":
"""Allow adding money with + operator."""
self.balance += amount
return self
# Usage
account = BankAccount("12345", 1000.0)
print(str(account)) # Account 12345: $1000.00
print(repr(account)) # BankAccount('12345', 1000.0)
account + 500 # Add money using +
print(account) # Account 12345: $1500.00Common special methods:
__init__: Constructor__str__: User-friendly string (used byprint())__repr__: Developer representation (used in REPL)__eq__,__lt__,__gt__: Comparison operators__len__: Length (forlen())__getitem__,__setitem__: Index access
2. Inheritance and Polymorphism
Inheritance allows classes to inherit attributes and methods from parent classes.
Basic Inheritance
from typing import Optional
class Animal:
"""Base class for all animals."""
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def speak(self) -> str:
"""Make a sound (to be overridden)."""
raise NotImplementedError("Subclasses must implement speak()")
def info(self) -> str:
"""Get animal info."""
return f"{self.name} is {self.age} years old"
class Dog(Animal):
"""A dog is a type of animal."""
def __init__(self, name: str, age: int, breed: str) -> None:
super().__init__(name, age) # Call parent constructor
self.breed = breed
def speak(self) -> str:
"""Dogs bark."""
return "Woof!"
def fetch(self, item: str) -> str:
"""Dog-specific behavior."""
return f"{self.name} fetches the {item}"
class Cat(Animal):
"""A cat is a type of animal."""
def __init__(self, name: str, age: int, indoor: bool = True) -> None:
super().__init__(name, age)
self.indoor = indoor
def speak(self) -> str:
"""Cats meow."""
return "Meow!"
# Polymorphism: different types, same interface
animals: list[Animal] = [
Dog("Buddy", 5, "Golden Retriever"),
Cat("Whiskers", 3),
Dog("Max", 2, "Beagle"),
]
for animal in animals:
print(f"{animal.name} says: {animal.speak()}")
# Buddy says: Woof!
# Whiskers says: Meow!
# Max says: Woof!Multiple Inheritance and Method Resolution Order (MRO)
class Flyable:
"""Mixin for flying capability."""
def fly(self) -> str:
return "I can fly!"
class Swimmable:
"""Mixin for swimming capability."""
def swim(self) -> str:
return "I can swim!"
class Duck(Animal, Flyable, Swimmable):
"""A duck can fly and swim."""
def speak(self) -> str:
return "Quack!"
duck = Duck("Donald", 2)
print(duck.speak()) # Quack!
print(duck.fly()) # I can fly!
print(duck.swim()) # I can swim!
# Check Method Resolution Order
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Animal'>, <class 'Flyable'>,
# <class 'Swimmable'>, <class 'object'>)3. Properties and Descriptors
Properties provide a way to customize attribute access while maintaining a clean interface.
Using @property Decorator
class Temperature:
"""Temperature with Celsius and Fahrenheit."""
def __init__(self, celsius: float) -> None:
self._celsius = celsius # Private attribute
@property
def celsius(self) -> float:
"""Get temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
"""Set temperature in Celsius with validation."""
if value < -273.15:
raise ValueError("Temperature below absolute zero!")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""Get temperature in Fahrenheit (computed property)."""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
"""Set temperature using Fahrenheit."""
self.celsius = (value - 32) * 5/9 # Use celsius setter
# Usage
temp = Temperature(25)
print(f"{temp.celsius}°C = {temp.fahrenheit}°F") # 25.0°C = 77.0°F
temp.celsius = 30
print(f"{temp.celsius}°C = {temp.fahrenheit}°F") # 30.0°C = 86.0°F
temp.fahrenheit = 32
print(f"{temp.celsius}°C") # 0.0°CClass Methods and Static Methods
from datetime import datetime
class Person:
"""Person with birth year."""
population = 0 # Class variable
def __init__(self, name: str, birth_year: int) -> None:
self.name = name
self.birth_year = birth_year
Person.population += 1
def get_age(self) -> int:
"""Instance method: needs self."""
return datetime.now().year - self.birth_year
@classmethod
def from_birth_date(cls, name: str, birth_date: str) -> "Person":
"""Class method: alternative constructor."""
year = int(birth_date.split("-")[0])
return cls(name, year)
@staticmethod
def is_adult(age: int) -> bool:
"""Static method: doesn't need class or instance."""
return age >= 18
# Usage
person1 = Person("Alice", 1990)
person2 = Person.from_birth_date("Bob", "1985-05-15") # Class method
print(person1.get_age()) # e.g., 36
print(Person.is_adult(person1.get_age())) # True
print(f"Population: {Person.population}") # 24. Dataclasses (Python 3.10+)
Dataclasses automatically generate special methods for data storage classes.
Basic Dataclass
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Product:
"""Product in an inventory system."""
name: str
price: float
quantity: int = 0
category: Optional[str] = None
def total_value(self) -> float:
"""Calculate total inventory value."""
return self.price * self.quantity
# Automatic __init__, __repr__, __eq__
product = Product("Laptop", 999.99, 10, "Electronics")
print(product)
# Product(name='Laptop', price=999.99, quantity=10, category='Electronics')
print(product.total_value()) # 9999.90Advanced Dataclass Features
from dataclasses import dataclass, field
from datetime import datetime
@dataclass(frozen=True) # Immutable dataclass
class Point:
"""Immutable 2D point."""
x: float
y: float
def distance_from_origin(self) -> float:
return (self.x**2 + self.y**2) ** 0.5
@dataclass
class Order:
"""Customer order with generated fields."""
customer_id: str
items: list[Product] = field(default_factory=list) # Mutable default
order_date: datetime = field(default_factory=datetime.now)
order_id: str = field(init=False) # Generated, not in __init__
def __post_init__(self) -> None:
"""Called after __init__ to set generated fields."""
self.order_id = f"ORD-{self.customer_id}-{self.order_date.timestamp()}"
def add_item(self, product: Product) -> None:
self.items.append(product)
def total_cost(self) -> float:
return sum(p.price * p.quantity for p in self.items)
# Usage
order = Order("CUST123")
order.add_item(Product("Mouse", 29.99, 2))
order.add_item(Product("Keyboard", 79.99, 1))
print(order.order_id) # ORD-CUST123-1737897600.0
print(f"Total: ${order.total_cost():.2f}") # Total: $139.975. Comprehensions
Comprehensions provide a concise way to create lists, dictionaries, and sets.
List Comprehensions
# Basic syntax: [expression for item in iterable if condition]
# Without comprehension
squares = []
for x in range(10):
squares.append(x**2)
# With comprehension (cleaner)
squares = [x**2 for x in range(10)]
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# With filtering
even_squares = [x**2 for x in range(10) if x % 2 == 0]
print(even_squares) # [0, 4, 16, 36, 64]
# Nested loops
pairs = [(x, y) for x in range(3) for y in range(3) if x != y]
print(pairs) # [(0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 1)]
# Transform strings
names = ["alice", "bob", "charlie"]
capitalized = [name.title() for name in names]
print(capitalized) # ['Alice', 'Bob', 'Charlie']Dictionary Comprehensions
# Basic syntax: {key_expr: value_expr for item in iterable}
# Create a mapping of numbers to their squares
squares_dict = {x: x**2 for x in range(6)}
print(squares_dict) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Swap keys and values
original = {"a": 1, "b": 2, "c": 3}
swapped = {v: k for k, v in original.items()}
print(swapped) # {1: 'a', 2: 'b', 3: 'c'}
# Filter dictionary
scores = {"Alice": 85, "Bob": 92, "Charlie": 78, "David": 95}
high_scorers = {name: score for name, score in scores.items() if score >= 90}
print(high_scorers) # {'Bob': 92, 'David': 95}Set Comprehensions
# Basic syntax: {expression for item in iterable}
# Unique word lengths
text = "the quick brown fox jumps over the lazy dog"
word_lengths = {len(word) for word in text.split()}
print(word_lengths) # {3, 4, 5}
# Remove duplicates with transformation
numbers = [1, -2, 3, -4, 5, -1, 2]
absolute_values = {abs(n) for n in numbers}
print(absolute_values) # {1, 2, 3, 4, 5}Generator Expressions
Generator expressions are like list comprehensions but create iterators (lazy evaluation).
# Syntax: (expression for item in iterable)
# Use parentheses instead of brackets
# List comprehension: creates full list in memory
squares_list = [x**2 for x in range(1000000)] # Uses ~4MB memory
# Generator expression: lazy evaluation
squares_gen = (x**2 for x in range(1000000)) # Uses ~80 bytes
# Iterate once
for sq in squares_gen:
if sq > 100:
print(sq)
break # 121
# Useful for large datasets
def read_large_file(file_path: str):
"""Generator to read file line by line."""
with open(file_path) as f:
# Process lines lazily without loading entire file
return (line.strip().upper() for line in f if line.strip())
# Use in function arguments
total = sum(x**2 for x in range(100)) # No need for extra brackets
print(total) # 3283506. Decorators Basics
Decorators are functions that modify the behavior of other functions or classes.
Function Decorators
from functools import wraps
from time import time, sleep
from typing import Callable, Any
def timer(func: Callable) -> Callable:
"""Decorator to measure function execution time."""
@wraps(func) # Preserve original function metadata
def wrapper(*args: Any, **kwargs: Any) -> Any:
start = time()
result = func(*args, **kwargs)
end = time()
print(f"{func.__name__} took {end - start:.4f}s")
return result
return wrapper
@timer
def slow_function() -> None:
"""A slow function."""
sleep(1)
print("Done!")
slow_function()
# Done!
# slow_function took 1.0012sDecorators with Arguments
from typing import Callable, Any
def repeat(times: int) -> Callable:
"""Decorator that repeats function execution."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for _ in range(times):
result = func(*args, **kwargs)
return result # Return last result
return wrapper
return decorator
@repeat(times=3)
def greet(name: str) -> str:
message = f"Hello, {name}!"
print(message)
return message
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!Built-in Decorators
class Circle:
"""Circle with cached area calculation."""
def __init__(self, radius: float) -> None:
self._radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@staticmethod
def from_diameter(diameter: float) -> "Circle":
"""Create circle from diameter."""
return Circle(diameter / 2)
@classmethod
def unit_circle(cls) -> "Circle":
"""Create a unit circle."""
return cls(1.0)
circle = Circle.unit_circle()
print(circle.radius) # 1.0For a complete guide to decorators, see the Python Decorators Deep Dive.
7. Context Managers
Context managers handle resource management (setup/teardown) automatically using the with statement.
Using Built-in Context Managers
# File handling (automatic close)
with open("data.txt", "w") as f:
f.write("Hello, World!")
# File is automatically closed here
# Multiple context managers
with open("input.txt") as infile, open("output.txt", "w") as outfile:
data = infile.read()
outfile.write(data.upper())Creating Custom Context Managers
from typing import Optional
class DatabaseConnection:
"""Database connection with automatic cleanup."""
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.connection: Optional[str] = None
def __enter__(self) -> "DatabaseConnection":
"""Setup: called when entering 'with' block."""
print(f"Connecting to {self.host}:{self.port}...")
self.connection = f"Connection to {self.host}"
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
"""Cleanup: called when exiting 'with' block."""
print(f"Closing connection to {self.host}...")
self.connection = None
# Return False to propagate exceptions, True to suppress
return False
def query(self, sql: str) -> str:
"""Execute a query."""
if not self.connection:
raise RuntimeError("No active connection")
return f"Result of: {sql}"
# Usage
with DatabaseConnection("localhost", 5432) as db:
result = db.query("SELECT * FROM users")
print(result)
# Connection automatically closed after block
# Connecting to localhost:5432...
# Result of: SELECT * FROM users
# Closing connection to localhost...Context Manager Using contextlib
from contextlib import contextmanager
from typing import Iterator
@contextmanager
def temporary_setting(config: dict, key: str, value: Any) -> Iterator[None]:
"""Temporarily change a config value."""
old_value = config.get(key)
config[key] = value
try:
yield # Code in 'with' block runs here
finally:
# Restore original value
if old_value is None:
config.pop(key, None)
else:
config[key] = old_value
# Usage
config = {"debug": False, "timeout": 30}
print(config) # {'debug': False, 'timeout': 30}
with temporary_setting(config, "debug", True):
print(config) # {'debug': True, 'timeout': 30}
print(config) # {'debug': False, 'timeout': 30} - restored8. Type Hints Fundamentals
Type hints improve code readability and enable static type checking with tools like mypy.
Basic Type Hints
from typing import List, Dict, Set, Tuple, Optional, Union
# Function annotations
def greet(name: str, age: int) -> str:
"""Greet a person."""
return f"Hello {name}, you are {age} years old"
# Variable annotations
username: str = "alice"
user_id: int = 12345
is_active: bool = True
# Collections (old style - Python 3.9+)
from typing import List, Dict
names: List[str] = ["Alice", "Bob", "Charlie"]
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}
# Modern style (Python 3.10+)
names: list[str] = ["Alice", "Bob", "Charlie"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (10.5, 20.3)Optional and Union Types
from typing import Optional, Union
# Optional[X] is shorthand for Union[X, None]
def find_user(user_id: int) -> Optional[str]:
"""Find user by ID, return None if not found."""
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # Returns str or None
# Union for multiple possible types
def process_id(user_id: Union[int, str]) -> str:
"""Process user ID as int or string."""
return str(user_id)
# Python 3.10+ uses | instead of Union
def process_id_modern(user_id: int | str) -> str:
"""Modern union syntax."""
return str(user_id)Type Hints for Classes
from typing import ClassVar
from dataclasses import dataclass
@dataclass
class Employee:
"""Employee with type hints."""
name: str
employee_id: int
salary: float
department: Optional[str] = None
# Class variable (shared across instances)
company_name: ClassVar[str] = "TechCorp"
def give_raise(self, amount: float) -> None:
"""Increase salary."""
self.salary += amount
def get_info(self) -> dict[str, str | int | float]:
"""Return employee info as dict."""
return {
"name": self.name,
"id": self.employee_id,
"salary": self.salary,
}Type Aliases
from typing import TypeAlias
# Create type aliases for complex types
UserId: TypeAlias = int
Username: TypeAlias = str
UserData: TypeAlias = dict[str, Union[str, int, bool]]
def create_user(user_id: UserId, username: Username) -> UserData:
"""Create user data."""
return {
"id": user_id,
"username": username,
"active": True,
}For a complete guide to type hints, see the Type Hints & Typing Deep Dive.
9. Practical Example: Building a Task Manager
Let's combine everything we've learned into a practical example:
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, ClassVar
from enum import Enum
class Priority(Enum):
"""Task priority levels."""
LOW = 1
MEDIUM = 2
HIGH = 3
URGENT = 4
class Status(Enum):
"""Task status."""
TODO = "todo"
IN_PROGRESS = "in_progress"
DONE = "done"
@dataclass
class Task:
"""Represents a task in the task manager."""
title: str
description: str = ""
priority: Priority = Priority.MEDIUM
status: Status = Status.TODO
created_at: datetime = field(default_factory=datetime.now)
completed_at: Optional[datetime] = None
task_count: ClassVar[int] = 0
def __post_init__(self) -> None:
Task.task_count += 1
def complete(self) -> None:
"""Mark task as completed."""
self.status = Status.DONE
self.completed_at = datetime.now()
@property
def is_overdue(self) -> bool:
"""Check if task is overdue (>7 days old and not done)."""
if self.status == Status.DONE:
return False
age = (datetime.now() - self.created_at).days
return age > 7
def __str__(self) -> str:
status_icon = "✅" if self.status == Status.DONE else "⏳"
return f"{status_icon} [{self.priority.name}] {self.title}"
class TaskManager:
"""Manages a collection of tasks."""
def __init__(self) -> None:
self.tasks: list[Task] = []
def add_task(self, task: Task) -> None:
"""Add a task to the manager."""
self.tasks.append(task)
def get_tasks_by_status(self, status: Status) -> list[Task]:
"""Get all tasks with a specific status."""
return [task for task in self.tasks if task.status == status]
def get_high_priority_tasks(self) -> list[Task]:
"""Get high priority and urgent tasks."""
return [
task for task in self.tasks
if task.priority in (Priority.HIGH, Priority.URGENT)
and task.status != Status.DONE
]
@property
def completion_rate(self) -> float:
"""Calculate completion rate as percentage."""
if not self.tasks:
return 0.0
completed = len([t for t in self.tasks if t.status == Status.DONE])
return (completed / len(self.tasks)) * 100
def __len__(self) -> int:
"""Return number of tasks."""
return len(self.tasks)
def __str__(self) -> str:
return f"TaskManager({len(self.tasks)} tasks, {self.completion_rate:.1f}% complete)"
# Usage
manager = TaskManager()
# Add tasks
manager.add_task(Task(
"Implement user authentication",
"Add login/logout functionality",
Priority.HIGH
))
manager.add_task(Task(
"Write documentation",
"Document API endpoints",
Priority.MEDIUM
))
manager.add_task(Task(
"Fix critical bug",
"Fix payment processing issue",
Priority.URGENT
))
# Mark first task as complete
manager.tasks[0].complete()
# Get high priority tasks
urgent_tasks = manager.get_high_priority_tasks()
for task in urgent_tasks:
print(task)
# ⏳ [URGENT] Fix critical bug
print(manager) # TaskManager(3 tasks, 33.3% complete)
print(f"Total tasks created: {Task.task_count}") # 310. Best Practices
✅ Use dataclasses for data storage: Reduces boilerplate code
✅ Prefer composition over inheritance: "Has-a" relationships are often clearer than "is-a"
✅ Use type hints: Improves code readability and catches bugs early
✅ Keep comprehensions readable: If it's complex, use a loop instead
✅ Use properties for computed attributes: Maintains clean interface
✅ Follow naming conventions: _private, __name_mangled, public
✅ Use context managers for resources: Ensures proper cleanup
✅ Write docstrings: Document classes and public methods
11. Common Pitfalls
❌ Mutable default arguments: Use field(default_factory=list) in dataclasses
❌ Forgetting self: First parameter of instance methods must be self
❌ Overusing inheritance: Deep inheritance hierarchies are hard to maintain
❌ Complex comprehensions: Break into multiple lines or use loops for clarity
❌ Not using @wraps: Always use functools.wraps in decorators
❌ Modifying lists during iteration: Create a copy first or use list comprehension
# ❌ BAD: Mutable default argument
def add_item(item: str, items: list = []) -> list:
items.append(item)
return items
# ✅ GOOD: Use None and create new list
def add_item(item: str, items: Optional[list] = None) -> list:
if items is None:
items = []
items.append(item)
return items
# ❌ BAD: Modifying list during iteration
numbers = [1, 2, 3, 4, 5]
for num in numbers:
if num % 2 == 0:
numbers.remove(num) # Skips elements!
# ✅ GOOD: Use list comprehension
numbers = [1, 2, 3, 4, 5]
numbers = [num for num in numbers if num % 2 != 0]Summary and Key Takeaways
In this phase, you learned:
✅ Classes and OOP: Create reusable, organized code with classes and inheritance
✅ Special methods: Customize object behavior with magic methods
✅ Properties: Control attribute access with @property decorator
✅ Dataclasses: Simplify data storage classes with automatic methods
✅ Comprehensions: Write concise, readable transformations for lists, dicts, and sets
✅ Generators: Process large datasets efficiently with lazy evaluation
✅ Decorators: Modify function behavior cleanly and reusably
✅ Context managers: Manage resources safely with with statements
✅ Type hints: Improve code quality with static typing
Practice Exercises
- Build a Library System: Create classes for
Book,Member, andLibrarywith borrowing/returning functionality - Implement a Decorator: Write a
@cachedecorator that memoizes function results - Data Processing Pipeline: Use comprehensions and generators to process a large dataset efficiently
Next Steps
Continue to Phase 3: Standard Library & Tools to learn about Python's powerful standard library and professional development tools.
Previous: Phase 1: Python Fundamentals
Related Deep Dives:
- Python Decorators - Complete guide to decorators
- Type Hints & Typing - Master Python's type system
Part of the Python Learning Roadmap series
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.