Data Science + GenAI
Data Analytics + GenAI
Programs from ₹5,000
New Batch Starts: Coming Soon

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.