Extra Topics
Advanced Python concepts beyond the syllabus
Exception Handling
Exception handling allows you to gracefully handle errors that occur during program execution, preventing crashes and providing meaningful error messages.
Try-Except Block
The basic structure for handling exceptions:
# Basic try-except
try:
number = int(input("Enter a number: "))
result = 10 / number
print(f"Result: {result}")
except ValueError:
print("Invalid input! Please enter a number.")
except ZeroDivisionError:
print("Cannot divide by zero!")
# Using else and finally
try:
file = open("data.txt", "r")
content = file.read()
except FileNotFoundError:
print("File not found!")
else:
print("File read successfully!")
print(content)
finally:
print("This always executes")
# Clean up code here
Common Exception Types
| Exception | Description | Example |
|---|---|---|
ValueError |
Invalid value for operation | int("hello") |
TypeError |
Operation on wrong type | "2" + 2 |
ZeroDivisionError |
Division by zero | 10 / 0 |
IndexError |
Invalid list index | [1,2,3][10] |
KeyError |
Key not found in dict | {'a':1}['b'] |
FileNotFoundError |
File doesn't exist | open("none.txt") |
AttributeError |
Invalid attribute access | "hello".append() |
Raising Exceptions
# Raising exceptions
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative!")
if age > 150:
raise ValueError("Age seems unrealistic!")
return age
try:
user_age = set_age(-5)
except ValueError as e:
print(f"Error: {e}")
# Creating custom exception classes
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Cannot withdraw {amount}. Balance: {balance}")
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
print(e) # Cannot withdraw 150. Balance: 100
Object-Oriented Programming
OOP is a programming paradigm that organizes code into objects, which are instances of classes. It helps in creating modular, reusable, and maintainable code.
Classes and Objects
# Defining a class
class Student:
# Class variable (shared by all instances)
school_name = "Python Academy"
# Constructor (initializer)
def __init__(self, name, age, grade):
# Instance variables (unique to each object)
self.name = name
self.age = age
self.grade = grade
# Instance method
def introduce(self):
return f"Hi, I'm {self.name}, {self.age} years old, in grade {self.grade}"
# Method to modify data
def promote(self):
self.grade += 1
print(f"{self.name} promoted to grade {self.grade}!")
# Creating objects (instances)
student1 = Student("Alice", 15, 10)
student2 = Student("Bob", 16, 11)
# Accessing attributes and methods
print(student1.name) # Alice
print(student1.introduce()) # Hi, I'm Alice, 15 years old, in grade 10
print(Student.school_name) # Python Academy
# Modifying object
student1.promote() # Alice promoted to grade 11!
Encapsulation
Encapsulation restricts direct access to some components, protecting data integrity:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance # Private attribute (double underscore)
# Getter method
def get_balance(self):
return self.__balance
# Setter with validation
def deposit(self, amount):
if amount > 0:
self.__balance += amount
print(f"Deposited ₹{amount}. New balance: ₹{self.__balance}")
else:
print("Invalid amount!")
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew ₹{amount}. Remaining: ₹{self.__balance}")
else:
print("Insufficient funds or invalid amount!")
# Using the class
account = BankAccount("Rishi", 1000)
account.deposit(500) # Deposited ₹500. New balance: ₹1500
account.withdraw(200) # Withdrew ₹200. Remaining: ₹1300
print(account.get_balance()) # 1300
# Cannot directly access private variable
# print(account.__balance) # AttributeError!
Properties (Getters and Setters)
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""Getter for radius"""
return self._radius
@radius.setter
def radius(self, value):
"""Setter with validation"""
if value > 0:
self._radius = value
else:
raise ValueError("Radius must be positive!")
@property
def area(self):
"""Calculated property"""
import math
return math.pi * self._radius ** 2
@property
def circumference(self):
import math
return 2 * math.pi * self._radius
# Using properties like attributes
circle = Circle(5)
print(circle.radius) # 5
print(f"Area: {circle.area:.2f}") # Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Circumference: 31.42
circle.radius = 10 # Uses setter
print(circle.area) # 314.159...
Inheritance
Inheritance allows a class to inherit attributes and methods from another class, promoting code reuse.
Single Inheritance
# Parent class (Base class)
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
def make_sound(self):
return "Some generic sound"
def info(self):
return f"{self.name} is a {self.species}"
# Child class (Derived class)
class Dog(Animal):
def __init__(self, name, breed):
# Call parent constructor
super().__init__(name, "Dog")
self.breed = breed
# Override parent method
def make_sound(self):
return "Woof! Woof!"
# New method specific to Dog
def fetch(self):
return f"{self.name} is fetching the ball!"
class Cat(Animal):
def __init__(self, name, indoor=True):
super().__init__(name, "Cat")
self.indoor = indoor
def make_sound(self):
return "Meow!"
# Creating objects
dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers")
print(dog.info()) # Buddy is a Dog
print(dog.make_sound()) # Woof! Woof!
print(dog.fetch()) # Buddy is fetching the ball!
print(cat.info()) # Whiskers is a Cat
print(cat.make_sound()) # Meow!
Multiple Inheritance
# Multiple parent classes
class Flyable:
def fly(self):
return "Flying high!"
class Swimmable:
def swim(self):
return "Swimming smoothly!"
class Walkable:
def walk(self):
return "Walking on land!"
# Duck inherits from multiple classes
class Duck(Flyable, Swimmable, Walkable):
def __init__(self, name):
self.name = name
def quack(self):
return "Quack! Quack!"
# Duck can do everything!
duck = Duck("Donald")
print(duck.fly()) # Flying high!
print(duck.swim()) # Swimming smoothly!
print(duck.walk()) # Walking on land!
print(duck.quack()) # Quack! Quack!
# Check inheritance
print(isinstance(duck, Flyable)) # True
print(isinstance(duck, Swimmable)) # True
Polymorphism
# Different shapes with same interface
class Shape:
def area(self):
pass
def perimeter(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
def perimeter(self):
import math
return 2 * math.pi * self.radius
# Polymorphism in action - same method, different behavior
shapes = [Rectangle(4, 5), Circle(3), Rectangle(2, 8)]
for shape in shapes:
print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}")
Modules & Imports
Modules are Python files containing code that can be reused in other programs. They help organize code into manageable, logical units.
Importing Modules
# Method 1: Import entire module
import math
print(math.sqrt(16)) # 4.0
print(math.pi) # 3.14159...
# Method 2: Import with alias
import numpy as np
arr = np.array([1, 2, 3])
# Method 3: Import specific items
from math import sqrt, pi, ceil
print(sqrt(25)) # 5.0
print(ceil(4.2)) # 5
# Method 4: Import all (not recommended)
from math import *
print(sin(0)) # 0.0
# Method 5: Import with alias for specific item
from datetime import datetime as dt
now = dt.now()
print(now)
Creating Your Own Module
# File: mymodule.py
"""My custom utility module"""
PI = 3.14159
AUTHOR = "Rishi"
def greet(name):
"""Return a greeting message"""
return f"Hello, {name}! Welcome to Python."
def calculate_area(radius):
"""Calculate circle area"""
return PI * radius ** 2
class Calculator:
"""Simple calculator class"""
@staticmethod
def add(a, b):
return a + b
@staticmethod
def multiply(a, b):
return a * b
# This runs only when module is executed directly
if __name__ == "__main__":
print("Testing mymodule...")
print(greet("World"))
print(calculate_area(5))
# File: main.py
import mymodule
print(mymodule.greet("Alice"))
print(mymodule.calculate_area(10))
print(mymodule.PI)
calc = mymodule.Calculator()
print(calc.add(5, 3))
# Or import specific items
from mymodule import greet, Calculator
print(greet("Bob"))
Useful Built-in Modules
| Module | Purpose | Example Usage |
|---|---|---|
os |
Operating system interface | os.getcwd(), os.listdir() |
sys |
System-specific parameters | sys.path, sys.exit() |
datetime |
Date and time operations | datetime.now() |
random |
Random number generation | random.randint(1, 100) |
json |
JSON encoding/decoding | json.loads(), json.dumps() |
re |
Regular expressions | re.search(), re.findall() |
collections |
Specialized containers | Counter, defaultdict |
List Comprehensions
List comprehensions provide a concise way to create lists. They are more readable and often faster than traditional loops.
Basic Syntax
# Traditional way
squares = []
for x in range(10):
squares.append(x ** 2)
# List comprehension (same result)
squares = [x ** 2 for x in range(10)]
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# With condition (filter)
even_squares = [x ** 2 for x in range(10) if x % 2 == 0]
print(even_squares) # [0, 4, 16, 36, 64]
# With if-else (transform)
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
print(labels) # ['even', 'odd', 'even', 'odd', 'even']
# String manipulation
words = ["hello", "world", "python"]
upper_words = [word.upper() for word in words]
print(upper_words) # ['HELLO', 'WORLD', 'PYTHON']
# Nested comprehension
matrix = [[i * j for j in range(1, 4)] for i in range(1, 4)]
print(matrix) # [[1, 2, 3], [2, 4, 6], [3, 6, 9]]
Dictionary & Set Comprehensions
# Dictionary comprehension
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}
# From two lists
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
name_age = {name: age for name, age in zip(names, ages)}
print(name_age) # {'Alice': 25, 'Bob': 30, 'Charlie': 35}
# Filtering dictionary
scores = {"Alice": 85, "Bob": 62, "Charlie": 91, "Diana": 78}
passed = {name: score for name, score in scores.items() if score >= 70}
print(passed) # {'Alice': 85, 'Charlie': 91, 'Diana': 78}
# Set comprehension (unique values)
numbers = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
unique_squares = {x**2 for x in numbers}
print(unique_squares) # {16, 1, 4, 9}
# Generator expression (memory efficient)
sum_of_squares = sum(x**2 for x in range(1000000))
print(sum_of_squares)
When to Use Comprehensions
- Simple transformations and filtering
- Creating new collections from existing ones
- When readability is maintained (avoid complex nested comprehensions)
Lambda Functions
Lambda functions are small, anonymous functions defined with the lambda keyword. They can have any number of arguments but only one expression.
Lambda Syntax
# Regular function
def add(a, b):
return a + b
# Equivalent lambda
add_lambda = lambda a, b: a + b
print(add(3, 5)) # 8
print(add_lambda(3, 5)) # 8
# More lambda examples
square = lambda x: x ** 2
print(square(4)) # 16
greet = lambda name: f"Hello, {name}!"
print(greet("Python")) # Hello, Python!
# Lambda with conditional
max_val = lambda a, b: a if a > b else b
print(max_val(10, 20)) # 20
Lambda with Built-in Functions
# map() - apply function to all items
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16, 25]
# filter() - filter items by condition
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4, 6, 8, 10]
# sorted() - custom sorting
students = [
{"name": "Alice", "grade": 85},
{"name": "Bob", "grade": 92},
{"name": "Charlie", "grade": 78}
]
# Sort by grade
by_grade = sorted(students, key=lambda s: s["grade"])
print([s["name"] for s in by_grade]) # ['Charlie', 'Alice', 'Bob']
# Sort by name (descending)
by_name_desc = sorted(students, key=lambda s: s["name"], reverse=True)
print([s["name"] for s in by_name_desc]) # ['Charlie', 'Bob', 'Alice']
# reduce() - cumulative operation
from functools import reduce
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda a, b: a * b, numbers)
print(product) # 120 (1*2*3*4*5)
Decorators
Decorators are a way to modify or extend the behavior of functions or methods without changing their source code.
Basic Decorator
# Simple decorator
def uppercase_decorator(func):
def wrapper():
result = func()
return result.upper()
return wrapper
@uppercase_decorator
def greet():
return "hello, world!"
print(greet()) # HELLO, WORLD!
# Decorator with timing
import time
def timer_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer_decorator
def slow_function():
time.sleep(1)
return "Done!"
slow_function() # slow_function took 1.0012 seconds
Practical Decorator Examples
# Logging decorator
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 5)
# Calling add with args: (3, 5), kwargs: {}
# add returned: 8
# Authentication decorator
def require_auth(func):
def wrapper(user, *args, **kwargs):
if not user.get("authenticated", False):
raise PermissionError("User not authenticated!")
return func(user, *args, **kwargs)
return wrapper
@require_auth
def view_dashboard(user):
return f"Welcome to dashboard, {user['name']}!"
user = {"name": "Alice", "authenticated": True}
print(view_dashboard(user)) # Welcome to dashboard, Alice!
# Retry decorator
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt + 1} failed: {e}")
raise Exception(f"All {max_attempts} attempts failed")
return wrapper
return decorator
@retry(max_attempts=3)
def unstable_function():
import random
if random.random() < 0.7:
raise ValueError("Random failure!")
return "Success!"
Generators
Generators are functions that can pause and resume their execution, yielding values one at a time. They are memory-efficient for large datasets.
Creating Generators
# Generator function using yield
def count_up_to(n):
count = 1
while count <= n:
yield count
count += 1
# Using the generator
counter = count_up_to(5)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# Or in a loop
for num in count_up_to(5):
print(num, end=" ") # 1 2 3 4 5
# Fibonacci generator
def fibonacci(limit):
a, b = 0, 1
while a < limit:
yield a
a, b = b, a + b
fib = list(fibonacci(100))
print(fib) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
# Infinite generator
def infinite_counter():
num = 0
while True:
yield num
num += 1
counter = infinite_counter()
for _ in range(5):
print(next(counter), end=" ") # 0 1 2 3 4
Generator Expressions
# List comprehension (creates list in memory)
squares_list = [x**2 for x in range(1000000)]
# Generator expression (lazy evaluation)
squares_gen = (x**2 for x in range(1000000))
# Memory comparison
import sys
print(f"List size: {sys.getsizeof(squares_list)} bytes") # ~8 MB
print(f"Generator size: {sys.getsizeof(squares_gen)} bytes") # ~120 bytes
# Using generator expression with sum
total = sum(x**2 for x in range(1000))
print(total) # 332833500
# Chaining generators
numbers = range(100)
evens = (x for x in numbers if x % 2 == 0)
squares = (x**2 for x in evens)
result = sum(squares)
print(result) # 161700
When to Use Generators
- Processing large files line by line
- Working with infinite sequences
- Memory-constrained environments
- Pipeline processing (chaining operations)
Regular Expressions
Regular expressions (regex) are powerful patterns for matching, searching, and manipulating text. Python's re module provides regex support.
Basic Pattern Matching
import re
text = "The quick brown fox jumps over the lazy dog"
# search() - find first match
match = re.search(r"fox", text)
if match:
print(f"Found: {match.group()}") # Found: fox
print(f"Position: {match.start()}-{match.end()}") # Position: 16-19
# findall() - find all matches
vowels = re.findall(r"[aeiou]", text)
print(vowels) # ['e', 'u', 'i', 'o', 'o', 'u', 'o', 'e', 'e', 'a', 'o']
# finditer() - iterator of matches
for match in re.finditer(r"\b\w{4}\b", text): # 4-letter words
print(match.group(), end=" ") # over lazy
# match() - match at beginning
if re.match(r"The", text):
print("Starts with 'The'")
# sub() - replace pattern
new_text = re.sub(r"fox", "cat", text)
print(new_text) # The quick brown cat jumps over the lazy dog
Common Patterns
| Pattern | Description | Example |
|---|---|---|
\d | Any digit | \d{3} matches "123" |
\w | Word character (a-z, A-Z, 0-9, _) | \w+ matches "hello_123" |
\s | Whitespace | \s+ matches spaces/tabs |
. | Any character (except newline) | a.c matches "abc", "a1c" |
^ | Start of string | ^Hello matches start |
$ | End of string | world$ matches end |
* | 0 or more | ab* matches "a", "ab", "abb" |
+ | 1 or more | ab+ matches "ab", "abb" |
? | 0 or 1 | colou?r matches "color", "colour" |
{n,m} | n to m times | \d{2,4} matches 2-4 digits |
Practical Examples
import re
# Email validation
def is_valid_email(email):
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return bool(re.match(pattern, email))
print(is_valid_email("user@example.com")) # True
print(is_valid_email("invalid.email")) # False
# Phone number extraction
text = "Call me at 123-456-7890 or 9876543210"
phones = re.findall(r'\d{3}[-.]?\d{3}[-.]?\d{4}', text)
print(phones) # ['123-456-7890', '9876543210']
# URL extraction
text = "Visit https://python.org or http://example.com"
urls = re.findall(r'https?://[\w\.-]+\.[\w]+', text)
print(urls) # ['https://python.org', 'http://example.com']
# Password validation
def validate_password(password):
"""
Password must have:
- At least 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
"""
pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$'
return bool(re.match(pattern, password))
print(validate_password("Weak")) # False
print(validate_password("Strong@Pass1")) # True
# Extract data from formatted string
data = "Name: John Doe, Age: 25, City: New York"
pattern = r'Name: ([\w\s]+), Age: (\d+), City: ([\w\s]+)'
match = re.search(pattern, data)
if match:
name, age, city = match.groups()
print(f"Name: {name}, Age: {age}, City: {city}")