Python Programming
Master Core python Concepts
Practice Questions
Python is a high-level, interpreted, object-oriented programming language known for its simplicity and readability.
Dictionary is a collection of data Items , each Item is a key ,value pair
A variable is a named location used to store data values.
int, float, complex, str, list, tuple, set, dict, and bool.
int
float
complex
str
list
tuple
dict
set
bool
NoneType
None represents the absence of a value.
x = None
PEP 8 is Python's official style guide that recommends coding standards for better readability.
Easy syntax, interpreted language, object-oriented, platform independent, open source, large standard library, and dynamic typing.
Type casting is the conversion of one data type into another using functions like int(), float(), and str().
between == and is?
== compares values, whereas is compares memory locations (object identity).
An interpreter is a program that executes Python code line by line at runtime, converting it to machine code on the fly. Unlike compiled languages, Python does not need a separate compilation step. The default interpreter is CPython, but alternatives include PyPy, Jython, and IronPython.
Indentation refers to the spaces or tabs at the beginning of a code line. Python uses indentation to define blocks of code (loops, functions, classes, conditionals) instead of curly braces. Consistent indentation (typically 4 spaces) is mandatory — incorrect indentation causes IndentationError.
Example:
if True:
print("Indented correctly") # 4 spaces
Comments are non-executable lines used to explain code.
Single-line comment:
# This is a comment
Multi-line comment (using triple quotes):
"""
This is a
multi-line comment
"""
Comments improve code readability and are ignored by the interpreter.
print() displays output to the console.
input() reads user input from the console and returns it as a string.
Example:
name = input("Enter your name: ") # reads input
print("Hello", name) # displays output
input() always returns a string, so you need type casting for numbers:
age = int(input("Enter age: "))
Keywords are reserved words with special meaning that cannot be used as variable names. Python has 35 keywords including: if, else, elif, for, while, break, continue, def, class, return, import, from, as, try, except, finally, raise, with, lambda, True, False, None, and, or, not, in, is, global, nonlocal, pass, yield, del, assert.
You can list all keywords using:
import keyword
print(keyword.kwlist)
A string is a sequence of characters enclosed in single quotes, double quotes, or triple quotes.
Examples:
s1 = 'hello'
s2 = "world"
s3 = """multi
line string"""
Strings are immutable — once created, they cannot be changed. Common operations include indexing (s[0]), slicing (s[1:3]), and methods like upper(), lower(), split(), join(), strip(), replace().
Use the input() function which reads a line from the console and returns it as a string.
name = input("Enter your name: ")
age = int(input("Enter your age: "))
salary = float(input("Enter salary: "))
The input() function always returns a string. Use int(), float(), or other type conversion functions to convert to the desired type.
String concatenation is joining two or more strings together.
Methods:
1. Using + operator: "Hello" + " " + "World"
2. Using join(): " ".join(["Hello", "World"])
3. Using f-strings: f"{first} {last}"
4. Using format(): "{} {}".format("Hello", "World")
The + operator creates a new string since strings are immutable. For joining many strings, join() is more efficient.
String slicing extracts a portion of a string using the syntax string[start:stop:step].
s = "Python Programming"
s[0:6] # "Python"
s[7:] # "Programming"
s[:6] # "Python"
s[-11:] # "Programming"
s[::2] # "Pto rgamn"
s[::-1] # "gnimmargorP nohtyP" (reversed)
start is inclusive, stop is exclusive. Negative indices count from the end.
F-strings (formatted string literals) were introduced in Python 3.6 for embedding expressions inside strings using curly braces {}.
name = "Alice"
age = 25
print(f"My name is {name} and I am {age} years old")
print(f"Next year I will be {age + 1}")
print(f"Pi is approximately {3.14159:.2f}")
F-strings are faster and more readable than .format() or % formatting.
Conditional statements execute different blocks of code based on conditions.
if condition:
# executes if condition is True
elif another_condition:
# executes if first is False, this is True
else:
# executes if all above are False
Example:
score = 85
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
else:
grade = "C"
Python also supports ternary operator: result = "Pass" if score >= 50 else "Fail"
if: Checks the first condition. Mandatory in any conditional block.
elif: Checks additional conditions if previous ones are False. Optional, can have multiple.
else: Executes when all if/elif conditions are False. Optional, maximum one.
Example:
x = 10
if x > 20:
print("Greater than 20")
elif x > 5:
print("Greater than 5") # This executes
else:
print("5 or less")
Conditions are evaluated top to bottom; first True condition's block runs.
A for loop iterates over a sequence (list, tuple, string, range, etc.).
# Iterate over a list
for item in [1, 2, 3]:
print(item)
# Using range
for i in range(5): # 0 to 4
print(i)
for i in range(1, 10, 2): # 1, 3, 5, 7, 9
print(i)
# Iterate over string
for char in "Python":
print(char)
# With enumerate
for index, value in enumerate(["a", "b", "c"]):
print(index, value)
A while loop repeatedly executes a block of code as long as a condition is True.
count = 0
while count < 5:
print(count)
count += 1
# With else clause
while count < 10:
count += 1
else:
print("Loop completed")
Be careful of infinite loops — ensure the condition eventually becomes False. Use break to exit early if needed.
break: Exits the entire loop immediately.
continue: Skips the current iteration and moves to the next one.
# break example
for i in range(10):
if i == 5:
break # stops at 5
print(i) # prints 0,1,2,3,4
# continue example
for i in range(10):
if i % 2 == 0:
continue # skips even numbers
print(i) # prints 1,3,5,7,9
pass is another statement that does nothing — used as a placeholder.
A list is an ordered, mutable collection that can hold elements of different types.
my_list = [1, "hello", 3.14, True]
Key features:
- Ordered (maintains insertion order)
- Mutable (can be changed after creation)
- Allows duplicates
- Indexed (starts at 0)
Common methods: append(), insert(), remove(), pop(), sort(), reverse(), extend(), index(), count(), copy(), clear().
List:
- Mutable (can be modified)
- Uses square brackets []
- Slower performance
- Has more built-in methods
- Example: [1, 2, 3]
Tuple:
- Immutable (cannot be modified after creation)
- Uses parentheses ()
- Faster performance
- Can be used as dictionary keys
- Example: (1, 2, 3)
Use tuples for fixed collections (coordinates, RGB values) and lists when you need to modify the collection.
A set is an unordered collection of unique elements.
s = {1, 2, 3, 4, 5}
s.add(6)
s.discard(3)
Key features:
- Unordered (no indexing)
- No duplicate elements
- Mutable (can add/remove elements)
- Supports mathematical set operations
Set operations:
a = {1, 2, 3}; b = {2, 3, 4}
a | b # Union: {1, 2, 3, 4}
a & b # Intersection: {2, 3}
a - b # Difference: {1}
a ^ b # Symmetric difference: {1, 4}
Several ways to add elements:
lst = [1, 2, 3]
# append() - adds single element at end
lst.append(4) # [1, 2, 3, 4]
# insert() - adds element at specific index
lst.insert(1, 10) # [1, 10, 2, 3, 4]
# extend() - adds all elements from another iterable
lst.extend([5, 6]) # [1, 10, 2, 3, 4, 5, 6]
# + operator - concatenates lists (creates new list)
lst = lst + [7, 8]
# += operator
lst += [9]
List comprehension is a concise way to create lists using a single line.
Syntax: [expression for item in iterable if condition]
Examples:
squares = [x**2 for x in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
evens = [x for x in range(20) if x % 2 == 0]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
words = ["hello", "world"]
upper = [w.upper() for w in words]
# ["HELLO", "WORLD"]
# Nested list comprehension
matrix = [[i*j for j in range(3)] for i in range(3)]
Common dictionary methods:
d = {"name": "Alice", "age": 25}
d.keys() # dict_keys(["name", "age"])
d.values() # dict_values(["Alice", 25])
d.items() # dict_items([("name","Alice"), ("age",25)])
d.get("name") # "Alice" (returns None if key missing)
d.update({"city": "NYC"}) # adds/updates key-value pairs
d.pop("age") # removes and returns value for key
d.setdefault("country", "US") # sets if key missing
d.copy() # returns shallow copy
d.clear() # removes all items
append() adds a single element to the end of the list.
extend() adds all elements from an iterable to the list.
lst = [1, 2, 3]
lst.append([4, 5])
# Result: [1, 2, 3, [4, 5]] — list added as single element
lst = [1, 2, 3]
lst.extend([4, 5])
# Result: [1, 2, 3, 4, 5] — elements added individually
lst.append("hello") # [1,2,3,4,5,"hello"]
lst.extend("hi") # [1,2,3,4,5,"hello","h","i"]
Multiple ways to iterate:
d = {"name": "Alice", "age": 25, "city": "NYC"}
# Iterate over keys (default)
for key in d:
print(key)
# Iterate over values
for value in d.values():
print(value)
# Iterate over key-value pairs
for key, value in d.items():
print(f"{key}: {value}")
# Dictionary comprehension
squared = {x: x**2 for x in range(5)}
# {0:0, 1:1, 2:4, 3:9, 4:16}
A frozenset is an immutable version of a set. Once created, elements cannot be added or removed.
fs = frozenset([1, 2, 3, 4])
# fs.add(5) → AttributeError
Key differences from set:
- Immutable (no add, remove, discard, pop, clear)
- Can be used as dictionary keys or set elements
- Supports set operations (union, intersection, etc.)
- Hashable
Use frozenset when you need an unchangeable set, especially as a dictionary key:
d = {frozenset([1,2]): "pair"}
del: Deletes by index or the entire variable.
remove(): Removes the first occurrence of a value.
pop(): Removes and returns element by index.
lst = [10, 20, 30, 40, 50]
del lst[1] # [10, 30, 40, 50] — removes index 1
lst.remove(30) # [10, 40, 50] — removes value 30
val = lst.pop(1) # val=40, lst=[10, 50] — removes & returns
lst.pop() # removes last element
remove() raises ValueError if value not found.
pop() raises IndexError if index out of range.
A function is a reusable block of code that performs a specific task.
def greet(name):
"""This is a docstring"""
return f"Hello, {name}!"
result = greet("Alice") # "Hello, Alice!"
Key concepts:
- def keyword to define
- Parameters (inputs) and arguments (values passed)
- return statement to send back a value
- Docstrings for documentation
- Functions are first-class objects in Python
Default arguments have predefined values that are used when no argument is passed.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
greet("Alice") # "Hello, Alice!"
greet("Bob", "Hi") # "Hi, Bob!"
Important: Default arguments are evaluated once at function definition, not at each call. Avoid mutable defaults:
# Bad: def func(lst=[])
# Good: def func(lst=None):
# if lst is None: lst = []
The return statement exits a function and sends a value back to the caller.
def add(a, b):
return a + b
result = add(3, 5) # result = 8
Key points:
- A function without return (or with bare return) returns None
- Can return multiple values as a tuple:
def min_max(lst):
return min(lst), max(lst)
lo, hi = min_max([3,1,4,1,5])
- return immediately exits the function
- Can return any data type
*args collects extra positional arguments as a tuple.
**kwargs collects extra keyword arguments as a dictionary.
def func(*args, **kwargs):
print(args) # tuple of positional args
print(kwargs) # dict of keyword args
func(1, 2, 3, name="Alice", age=25)
# args = (1, 2, 3)
# kwargs = {"name": "Alice", "age": 25}
def sum_all(*numbers):
return sum(numbers)
sum_all(1, 2, 3, 4) # 10
Order: def func(regular, *args, **kwargs)
A lambda function is a small anonymous function defined with the lambda keyword. It can have any number of arguments but only one expression.
# Syntax: lambda arguments: expression
square = lambda x: x ** 2
square(5) # 25
add = lambda a, b: a + b
add(3, 4) # 7
# Commonly used with map, filter, sorted
numbers = [3, 1, 4, 1, 5]
sorted(numbers, key=lambda x: -x) # [5, 4, 3, 1, 1]
names = ["Alice", "Bob", "Charlie"]
sorted(names, key=lambda x: len(x)) # ["Bob", "Alice", "Charlie"]
Use the open() function with mode parameter.
# Read entire file
with open("file.txt", "r") as f:
content = f.read()
# Read line by line
with open("file.txt", "r") as f:
for line in f:
print(line.strip())
# Read all lines into a list
with open("file.txt", "r") as f:
lines = f.readlines()
# Write to file
with open("output.txt", "w") as f:
f.write("Hello World")
Modes: r (read), w (write), a (append), rb (read binary), wb (write binary)
The with statement is used for resource management (context managers). It ensures proper acquisition and release of resources.
# File handling — file is automatically closed
with open("file.txt", "r") as f:
data = f.read()
# f is automatically closed here, even if an error occurs
Without with:
f = open("file.txt", "r")
try:
data = f.read()
finally:
f.close()
The with statement calls __enter__() on entry and __exit__() on exit, making code cleaner and safer.
A module is a file containing Python definitions, functions, classes, and variables that can be imported and reused.
# math module
import math
math.sqrt(16) # 4.0
# Specific import
from os import path
path.exists("file.txt")
# Import with alias
import numpy as np
# Create your own module
# mymodule.py
def greet(name):
return f"Hello {name}"
# main.py
from mymodule import greet
Built-in modules: os, sys, math, random, datetime, json, re, collections, itertools
pip is Python's package installer, used to install and manage third-party packages from PyPI (Python Package Index).
Common commands:
pip install package_name # Install a package
pip install package==1.0.0 # Install specific version
pip uninstall package_name # Remove a package
pip list # List installed packages
pip freeze > requirements.txt # Export dependencies
pip install -r requirements.txt # Install from file
pip show package_name # Show package info
pip install --upgrade package # Upgrade a package
pip comes bundled with Python 3.4+ installations.
Module: A single Python file (.py) containing code that can be imported.
Package: A directory containing multiple modules and an __init__.py file.
# Module: math_utils.py
def add(a, b): return a + b
# Package structure:
mypackage/
__init__.py
module1.py
module2.py
subpackage/
__init__.py
module3.py
# Usage:
import mypackage.module1
from mypackage.subpackage import module3
In Python 3.3+, __init__.py is optional (namespace packages), but it is still recommended for clarity.
OOP is a programming paradigm that organizes code into objects — instances of classes that bundle data (attributes) and behavior (methods).
Four pillars of OOP:
1. Encapsulation: Bundling data and methods, hiding internal details
2. Inheritance: Creating new classes from existing ones
3. Polymorphism: Same interface, different implementations
4. Abstraction: Hiding complex implementation, showing only essentials
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
dog = Dog("Buddy")
print(dog.speak()) # "Buddy says Woof!"
Class: A blueprint or template that defines attributes and methods. It is a logical entity.
Object: An instance of a class. It is a physical entity that occupies memory.
class Car: # Class (blueprint)
def __init__(self, brand, color):
self.brand = brand
self.color = color
def drive(self):
return f"{self.brand} is driving"
# Objects (instances)
car1 = Car("Toyota", "Red")
car2 = Car("Honda", "Blue")
print(car1.drive()) # "Toyota is driving"
print(car2.brand) # "Honda"
A class can create unlimited objects. Each object has its own attribute values.
__init__() is the constructor method called automatically when an object is created. It initializes the object's attributes.
class Student:
def __init__(self, name, age, grade):
self.name = name # instance attribute
self.age = age
self.grade = grade
def display(self):
return f"{self.name}, Age: {self.age}, Grade: {self.grade}"
s = Student("Alice", 20, "A")
print(s.display())
Key points:
- First parameter is always self (reference to the instance)
- Called automatically during object creation
- Used to set initial state of the object
- Cannot return a value (returns None implicitly)
self is a reference to the current instance of the class. It is used to access attributes and methods of the class within its methods.
class Person:
def __init__(self, name):
self.name = name # self.name is instance attribute
def greet(self):
return f"Hi, I am {self.name}"
p = Person("Alice")
p.greet() # "Hi, I am Alice"
Key points:
- self is not a keyword, just a convention (could be any name)
- Must be the first parameter of instance methods
- Python passes the instance automatically
- self allows each object to keep track of its own data
Inheritance allows a class (child) to inherit attributes and methods from another class (parent), promoting code reuse.
class Animal:
def __init__(self, name):
self.name = name
def eat(self):
return f"{self.name} is eating"
class Dog(Animal):
def bark(self):
return f"{self.name} says Woof!"
dog = Dog("Buddy")
dog.eat() # Inherited: "Buddy is eating"
dog.bark() # Own method: "Buddy says Woof!"
Use super() to call parent class methods:
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
1. Single Inheritance: Child inherits from one parent
class B(A): pass
2. Multiple Inheritance: Child inherits from multiple parents
class C(A, B): pass
3. Multilevel Inheritance: Chain of inheritance
class C(B): pass # where B(A)
4. Hierarchical Inheritance: Multiple children from one parent
class B(A): pass
class C(A): pass
5. Hybrid Inheritance: Combination of multiple types
Python supports all types. Multiple inheritance uses MRO (Method Resolution Order) to resolve conflicts. Use ClassName.__mro__ or ClassName.mro() to see the order.
Method overriding occurs when a child class provides a different implementation of a method that is already defined in the parent class.
class Shape:
def area(self):
return 0
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self): # Overrides parent method
return 3.14159 * self.radius ** 2
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self): # Overrides parent method
return self.side ** 2
shapes = [Circle(5), Square(4)]
for s in shapes:
print(s.area()) # Polymorphism in action
# 78.54, 16
Use super().method() to call the parent's version.
Encapsulation is the bundling of data and methods within a class, restricting direct access to some components.
Python uses naming conventions for access control:
- public: name (accessible everywhere)
- protected: _name (convention, accessible in class and subclasses)
- private: __name (name mangling, harder to access outside)
class BankAccount:
def __init__(self, balance):
self.__balance = balance # private
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self):
return self.__balance
acc = BankAccount(1000)
# acc.__balance → AttributeError
acc.get_balance() # 1000
# acc._BankAccount__balance # name mangling (still accessible)
Polymorphism means "many forms" — the ability of different objects to respond to the same method call in different ways.
# Method overriding (runtime polymorphism)
class Cat:
def speak(self): return "Meow"
class Dog:
def speak(self): return "Woof"
for animal in [Cat(), Dog()]:
print(animal.speak()) # Different outputs
# Built-in polymorphism
len("hello") # 5 (string)
len([1,2,3]) # 3 (list)
len({"a": 1}) # 1 (dict)
# Operator overloading
"hello" + " world" # string concatenation
[1, 2] + [3, 4] # list concatenation
3 + 4 # arithmetic addition
Duck typing: "If it walks like a duck and quacks like a duck, it is a duck."
Abstraction hides complex implementation details and shows only the essential features.
Python achieves abstraction using abstract base classes (ABC module):
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def connect(self):
pass
@abstractmethod
def query(self, sql):
pass
class MySQL(Database):
def connect(self):
return "Connected to MySQL"
def query(self, sql):
return f"MySQL executing: {sql}"
# db = Database() → TypeError (cannot instantiate abstract class)
db = MySQL()
db.connect() # "Connected to MySQL"
Abstract classes cannot be instantiated. Subclasses must implement all abstract methods.
defaultdict is a dictionary subclass from the collections module that provides a default value for missing keys.
from collections import defaultdict
# Default int (0)
word_count = defaultdict(int)
for word in ["apple", "banana", "apple", "cherry", "banana", "apple"]:
word_count[word] += 1
# {"apple": 3, "banana": 2, "cherry": 1}
# Default list
groups = defaultdict(list)
students = [("A", "Alice"), ("B", "Bob"), ("A", "Charlie")]
for grade, name in students:
groups[grade].append(name)
# {"A": ["Alice", "Charlie"], "B": ["Bob"]}
Unlike regular dict, accessing a missing key creates it with the default value instead of raising KeyError.
OrderedDict is a dictionary subclass from collections that remembers the insertion order of keys.
from collections import OrderedDict
od = OrderedDict()
od["banana"] = 3
od["apple"] = 1
od["cherry"] = 2
# Iteration follows insertion order
for key, value in od.items():
print(key, value) # banana 3, apple 1, cherry 2
# move_to_end method
od.move_to_end("banana") # moves to end
od.move_to_end("cherry", last=False) # moves to beginning
Note: Since Python 3.7, regular dicts also maintain insertion order, but OrderedDict provides additional methods like move_to_end() and considers order in equality comparisons.
namedtuple creates tuple subclasses with named fields, making code more readable.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y) # 3 4
print(p[0], p[1]) # 3 4 (still supports indexing)
Student = namedtuple("Student", "name age grade")
s = Student("Alice", 20, "A")
s.name # "Alice"
# Convert to dict
s._asdict() # {"name": "Alice", "age": 20, "grade": "A"}
# Create from dict
s2 = Student._make(["Bob", 22, "B"])
namedtuples are immutable like regular tuples but with named access.
deque (double-ended queue) from collections provides O(1) append and pop operations from both ends, unlike lists which are O(n) for operations at the beginning.
from collections import deque
dq = deque([1, 2, 3])
dq.append(4) # [1, 2, 3, 4]
dq.appendleft(0) # [0, 1, 2, 3, 4]
dq.pop() # returns 4
dq.popleft() # returns 0
dq.rotate(1) # rotate right
dq.rotate(-1) # rotate left
# Fixed-size deque (auto-removes oldest)
recent = deque(maxlen=3)
recent.append(1) # [1]
recent.append(2) # [1, 2]
recent.append(3) # [1, 2, 3]
recent.append(4) # [2, 3, 4] — 1 is removed
Use cases: queue implementation, sliding window, undo history.
Counter is a dictionary subclass from collections that counts hashable objects.
from collections import Counter
# Count elements
c = Counter(["apple", "banana", "apple", "cherry", "apple", "banana"])
# Counter({"apple": 3, "banana": 2, "cherry": 1})
# Count characters in string
c = Counter("mississippi")
# Counter({"s": 4, "i": 4, "p": 2, "m": 1})
c.most_common(2) # [("s", 4), ("i", 4)]
c.total() # 11 (Python 3.10+)
# Arithmetic operations
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)
c1 + c2 # Counter({"a": 4, "b": 3})
c1 - c2 # Counter({"a": 2})
# Convert to list of elements
list(c.elements()) # repeats each element by count
Exception handling manages runtime errors gracefully using try-except blocks.
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero!")
except (TypeError, ValueError) as e:
print(f"Error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
else:
print("No errors occurred") # runs if no exception
finally:
print("Always executes") # cleanup code
Key components:
- try: Code that might raise an exception
- except: Handles specific exceptions
- else: Runs if no exception occurred
- finally: Always runs (cleanup, closing files/connections)
try-except: Catches and handles specific exceptions.
try-finally: Ensures cleanup code runs regardless of whether an exception occurred.
# try-except
try:
result = int("abc")
except ValueError:
print("Invalid conversion") # This runs
# try-finally
try:
f = open("file.txt")
data = f.read()
finally:
f.close() # Always closes file
# Combined
try:
risky_operation()
except SomeError:
handle_error()
finally:
cleanup() # Runs even if except raises
finally is commonly used for closing database connections, files, network sockets, and releasing locks.
Create custom exception classes by inheriting from Exception or its subclasses.
# Define custom exception
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Cannot withdraw {amount}. Balance: {balance}")
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
# Usage
try:
acc = BankAccount(100)
acc.withdraw(150)
except InsufficientFundsError as e:
print(e) # "Cannot withdraw 150. Balance: 100"
print(e.balance, e.amount) # 100, 150
Syntax Errors: Detected during parsing (before execution). The code cannot run at all.
Exceptions: Occur during execution (runtime). The code is syntactically correct but encounters an error.
# Syntax Error (caught before running)
# if True
# print("Missing colon") → SyntaxError
# Exception (caught during running)
x = 10 / 0 # ZeroDivisionError
int("abc") # ValueError
lst = [1,2,3]
lst[10] # IndexError
d = {}
d["key"] # KeyError
Syntax errors cannot be caught with try-except.
Exceptions can be caught and handled with try-except.
Common exceptions: TypeError, ValueError, KeyError, IndexError, AttributeError, FileNotFoundError, ZeroDivisionError, ImportError.
Python has a hierarchy of built-in exceptions:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── TypeError
├── ValueError
├── AttributeError
├── ImportError
├── FileNotFoundError
├── IOError
├── OSError
├── RuntimeError
├── StopIteration
├── NameError
├── MemoryError
└── RecursionError
Always catch Exception (not BaseException) for general error handling, as BaseException includes SystemExit and KeyboardInterrupt which should not normally be caught.
An iterator is an object that implements __iter__() and __next__() methods, allowing sequential access to elements.
# Creating an iterator
class CountDown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
for num in CountDown(5):
print(num) # 5, 4, 3, 2, 1
# Using built-in iter() and next()
lst = [10, 20, 30]
it = iter(lst)
next(it) # 10
next(it) # 20
next(it) # 30
# next(it) → StopIteration
Iterable: An object that has an __iter__() method and can return an iterator. Examples: list, tuple, string, dict, set, range.
Iterator: An object that has both __iter__() and __next__() methods. It maintains state and knows how to get the next value.
lst = [1, 2, 3] # Iterable (not an iterator)
it = iter(lst) # Iterator
# An iterable can create multiple iterators
it1 = iter(lst)
it2 = iter(lst)
next(it1) # 1
next(it2) # 1 (independent)
# Check types
from collections.abc import Iterable, Iterator
isinstance(lst, Iterable) # True
isinstance(lst, Iterator) # False
isinstance(it, Iterator) # True
Every iterator is an iterable, but not every iterable is an iterator.
A generator is a function that uses yield to produce a sequence of values lazily (one at a time), saving memory.
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
for num in fibonacci(10):
print(num) # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
# Generator expression
squares = (x**2 for x in range(1000000))
# Uses almost no memory vs list comprehension
Advantages:
- Memory efficient (values generated on demand)
- Can represent infinite sequences
- Lazy evaluation
- Simpler code than writing iterator classes
yield pauses a function and returns a value to the caller. The function's state is saved, and execution resumes from where it left off on the next call.
def counter(max):
count = 1
while count <= max:
yield count # pause and return value
count += 1 # resume here on next call
gen = counter(3)
next(gen) # 1
next(gen) # 2
next(gen) # 3
# next(gen) → StopIteration
# yield vs return:
# return: terminates function, returns one value
# yield: pauses function, can return multiple values
# yield from (delegates to sub-generator)
def combined():
yield from range(3) # 0, 1, 2
yield from range(10, 13) # 10, 11, 12
List comprehension creates the entire list in memory at once.
Generator expression produces values one at a time (lazy evaluation).
# List comprehension — uses [] brackets
squares_list = [x**2 for x in range(1000000)] # stores all in memory
# Generator expression — uses () parentheses
squares_gen = (x**2 for x in range(1000000)) # generates on demand
# Memory comparison
import sys
sys.getsizeof(squares_list) # ~8 MB
sys.getsizeof(squares_gen) # ~120 bytes
# Key differences:
# List: random access (lst[5]), reusable, more memory
# Generator: sequential access only, single-use, minimal memory
# Use generators for large datasets or when you only need to iterate once
sum(x**2 for x in range(1000000)) # efficient
Python strings have many built-in methods:
s = " Hello, World! "
# Case methods
s.upper() # " HELLO, WORLD! "
s.lower() # " hello, world! "
s.title() # " Hello, World! "
s.capitalize() # " hello, world! "
s.swapcase() # " hELLO, wORLD! "
# Search methods
s.find("World") # 9 (index, -1 if not found)
s.index("World") # 9 (raises ValueError if not found)
s.count("l") # 3
s.startswith(" He") # True
s.endswith("! ") # True
# Modification methods
s.strip() # "Hello, World!" (removes whitespace)
s.replace("World", "Python")
s.split(",") # [" Hello", " World! "]
", ".join(["a","b","c"]) # "a, b, c"
# Check methods
"hello".isalpha() # True
"123".isdigit() # True
"hello123".isalnum() # True
Multiple ways to reverse a string:
s = "Hello World"
# Method 1: Slicing (most Pythonic)
reversed_s = s[::-1] # "dlroW olleH"
# Method 2: reversed() + join()
reversed_s = "".join(reversed(s))
# Method 3: Loop
reversed_s = ""
for char in s:
reversed_s = char + reversed_s
# Method 4: List reverse
lst = list(s)
lst.reverse()
reversed_s = "".join(lst)
# Method 5: Recursion
def reverse(s):
if len(s) <= 1:
return s
return reverse(s[1:]) + s[0]
# Reverse words (not characters)
" ".join(s.split()[::-1]) # "World Hello"
The re module provides support for regular expressions (pattern matching in strings).
import re
text = "Contact: alice@email.com or bob@gmail.com"
# search() — finds first match
match = re.search(r"\w+@\w+\.\w+", text)
match.group() # "alice@email.com"
# findall() — finds all matches
emails = re.findall(r"\w+@\w+\.\w+", text)
# ["alice@email.com", "bob@gmail.com"]
# sub() — replace matches
cleaned = re.sub(r"\d+", "NUM", "Order 123, Item 456")
# "Order NUM, Item NUM"
# split() — split by pattern
re.split(r"[,;]\s*", "a, b; c, d")
# ["a", "b", "c", "d"]
# compile() — precompile pattern for reuse
pattern = re.compile(r"\d{3}-\d{4}")
pattern.findall("Call 123-4567 or 987-6543")
Common regex patterns and methods:
import re
# Special characters
# . — any character except newline
# \d — digit [0-9]
# \w — word character [a-zA-Z0-9_]
# \s — whitespace
# ^ — start of string
# $ — end of string
# * — 0 or more
# + — 1 or more
# ? — 0 or 1
# {n} — exactly n times
# [] — character class
# () — capturing group
# Examples
phone = re.findall(r"\d{3}-\d{3}-\d{4}", "Call 123-456-7890")
words = re.findall(r"\b\w{5}\b", "Hello World Python") # 5-letter words
urls = re.findall(r"https?://\S+", text)
# Groups
match = re.search(r"(\w+)@(\w+)\.(\w+)", "alice@gmail.com")
match.group(1) # "alice"
match.group(2) # "gmail"
match.groups() # ("alice", "gmail", "com")
re.match() checks for a match only at the beginning of the string.
re.search() scans the entire string for a match.
import re
text = "Hello World 123"
# match() — only checks beginning
result = re.match(r"\d+", text)
print(result) # None (no digits at start)
result = re.match(r"Hello", text)
print(result.group()) # "Hello"
# search() — scans entire string
result = re.search(r"\d+", text)
print(result.group()) # "123" (found anywhere)
# fullmatch() — must match entire string
re.fullmatch(r"\d+", "123") # Match
re.fullmatch(r"\d+", "123abc") # None
Use match() when you expect pattern at the start.
Use search() when pattern can be anywhere.
Use fullmatch() for complete string validation.
map() applies a function to every item in an iterable and returns a map object (iterator).
# Syntax: map(function, iterable)
numbers = [1, 2, 3, 4, 5]
# Square each number
squared = list(map(lambda x: x**2, numbers))
# [1, 4, 9, 16, 25]
# Convert strings to integers
str_nums = ["1", "2", "3", "4"]
int_nums = list(map(int, str_nums))
# [1, 2, 3, 4]
# With multiple iterables
a = [1, 2, 3]
b = [10, 20, 30]
sums = list(map(lambda x, y: x + y, a, b))
# [11, 22, 33]
# Equivalent list comprehension
squared = [x**2 for x in numbers]
filter() creates an iterator from elements of an iterable for which a function returns True.
# Syntax: filter(function, iterable)
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Filter even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
# [2, 4, 6, 8, 10]
# Filter non-empty strings
words = ["hello", "", "world", "", "python"]
non_empty = list(filter(None, words))
# ["hello", "world", "python"]
# Filter with named function
def is_positive(n):
return n > 0
positives = list(filter(is_positive, [-2, -1, 0, 1, 2]))
# [1, 2]
# Equivalent list comprehension
evens = [x for x in numbers if x % 2 == 0]
reduce() from functools applies a function cumulatively to items of a sequence, reducing it to a single value.
from functools import reduce
# Sum of all numbers
numbers = [1, 2, 3, 4, 5]
total = reduce(lambda a, b: a + b, numbers)
# ((((1+2)+3)+4)+5) = 15
# Find maximum
maximum = reduce(lambda a, b: a if a > b else b, numbers)
# 5
# String concatenation
words = ["Hello", " ", "World"]
sentence = reduce(lambda a, b: a + b, words)
# "Hello World"
# With initial value
product = reduce(lambda a, b: a * b, numbers, 1)
# 1*1*2*3*4*5 = 120
# Flatten list of lists
nested = [[1,2], [3,4], [5,6]]
flat = reduce(lambda a, b: a + b, nested)
# [1, 2, 3, 4, 5, 6]
A closure is a function that remembers and has access to variables from its enclosing scope even after the outer function has finished executing.
def multiplier(factor):
def multiply(number):
return number * factor # accesses factor from outer scope
return multiply
double = multiplier(2)
triple = multiplier(3)
double(5) # 10
triple(5) # 15
# Closure conditions:
# 1. Nested function
# 2. Inner function references variables from outer function
# 3. Outer function returns the inner function
# Practical use: data hiding
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c = counter()
c() # 1
c() # 2
c() # 3
Higher-order functions either take functions as arguments or return functions as results.
# Function as argument
def apply(func, value):
return func(value)
apply(str.upper, "hello") # "HELLO"
apply(len, [1, 2, 3]) # 3
# Function returning function
def power(exp):
def compute(base):
return base ** exp
return compute
square = power(2)
cube = power(3)
square(5) # 25
cube(3) # 27
# Built-in higher-order functions:
# map(), filter(), reduce(), sorted(key=), min(key=), max(key=)
# Decorators are higher-order functions
def log(func):
def wrapper(*args):
print(f"Calling {func.__name__}")
return func(*args)
return wrapper
LEGB rule defines the order Python looks up variable names:
L — Local: Variables inside the current function
E — Enclosing: Variables in the enclosing function (for nested functions)
G — Global: Variables at the module level
B — Built-in: Names in the built-in module (print, len, etc.)
x = "global" # Global scope
def outer():
x = "enclosing" # Enclosing scope
def inner():
x = "local" # Local scope
print(x) # "local"
inner()
print(x) # "enclosing"
outer()
print(x) # "global"
Python searches L → E → G → B in order. If not found in any, raises NameError.
The global keyword allows a function to modify a variable defined at the module (global) level.
count = 0 # global variable
def increment():
global count
count += 1
increment()
print(count) # 1
increment()
print(count) # 2
Without global:
count = 0
def increment():
count += 1 # UnboundLocalError!
# Note: You can READ global variables without global keyword,
# but need it to MODIFY them.
x = 10
def read_global():
print(x) # Works fine — reading
def modify_global():
global x
x = 20 # Now modifies the global x
Best practice: Minimize use of global variables. Pass values as arguments and return results instead.
nonlocal allows a nested function to modify a variable from its enclosing (non-global) scope.
def outer():
count = 0
def inner():
nonlocal count
count += 1
return count
return inner
counter = outer()
counter() # 1
counter() # 2
counter() # 3
# Without nonlocal:
def outer():
count = 0
def inner():
count += 1 # UnboundLocalError!
inner()
# Difference from global:
# global — refers to module-level variable
# nonlocal — refers to enclosing function's variable
# nonlocal is essential for closures that need to modify
# the enclosed state.
Python uses automatic memory management with several mechanisms:
1. Reference Counting: Each object has a counter tracking references to it. When count reaches 0, memory is freed.
import sys
a = [1, 2, 3]
sys.getrefcount(a) # Shows reference count
2. Garbage Collection: Handles circular references that reference counting cannot resolve.
import gc
gc.collect() # Manual garbage collection
3. Memory Pool (PyMalloc): Python pre-allocates small memory blocks for efficiency.
4. Object Caching: Small integers (-5 to 256) and short strings are cached and reused.
a = 256
b = 256
a is b # True (cached)
a = 257
b = 257
a is b # False (not cached)
5. Everything in Python is an object stored on the heap. Variables are references (pointers) to objects.
Garbage collection automatically reclaims memory occupied by objects that are no longer referenced.
Python uses two strategies:
1. Reference Counting (primary): Frees memory when reference count hits 0
2. Generational Garbage Collector (secondary): Detects and collects circular references
import gc
# Circular reference example
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b
b.ref = a # Circular reference
del a, b # Reference counting cannot free these
gc.collect() # GC detects and frees circular references
# GC has 3 generations (0, 1, 2)
gc.get_threshold() # (700, 10, 10) — collection thresholds
# Disable/enable GC
gc.disable()
gc.enable()
# gc.get_count() — objects in each generation
# gc.get_objects() — all tracked objects
Shallow copy creates a new object but references the same nested objects.
Deep copy creates a new object and recursively copies all nested objects.
import copy
original = [[1, 2], [3, 4], [5, 6]]
# Shallow copy
shallow = copy.copy(original)
shallow[0][0] = 99
print(original[0][0]) # 99 — inner list was shared!
# Deep copy
original = [[1, 2], [3, 4], [5, 6]]
deep = copy.deepcopy(original)
deep[0][0] = 99
print(original[0][0]) # 1 — completely independent
# Other shallow copy methods:
lst2 = lst[:] # slicing
lst2 = list(lst) # constructor
lst2 = lst.copy() # copy method
d2 = dict(d) # for dicts
Use deepcopy when objects contain nested mutable objects (lists of lists, dicts of lists, etc.).
Mutable: Objects whose value can be changed after creation.
Immutable: Objects whose value cannot be changed after creation.
Mutable types: list, dict, set, bytearray, user-defined classes
Immutable types: int, float, str, tuple, frozenset, bool, bytes
# Immutable example
s = "hello"
# s[0] = "H" → TypeError
s = "Hello" # Creates NEW string, old one is garbage collected
# Mutable example
lst = [1, 2, 3]
lst[0] = 10 # Modifies in place: [10, 2, 3]
# Why it matters:
# 1. Immutable objects can be dictionary keys
# 2. Immutable objects are thread-safe
# 3. Passing mutable objects to functions can cause side effects
def modify(lst):
lst.append(4) # Modifies original!
my_list = [1, 2, 3]
modify(my_list)
print(my_list) # [1, 2, 3, 4]
enumerate() adds a counter to an iterable, returning index-value pairs.
fruits = ["apple", "banana", "cherry"]
# Without enumerate
for i in range(len(fruits)):
print(i, fruits[i])
# With enumerate (Pythonic way)
for index, fruit in enumerate(fruits):
print(index, fruit)
# 0 apple
# 1 banana
# 2 cherry
# Custom start index
for i, fruit in enumerate(fruits, start=1):
print(i, fruit)
# 1 apple
# 2 banana
# 3 cherry
# Convert to list of tuples
list(enumerate(fruits))
# [(0, "apple"), (1, "banana"), (2, "cherry")]
# Useful for creating dictionaries
d = {i: v for i, v in enumerate(fruits)}
zip() combines multiple iterables element-wise into tuples.
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
cities = ["NYC", "LA", "Chicago"]
# Basic zip
paired = list(zip(names, ages))
# [("Alice", 25), ("Bob", 30), ("Charlie", 35)]
# Zip three iterables
combined = list(zip(names, ages, cities))
# [("Alice", 25, "NYC"), ("Bob", 30, "LA"), ("Charlie", 35, "Chicago")]
# Create dictionary from two lists
name_age = dict(zip(names, ages))
# {"Alice": 25, "Bob": 30, "Charlie": 35}
# Unzip (using *)
pairs = [("a", 1), ("b", 2), ("c", 3)]
letters, numbers = zip(*pairs)
# letters = ("a", "b", "c")
# numbers = (1, 2, 3)
# zip stops at shortest iterable
# Use itertools.zip_longest for padding
Decorators are functions that modify the behavior of other functions without changing their code. They use the @decorator syntax.
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end-start:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "Done"
slow_function() # prints: slow_function took 1.00xxs
# Decorator with arguments
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
# Common built-in decorators: @property, @staticmethod, @classmethod, @abstractmethod
A metaclass is the "class of a class" — it defines how classes behave. Just as classes create objects, metaclasses create classes.
# type is the default metaclass
class MyClass:
pass
print(type(MyClass)) # <class 'type'>
# Custom metaclass
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self):
self.connection = "connected"
db1 = Database()
db2 = Database()
print(db1 is db2) # True — same instance
# Metaclass use cases:
# - Singleton pattern
# - Class registration
# - Attribute validation
# - ORM frameworks (Django models)
# - API enforcement
MRO defines the order in which base classes are searched when looking for a method. Python uses the C3 linearization algorithm.
class A:
def method(self): return "A"
class B(A):
def method(self): return "B"
class C(A):
def method(self): return "C"
class D(B, C):
pass
print(D.mro())
# [D, B, C, A, object]
d = D()
d.method() # "B" — found in B first
# The C3 linearization ensures:
# 1. Children come before parents
# 2. Order of parents is preserved
# 3. Consistent ordering across the hierarchy
# Diamond problem resolution
class A:
def greet(self): return "A"
class B(A):
def greet(self): return "B"
class C(A):
def greet(self): return "C"
class D(B, C):
pass
D().greet() # "B" — MRO: D → B → C → A → object
# Use super() to follow MRO correctly in cooperative multiple inheritance
Dunder (double underscore) or magic methods are special methods with __ prefix and suffix that Python calls implicitly.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self): # Developer representation
return f"Vector({self.x}, {self.y})"
def __str__(self): # User-friendly string
return f"({self.x}, {self.y})"
def __add__(self, other): # + operator
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other): # == operator
return self.x == other.x and self.y == other.y
def __len__(self): # len() function
return int((self.x**2 + self.y**2)**0.5)
def __getitem__(self, key): # indexing []
return [self.x, self.y][key]
v1 = Vector(3, 4)
v2 = Vector(1, 2)
v3 = v1 + v2 # __add__: Vector(4, 6)
print(v1) # __str__: (3, 4)
len(v1) # __len__: 5
Key magic methods: __init__, __str__, __repr__, __add__, __sub__, __mul__, __eq__, __lt__, __gt__, __len__, __getitem__, __setitem__, __contains__, __iter__, __next__, __enter__, __exit__, __call__, __hash__
__new__ creates the instance (allocates memory). __init__ initializes the instance (sets attributes).
class MyClass:
def __new__(cls, *args, **kwargs):
print("__new__ called — creating instance")
instance = super().__new__(cls)
return instance
def __init__(self, value):
print("__init__ called — initializing")
self.value = value
obj = MyClass(42)
# __new__ called — creating instance
# __init__ called — initializing
# __new__ use cases:
# 1. Singleton pattern
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# 2. Immutable types (can't modify in __init__)
class UpperStr(str):
def __new__(cls, value):
return super().__new__(cls, value.upper())
s = UpperStr("hello") # "HELLO"
# Key differences:
# __new__ is a static method, __init__ is an instance method
# __new__ must return an instance, __init__ returns None
# __new__ runs before __init__
Descriptors are objects that define __get__, __set__, or __delete__ methods to customize attribute access.
class Validator:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return getattr(obj, f"_{self.name}", None)
def __set__(self, obj, value):
if not self.min_val <= value <= self.max_val:
raise ValueError(f"{self.name} must be between {self.min_val} and {self.max_val}")
setattr(obj, f"_{self.name}", value)
class Student:
age = Validator(0, 150)
grade = Validator(0, 100)
def __init__(self, name, age, grade):
self.name = name
self.age = age
self.grade = grade
s = Student("Alice", 20, 95)
# s.age = 200 → ValueError
# property, classmethod, staticmethod are all implemented using descriptors.
# Data descriptors (define __set__) take priority over instance __dict__.
# Non-data descriptors (only __get__) can be overridden by instance attributes.
The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time, even on multi-core systems.
Impact:
- CPU-bound tasks do NOT benefit from multithreading
- I/O-bound tasks CAN benefit from multithreading (GIL is released during I/O)
# CPU-bound — use multiprocessing
from multiprocessing import Pool
def cpu_task(n):
return sum(range(n))
with Pool(4) as pool:
results = pool.map(cpu_task, [10**7]*4)
# I/O-bound — use threading
import threading
def io_task(url):
# GIL released during network I/O
response = requests.get(url)
threads = [threading.Thread(target=io_task, args=(url,)) for url in urls]
# Workarounds for GIL:
# 1. multiprocessing module (separate processes)
# 2. C extensions that release GIL
# 3. Alternative interpreters (PyPy, Jython)
# 4. asyncio for I/O-bound tasks
# 5. Python 3.13+ has experimental free-threading mode
Multithreading: Multiple threads within ONE process, sharing the same memory.
Multiprocessing: Multiple processes, each with its OWN memory space.
import threading
import multiprocessing
# Multithreading — best for I/O-bound tasks
def download(url):
pass # network I/O
threads = [threading.Thread(target=download, args=(url,)) for url in urls]
for t in threads: t.start()
for t in threads: t.join()
# Multiprocessing — best for CPU-bound tasks
def compute(data):
return heavy_calculation(data)
with multiprocessing.Pool(4) as pool:
results = pool.map(compute, data_chunks)
Key Differences:
| Feature | Threading | Multiprocessing |
|----------------|-----------------|------------------|
| Memory | Shared | Separate |
| GIL impact | Yes (limited) | No (true parallel)|
| Overhead | Low | High |
| Best for | I/O-bound | CPU-bound |
| Communication | Easy (shared) | IPC required |
| Crash impact | Affects process | Isolated |
asyncio is Python's built-in library for writing concurrent code using async/await syntax, ideal for I/O-bound operations.
import asyncio
async def fetch_data(url, delay):
print(f"Fetching {url}...")
await asyncio.sleep(delay) # Non-blocking wait
return f"Data from {url}"
async def main():
# Run concurrently
tasks = [
fetch_data("api1.com", 2),
fetch_data("api2.com", 1),
fetch_data("api3.com", 3),
]
results = await asyncio.gather(*tasks)
# All complete in ~3 seconds (not 6)
print(results)
asyncio.run(main())
Key concepts:
- async def: defines a coroutine
- await: pauses execution until result is ready
- asyncio.gather(): runs coroutines concurrently
- asyncio.create_task(): schedules coroutine execution
- Event loop: manages and runs async tasks
Use asyncio for: HTTP requests, database queries, file I/O, WebSocket connections — any I/O-bound task.
Coroutines are functions that can be paused and resumed, allowing cooperative multitasking.
# Async coroutine (modern Python 3.5+)
async def greet(name):
await asyncio.sleep(1)
return f"Hello, {name}!"
# Generator-based coroutine (legacy)
def old_style_coroutine():
while True:
value = yield
print(f"Received: {value}")
# Using async coroutines
async def main():
result = await greet("Alice")
print(result)
asyncio.run(main())
# Coroutine lifecycle:
# 1. Created: coro = greet("Alice") — not yet running
# 2. Running: await coro — executing
# 3. Suspended: at await point — paused
# 4. Completed: returned a value
# async generators
async def async_range(n):
for i in range(n):
await asyncio.sleep(0.1)
yield i
async def main():
async for num in async_range(5):
print(num)
Concurrency: Managing multiple tasks that can make progress by switching between them. Tasks may not run simultaneously.
Parallelism: Actually executing multiple tasks at the same time on multiple CPU cores.
Analogy:
- Concurrency: One chef juggling multiple dishes
- Parallelism: Multiple chefs each cooking a dish
Python implementations:
# Concurrency (asyncio) — single thread, cooperative switching
async def task1():
await asyncio.sleep(1)
async def task2():
await asyncio.sleep(1)
# Both complete in ~1 second
# Concurrency (threading) — multiple threads, OS switching
# Limited by GIL for CPU tasks
# Parallelism (multiprocessing) — multiple processes, true parallel
from multiprocessing import Pool
with Pool(4) as p:
results = p.map(cpu_task, data) # Runs on 4 cores
| Aspect | Concurrency | Parallelism |
|------------|---------------------|-----------------------|
| Execution | Interleaved | Simultaneous |
| CPU cores | Can use 1 | Uses multiple |
| Python | asyncio, threading | multiprocessing |
| Best for | I/O-bound | CPU-bound |
Context managers handle resource setup and cleanup using the with statement.
# Method 1: Class-based
class FileManager:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
return False # Don't suppress exceptions
with FileManager("test.txt", "w") as f:
f.write("Hello")
# Method 2: Using contextlib
from contextlib import contextmanager
@contextmanager
def timer():
import time
start = time.time()
yield # code inside 'with' runs here
end = time.time()
print(f"Elapsed: {end - start:.2f}s")
with timer():
# some operation
pass
# __exit__ receives exception info: return True to suppress the exception.
Monkey patching is dynamically modifying or extending a class or module at runtime.
# Original class
class Calculator:
def add(self, a, b):
return a + b
# Monkey patch — add new method at runtime
def multiply(self, a, b):
return a * b
Calculator.multiply = multiply
calc = Calculator()
calc.multiply(3, 4) # 12
# Monkey patch existing method
original_add = Calculator.add
def logged_add(self, a, b):
print(f"Adding {a} + {b}")
return original_add(self, a, b)
Calculator.add = logged_add
calc.add(2, 3) # prints "Adding 2 + 3", returns 5
# Common use cases:
# - Testing (mocking dependencies)
# - Fixing third-party library bugs
# - Adding functionality without subclassing
# Risks:
# - Makes code harder to understand
# - Can break if the patched library updates
# - Not thread-safe by default
__getattribute__: Called for EVERY attribute access, even if the attribute exists.
__getattr__: Called ONLY when normal attribute lookup fails (attribute not found).
class MyClass:
def __init__(self):
self.name = "Alice"
def __getattribute__(self, name):
print(f"Accessing: {name}")
return super().__getattribute__(name)
def __getattr__(self, name):
print(f"Not found: {name}")
return f"Default value for {name}"
obj = MyClass()
obj.name # "Accessing: name" → "Alice"
obj.missing # "Accessing: missing" → "Not found: missing" → "Default value for missing"
# __getattribute__ is called first for every access
# If it raises AttributeError, __getattr__ is called
# Use cases:
# __getattribute__: logging, proxying, lazy loading
# __getattr__: providing default values, dynamic attributes
# Warning: Infinite recursion in __getattribute__
# Always use super().__getattribute__(name) inside it
__slots__ restricts instance attributes to a fixed set, saving memory by not creating __dict__ for each instance.
# Without __slots__ — each instance has a __dict__
class PointRegular:
def __init__(self, x, y):
self.x = x
self.y = y
# With __slots__ — no __dict__, fixed attributes
class PointSlots:
__slots__ = ["x", "y"]
def __init__(self, x, y):
self.x = x
self.y = y
p = PointSlots(1, 2)
# p.z = 3 → AttributeError (not in __slots__)
# Memory comparison (for millions of instances)
import sys
regular = PointRegular(1, 2)
slotted = PointSlots(1, 2)
sys.getsizeof(regular.__dict__) # ~104 bytes per instance
# PointSlots has no __dict__
# Benefits:
# - 40-50% less memory per instance
# - Slightly faster attribute access
# - Prevents accidental attribute creation
# Limitations:
# - Cannot add new attributes dynamically
# - Cannot use __dict__
# - Subclasses must also define __slots__
Memoization is an optimization technique that caches function results for previously computed inputs to avoid redundant calculations.
# Method 1: Manual dictionary cache
def fibonacci(n, cache={}):
if n in cache:
return cache[n]
if n <= 1:
return n
cache[n] = fibonacci(n-1) + fibonacci(n-2)
return cache[n]
fibonacci(100) # Instant (without memoization: impossibly slow)
# Method 2: functools.lru_cache (recommended)
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
fibonacci(100) # Instant
fibonacci.cache_info() # hits, misses, maxsize, currsize
fibonacci.cache_clear() # clear cache
# Method 3: functools.cache (Python 3.9+, unlimited cache)
from functools import cache
@cache
def expensive_computation(x, y):
return x ** y
# Best for: recursive problems, expensive computations, pure functions (same input → same output)
__call__ makes an instance callable like a function.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
double(5) # 10 — instance called like a function
triple(5) # 15
# Practical use: function with state
class RunningAverage:
def __init__(self):
self.values = []
def __call__(self, value):
self.values.append(value)
return sum(self.values) / len(self.values)
avg = RunningAverage()
avg(10) # 10.0
avg(20) # 15.0
avg(30) # 20.0
# Check if callable
callable(double) # True
callable(42) # False
# Use cases: decorators as classes, strategy pattern, callable configuration objects
The walrus operator (:=), introduced in Python 3.8, assigns values to variables as part of an expression.
# Without walrus operator
n = len(data)
if n > 10:
print(f"Too long: {n}")
# With walrus operator
if (n := len(data)) > 10:
print(f"Too long: {n}")
# In while loops
while (line := input("Enter: ")) != "quit":
print(f"You said: {line}")
# In list comprehensions
results = [y for x in data if (y := expensive_func(x)) is not None]
# In regex
import re
if (match := re.search(r"\d+", text)):
print(match.group())
# Reading file chunks
while (chunk := f.read(8192)):
process(chunk)
# Key rule: := cannot be used in top-level assignments
# x := 5 → SyntaxError
# Must be inside an expression: (x := 5)
Type hints (PEP 484) provide optional static type information for variables, function parameters, and return values.
# Variable annotations
name: str = "Alice"
age: int = 25
scores: list[int] = [90, 85, 92]
data: dict[str, int] = {"a": 1, "b": 2}
# Function annotations
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
# Complex types
from typing import Optional, Union, Tuple, Callable
def find(lst: list[int], target: int) -> Optional[int]:
"""Returns index or None"""
try:
return lst.index(target)
except ValueError:
return None
# Callable type
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
# Type aliases
Vector = list[float]
Matrix = list[Vector]
# Type hints are NOT enforced at runtime — use mypy for static checking
# mypy script.py
@staticmethod: Does not receive the class or instance as the first argument. It is like a regular function that belongs to the class namespace.
@classmethod: Receives the class (cls) as the first argument. Can access and modify class-level state.
class Employee:
raise_pct = 1.05 # class variable
def __init__(self, name, salary):
self.name = name
self.salary = salary
# Instance method — accesses instance (self)
def apply_raise(self):
self.salary *= self.raise_pct
# Class method — accesses class (cls)
@classmethod
def set_raise_pct(cls, pct):
cls.raise_pct = pct
# Class method as alternative constructor
@classmethod
def from_string(cls, emp_str):
name, salary = emp_str.split("-")
return cls(name, int(salary))
# Static method — no access to class or instance
@staticmethod
def is_workday(day):
return day.weekday() < 5
emp = Employee.from_string("Alice-50000")
Employee.set_raise_pct(1.10)
Employee.is_workday(datetime.date.today())
__mro__ (Method Resolution Order) is a tuple showing the order in which Python searches for methods in a class hierarchy.
class A:
def who(self): return "A"
class B(A):
pass
class C(A):
def who(self): return "C"
class D(B, C):
pass
print(D.__mro__)
# (<class D>, <class B>, <class C>, <class A>, <class object>)
D().who() # "C" — B doesn't have who(), so C is checked next
# C3 Linearization rules:
# 1. The class itself comes first
# 2. Then parents in the order specified
# 3. Parents of parents follow same rules
# 4. A class appears only once
# 5. object is always last
# super() follows MRO
class A:
def method(self):
print("A")
class B(A):
def method(self):
print("B")
super().method() # calls C.method (not A.method!)
class C(A):
def method(self):
print("C")
super().method() # calls A.method
class D(B, C):
def method(self):
print("D")
super().method() # calls B.method
D().method() # D → B → C → A (follows MRO)
The Singleton pattern ensures a class has only one instance and provides a global access point to it.
# Method 1: Using __new__
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) # True
# Method 2: Using metaclass
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self):
self.connection = "active"
# Method 3: Module-level (simplest — Python modules are singletons)
# config.py
# settings = {"debug": True, "db": "postgres"}
# Use cases: database connections, logging, configuration, caches
The Factory pattern creates objects without exposing the creation logic, using a common interface.
from abc import ABC, abstractmethod
# Product interface
class Notification(ABC):
@abstractmethod
def send(self, message): pass
# Concrete products
class EmailNotification(Notification):
def send(self, message):
return f"Email: {message}"
class SMSNotification(Notification):
def send(self, message):
return f"SMS: {message}"
class PushNotification(Notification):
def send(self, message):
return f"Push: {message}"
# Factory
class NotificationFactory:
@staticmethod
def create(channel: str) -> Notification:
factories = {
"email": EmailNotification,
"sms": SMSNotification,
"push": PushNotification,
}
if channel not in factories:
raise ValueError(f"Unknown channel: {channel}")
return factories[channel]()
# Usage
notifier = NotificationFactory.create("email")
notifier.send("Hello!") # "Email: Hello!"
# Benefits: decouples creation from usage, easy to add new types, follows Open/Closed Principle
Dataclasses (Python 3.7+) automatically generate __init__, __repr__, __eq__, and other methods for classes that primarily store data.
from dataclasses import dataclass, field
@dataclass
class Student:
name: str
age: int
grade: str = "A"
courses: list = field(default_factory=list)
s = Student("Alice", 20)
print(s) # Student(name='Alice', age=20, grade='A', courses=[])
s1 = Student("Alice", 20)
s2 = Student("Alice", 20)
s1 == s2 # True (auto-generated __eq__)
# Frozen (immutable) dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(3.0, 4.0)
# p.x = 5.0 → FrozenInstanceError
# With ordering
@dataclass(order=True)
class Priority:
level: int
task: str
# Post-init processing
@dataclass
class Circle:
radius: float
area: float = field(init=False)
def __post_init__(self):
self.area = 3.14159 * self.radius ** 2
# Dataclasses vs namedtuple: dataclasses are mutable by default, support default values, inheritance, and methods.
Duck typing is a concept where the type or class of an object is less important than the methods it defines. "If it walks like a duck and quacks like a duck, it is a duck."
class Duck:
def quack(self): return "Quack!"
def walk(self): return "Walking like a duck"
class Person:
def quack(self): return "I can quack too!"
def walk(self): return "Walking like a person"
def make_it_quack(thing):
# No type checking — just calls the method
print(thing.quack())
make_it_quack(Duck()) # "Quack!"
make_it_quack(Person()) # "I can quack too!"
# Real-world example: iteration protocol
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
return self
def __next__(self):
if self.start <= 0:
raise StopIteration
self.start -= 1
return self.start + 1
# Works with for loop because it "walks like an iterable"
for n in Countdown(3):
print(n) # 3, 2, 1
# Python relies on duck typing extensively — len(), iter(), next(), str() all work based on whether the object implements the required dunder methods, not its type.
ABCs define interfaces that subclasses must implement. They cannot be instantiated directly.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
"""Must be implemented by subclasses"""
pass
@abstractmethod
def perimeter(self) -> float:
pass
def description(self): # Concrete method (optional to override)
return f"Shape with area {self.area():.2f}"
# shape = Shape() → TypeError (cannot instantiate abstract class)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
c = Circle(5)
c.area() # 78.54
c.description() # "Shape with area 78.54"
# Register virtual subclass (structural typing)
class MyList:
def __len__(self): return 0
def __getitem__(self, i): return None
from collections.abc import Sequence
Sequence.register(MyList)
isinstance(MyList(), Sequence) # True
# collections.abc provides ABCs: Iterable, Iterator, Sequence, Mapping, MutableMapping, Callable, etc.
