Unit - 2
Functions
1. Functions
1.1 Introduction to Functions
- A function is a named object that encapsulates a block of executable code.
- In Python, functions are first-class objects stored in memory like any other object.
- Internally, a function consists of:
- A code object (compiled bytecode)
- A global namespace reference
- A default argument table
- A closure (if any)
- Metadata (name, docstring, annotations)
Mental model (important):
A function is not code, it is an object that owns code.
def f():
pass
This creates:
- A function object in heap memory
- A name
fin the current namespace pointing to that object
Function Object Anatomy (Deep Internals)
Every function object contains:
__code__→ compiled bytecode + metadata__globals__→ reference to global symbol table__defaults__→ tuple of default parameter values__closure__→ captured outer variables__dict__→ function attributes__name__,__doc__
def demo(a=10):
"""sample"""
return a
print(demo.__code__)
print(demo.__defaults__)
print(demo.__globals__ is globals())
1.2 Defining and Using Functions
1.2.1 Function Definition
- The
defstatement is executed at runtime. - When Python encounters
def:- The function body is compiled into bytecode
- A function object is created
- The function name is registered in the current namespace
def add(x, y):
return x + y
Mind-bender #1: def executes immediately
print("before")
def f():
print("inside")
print("after")
The function body is not executed, but the function object is created immediately.
Namespace Registration (Registry Concept)
- Python stores names in symbol tables (registries):
- Local
- Enclosing
- Global
- Built-in (LEGB rule)
def test():
pass
print("test" in globals())
The function name is registered in the global registry.
1.2.2 Function Call
Calling a function triggers a full execution pipeline:
- Create a stack frame
- Allocate local namespace (dictionary)
- Bind arguments → parameters
- Execute bytecode
- Return value
- Destroy frame (or reuse via optimization)
result = add(2, 3)
Call Stack & Frame Objects (Very Important)
Each function call creates a frame object:
- Stored on the call stack
- Contains:
- Local variables
- Instruction pointer
- Operand stack
- Reference to code object
import inspect
def f():
frame = inspect.currentframe()
print(frame.f_locals)
f()
Function Call Flow
1.2.3 Parameters and Arguments
- Parameters → local names in function definition
- Arguments → object references passed during call
- Python uses call-by-object-reference
def modify(x):
x += 1
a = 10
modify(a)
print(a)
a does not change because integers are immutable.
Mutability Trap (Critical)
def modify(lst):
lst.append(99)
data = [1, 2]
modify(data)
print(data)
The list changes because:
- Reference is shared
- Object is mutable
Argument Binding Order (Internal)
- Positional arguments
- Keyword arguments
- Default arguments
args*kwargs
Violation raises TypeError.
1.2.4 Return Statement
return:- Terminates function execution
- Sends a reference back to caller
- If omitted → implicit
return None
def f():
return
print(f())
Multiple Returns (Illusion)
def calc(a, b):
return a+b, a-b
Python returns one tuple object, not multiple values.
Return & Frame Cleanup
- On return:
- Frame is popped
- Local namespace is destroyed
- Reference counts updated
- Returned object lives if referenced.
1.2.5 Function Docstring
- A docstring is a runtime-stored string literal
- Stored in
__doc__ - Used by:
help()- IDEs
- Documentation generators
def f():
"""This is a docstring"""
pass
print(f.__doc__)
Docstrings are not comments.
1.3 Types of Python Functions
1.3.1 Built-in Functions
- Stored in the built-in namespace
- Written in C
- Extremely optimized
Examples:
len,id,type,print
print(type(len))
Misinformation trap:
❌ Built-in functions are keywords
✔ They are objects in builtins registry
Built-in Registry
import builtins
print("len" in dir(builtins))
1.3.2 Functions Defined in Built-in Modules
- Provided by standard library modules
- Loaded dynamically into memory
- Names registered in module namespace
import math
print(math.sqrt(25))
Here:
math→ module objectsqrt→ function attribute
Namespace Shadowing (Dangerous)
from math import sqrt
def sqrt(x):
return x + 1
print(sqrt(9))
Local registry overrides imported name.
1.3.3 User-Defined Functions
- Created using
def - Stored in heap
- Registered in local or global namespace
def is_even(n):
return n % 2 == 0
Functions as First-Class Objects (Registry Power)
def f():
print("hi")
registry = {}
registry["handler"] = f
registry["handler"]()
Functions can be:
- Stored in data structures
- Passed as arguments
- Returned from functions
Advanced Function Internals (Mind-Bending)
Default Argument Memory Trap
def add_item(x, lst=[]):
lst.append(x)
return lst
Why it breaks:
- Default values stored in
__defaults__ - Evaluated once at definition time
print(add_item(1))
print(add_item(2))
Correct Pattern
def add_item(x, lst=None):
if lst is None:
lst = []
lst.append(x)
return lst
Closures & __closure__
def outer():
x = 10
def inner():
return x
return inner
f = outer()
print(f.__closure__[0].cell_contents)
The value is stored in a cell object, not copied.
Function Identity vs Equality
def a(): pass
def b(): pass
print(a == b)
print(a is b)
Functions compare by identity, not code.
Recursion & Stack Frames
Each recursive call:
- Allocates a new frame
- Consumes stack memory
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
Too deep → RecursionError.
Common Exam-Level Misinformation
-
❌ Python uses call-by-value
✔ Uses call-by-object-reference
-
❌ Functions execute at definition
✔ They execute at call time
-
❌ Default arguments reset each call
✔ They persist in memory
-
❌ Docstrings are comments
✔ They are runtime metadata
-
❌ Multiple values returned
✔ One tuple object returned
Final Mental Model (Very Important)
2. Pass by Reference vs Pass by Value
2.1 Concept of Call by Value
- Call by value means a function receives a copy of the value, not the original variable.
- Any modification inside the function affects only the local copy.
- The caller’s data remains unchanged.
- This model is common in languages like C (for primitive data types).
Conceptual Memory Model
- Two separate objects exist in memory.
- Caller and callee do not share memory for the value.
def increment(x):
x = x + 1
a = 10
increment(a)
print(a)
Explanation:
ais bound to integer object10xreceives a reference to the same integer initiallyx = x + 1creates a new integer object (11)xis rebound locallyastill points to10
This looks like call by value, but no actual copying happened.
Common Misinformation
❌ Python copies values when passing arguments
✔ Python never copies values automatically
2.2 Concept of Call by Reference
- Call by reference means a function receives a direct reference to the caller’s variable.
- Any change inside the function directly modifies the caller’s data.
- Seen in languages like C++ (using references).
Conceptual Memory Model
- Caller and callee point to the same memory location.
- No rebinding—only direct modification.
def modify(lst):
lst.append(100)
data = [1, 2]
modify(data)
print(data)
Explanation:
dataandlstreference the same list objectappend()mutates the list in-place- Caller observes the change
Common Misinformation
❌ Python uses call by reference
✔ Python does not expose variable references like C++
2.3 Parameter Passing Mechanism in Python
Python uses neither call by value nor call by reference.
Python uses Call by Object Reference
(also called Call by Sharing)
What This Means
- Objects live in heap memory
- Variables are names, not memory locations
- Function parameters are new names bound to existing objects
- Behavior depends entirely on:
- Mutability of the object
- Mutation vs rebinding
Function Call Internals (Step-by-Step)
- Function call creates a new stack frame
- A local namespace (dictionary) is allocated
- Parameter names are bound to object references
- Bytecode executes
- Frame is destroyed on return
2.3.1 Object Reference Model
This model explains all Python argument behavior.
def rebind(x):
x = 20
def mutate(lst):
lst.append(5)
a = 10
b = [1, 2]
rebind(a)
mutate(b)
print(a)
print(b)
Explanation:
aunchanged → integers are immutablebchanged → lists are mutablexandainitially reference the same object- Rebinding
xcreates a new object - Mutation changes the shared object
Name–Object Relationship
Golden Rule
Mutation affects the caller, rebinding does not
Rebinding vs Mutation (Critical Distinction)
Rebinding
A name stops pointing to one object and starts pointing to another object.
The object does not change. Only the name–object link changes.
def f(x):
x = x + 1
- Creates a new object
- Changes local binding only
- Caller unaffected
Mutation
Changing the internal state of an object without changing its identity.
def f(lst):
lst.append(1)
- Modifies object in-place
- All references see the change
Augmented Assignment Trap (+=)
def f(x):
x += 1
def g(lst):
lst += [1]
- Immutable →
+=creates new object - Mutable →
+=mutates in place
Same syntax, different memory behavior.
2.3.2 id() Function and Memory Reference
id(obj)returns a unique identity for the object during its lifetime.- In CPython, this is usually the memory address.
- Used to detect shared references, not value equality.
x = [1, 2]
y = x
print(id(x), id(y))
Same id confirms both names reference the same object.
Integer Interning (Optimization Detail)
- Python caches small integers (
5to256) for performance.
a = 100
b = 100
print(a is b)
a = 1000
b = 1000
print(a is b)
Misinformation
❌ *is checks value equality*
✔ is checks object identity
Exceptions and Parameter Passing (Subtle but Important)
def modify(lst):
lst.append(1)
raise Exception("Stop")
data = []
try:
modify(data)
except:
pass
print(data)
Explanation:
- Mutation occurs before exception
- Stack unwinds after mutation
- Python does not roll back object state
Heap vs Stack (Python Memory Model)
- Objects → Heap
- Names & parameters → Stack frames / namespaces
- Python never passes raw memory addresses
Common Exam-Level Misinformation (Very Important)
| Statement | Reality |
|---|---|
| Python uses call by value | ❌ False |
| Python uses call by reference | ❌ False |
| Python copies objects | ❌ False |
| Mutable always changes caller | ❌ Only if mutated |
id() is always memory address | ❌ Implementation detail |
| Exceptions undo changes | ❌ False |
Final Mental Model (Lock This In)
Python passes object references,
binds them to local parameter names,
and behavior depends on mutation vs rebinding.
3. Types of Python Function Arguments
(Deep dive: binding rules, memory behavior, evaluation timing, and exam-level traps)
3.1 Required (Positional) Arguments
- Required arguments are parameters that must receive values during a function call.
- They are bound strictly by position, not by name.
- Python performs argument binding left to right.
def power(base, exp):
return base ** exp
power(2, 3)
Explanation:
base ← 2exp ← 3- Binding happens before the function body executes.
If a required argument is missing, Python raises a TypeError before execution begins.
3.1.1 Correct Order of Arguments
- Positional arguments must follow the exact order defined in the function signature.
power(3, 2)
Explanation:
- No error occurs
- But meaning changes because binding is positional
- Python does not know your intent, only positions
3.1.2 Effects of Incorrect Order
Incorrect order does not raise an error, but causes logical bugs, which are harder to detect than syntax errors.
def divide(a, b):
return a / b
divide(2, 10)
This returns 0.2, not 5.
❗ Misinformation trap
❌ “Python checks argument meaning”
✔ Python only checks position and count
3.2 Default Arguments
- Default arguments allow parameters to have predefined values.
- Used when a parameter is optional.
def greet(name, msg="Hello"):
return f"{msg}, {name}"
Here:
msgis optional- If not passed, default value is used
3.2.1 Defining Default Values (CRITICAL INTERNAL DETAIL)
- Default values are:
- Evaluated once
- At function definition time
- Stored inside the function object (
__defaults__)
def f(x, y=10):
return x + y
print(f.__defaults__)
Default Argument Memory Trap (Very Important)
def add_item(x, acc=[]):
acc.append(x)
return acc
print(add_item(1))
print(add_item(2))
Explanation:
accis created once- Stored in function object memory
- Shared across calls
This is not argument passing behavior, but function object state.
Correct Pattern
def add_item(x, acc=None):
if acc is None:
acc = []
acc.append(x)
return acc
This ensures a new list per call.
3.2.2 Overriding Default Arguments
- Providing a value explicitly overrides the default.
greet("Ankur", "Hi")
Explanation:
- Default is ignored
- Explicit argument always wins
3.3 Keyword Arguments
- Keyword arguments bind parameters by name, not position.
- Improve readability and safety.
power(exp=3, base=2)
3.3.1 Named Argument Passing
- Names must match parameter names exactly.
def profile(name, age):
return name, age
profile(age=20, name="Ankur")
Explanation:
- Order does not matter
- Binding happens by name lookup
Keyword Argument Rules (Strict)
- Positional arguments must come before keyword arguments.
## profile(name="Ankur", 20) ## SyntaxError
Python enforces this at parse time, not runtime.
3.3.2 Order Independence
profile(age=20, name="Ankur")
profile(name="Ankur", age=20)
Both are equivalent because binding is name-based.
❗ Misinformation trap
❌ “Keyword arguments are slower”
✔ Difference is negligible; clarity is preferred
3.4 Variable-Length Arguments
Used when the number of arguments is unknown or flexible.
3.4.1 Arbitrary Positional Arguments (args)
argscollects extra positional arguments into a tuple.- The tuple is created during function call, not definition.
def total(*args):
return sum(args)
total(1, 2, 3, 4)
Explanation:
argsis a tuple → immutable- Length depends on how many arguments are passed
Argument Packing & Unpacking
nums = [1, 2, 3]
total(*nums)
numsunpacks the list- Elements become positional arguments
Internal Binding Order (Important)
def f(a, b, *args):
print(a, b, args)
f(1, 2, 3, 4)
a ← 1b ← 2- Remaining →
args
3.4.2 Arbitrary Keyword Arguments (*kwargs)
*kwargscollects extra keyword arguments into a dictionary.
def show(**kwargs):
return kwargs
show(name="Ankur", age=20)
Explanation:
- Keys → argument names
- Values → argument values
- Useful for configurations and APIs
Packing & Unpacking with *
data = {"name": "Ankur", "age": 20}
show(**data)
Combined Usage (Advanced)
def demo(a, b=10, *args, **kwargs):
print(a, b, args, kwargs)
demo(1, 2, 3, 4, x=5, y=6)
Binding order (EXAM GOLD):
- Required positional
- Default positional
args- Keyword-only arguments
*kwargs
Common Misinformation & Exam Traps
| Myth | Reality |
|---|---|
| Default args reset every call | ❌ Evaluated once |
*args is a list | ❌ Tuple |
**kwargs is optional | ❌ Required if defined |
| Keyword args ignore position | ❌ Only named |
| Argument errors happen inside function | ❌ During binding |
One-Line Mental Model (Lock This In)
Argument passing in Python is name binding to object references, governed by strict binding rules executed before function body runs.
Ultra-Short Summary
- Positional → order matters
- Default → evaluated once
- Keyword → order independent
args→ tuple of extras*kwargs→ dict of extras- Binding happens before execution
- Mutation vs rebinding rules still apply
4. Python – Object-Oriented Programming Concepts
(Deep dive: object model, memory layout, method resolution, misconceptions, and Python-specific realities)
4.1 Introduction to OOP
- Object-Oriented Programming (OOP) is a paradigm that organizes programs around objects rather than functions or logic alone.
- Python is purely object-oriented at runtime:
- Everything is an object: integers, functions, classes, modules.
- OOP in Python is dynamic, not rigid like Java/C++.
Why Python OOP is Different
- No strict access modifiers (
public/private/protected) - Classes are runtime objects
- Methods are just functions with binding
- Inheritance and polymorphism are duck-typed, not interface-enforced
❗ Misinformation trap
❌ Python OOP works like Java
✔ Python OOP works via dynamic binding + object model
4.2 Object and Class
4.2.1 Class Definition
-
A class is a blueprint, but internally:
A class is an object that creates other objects
-
Defined using the
classkeyword. -
Class body executes immediately, just like
def.
class Person:
species = "Human"
def greet(self):
return "Hello"
What happens internally:
- Class body executes top-down
- Attributes and methods collected in a namespace dict
type()is called to create the class object- Class name bound to the class object
print(type(Person))
Class Object Anatomy (Important)
A class object contains:
__dict__→ attributes & methods__mro__→ method resolution order__bases__→ parent classes__init__,__new__, etc.
print(Person.__dict__.keys())
4.2.2 Object Creation
- Objects are instances of classes.
- Creation involves two steps, not one.
p = Person()
Internally:
__new__()→ allocates memory (returns object)__init__()→ initializes object state
class Test:
def __new__(cls):
print("Allocating")
return super().__new__(cls)
def __init__(self):
print("Initializing")
Test()
❗ Misinformation trap
❌ __init__ creates objects
✔ __new__ creates, __init__ initializes
Object Memory Model
4.2.3 Attributes
Attributes are names bound to objects.
Instance Attributes
p.name = "Ankur"
- Stored in
p.__dict__ - Unique per object
print(p.__dict__)
Class Attributes
print(Person.species)
- Stored in
Person.__dict__ - Shared across instances
Attribute Lookup Order (Mind-Bending but Crucial)
When accessing p.attr, Python searches:
p.__dict__Person.__dict__- Parent classes (
__mro__) object
print(p.species)
Attribute Shadowing
p.species = "Alien"
print(Person.species)
print(p.species)
Instance attribute shadows class attribute.
4.2.4 Methods
- A method is a function stored in a class.
- When accessed via an instance, it becomes a bound method.
p.greet()
Internally:
Person.greet(p)
Bound vs Unbound Concept
print(p.greet)
print(Person.greet)
p.greet→ bound (self already attached)Person.greet→ function, needs explicit instance
❗ Misinformation trap
❌ self is a keyword
✔ self is a convention, not special
4.3 Principles of OOP
4.3.1 Encapsulation
- Encapsulation means binding data and behavior together.
- Python enforces encapsulation by convention, not force.
Attribute Access Levels (Convention)
| Syntax | Meaning |
|---|---|
name | public |
_name | internal |
__name | name-mangled |
class Demo:
def __init__(self):
self.__secret = 42
print(Demo().__dict__)
Python rewrites __secret → _Demo__secret.
❗ Misinformation trap
❌ Double underscore makes attribute private
✔ It only mangles the name
Getters and Setters (Pythonic Way)
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
This preserves encapsulation without Java-style boilerplate.
4.3.2 Inheritance
- Inheritance allows a class to reuse and extend behavior.
- Python supports:
- Single
- Multiple
- Multilevel inheritance
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
def speak(self):
return "bark"
Method Resolution Order (MRO) – VERY IMPORTANT
print(Dog.__mro__)
Python uses C3 Linearization to resolve methods.
MRO Diagram
super() Internals
class A:
def show(self):
print("A")
class B(A):
def show(self):
super().show()
print("B")
super() does not mean parent
It means: next class in MRO
❗ Misinformation trap
❌ super() always calls parent
✔ It follows MRO order
4.3.3 Polymorphism
- Polymorphism means same interface, different behavior.
- Python uses duck typing.
class Bird:
def fly(self):
return "Flying"
class Plane:
def fly(self):
return "Flying fast"
def lift(obj):
return obj.fly()
Python doesn’t care about type, only behavior.
Operator Overloading (Special Methods)
class Point:
def __init__(self, x):
self.x = x
def __add__(self, other):
return Point(self.x + other.x)
This is compile-time polymorphism via runtime dispatch.
4.3.4 Abstraction
- Abstraction focuses on what, not how.
- Implemented using abstract base classes (ABC).
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Square(Shape):
def area(self):
return 4
Trying to instantiate Shape raises TypeError.
Why Abstraction Matters
- Forces correct subclass implementation
- Prevents incomplete object creation
- Enables framework-level design
Common OOP Misinformation (Exam Gold)
| Myth | Reality |
|---|---|
| Python isn’t truly OOP | ❌ Everything is object |
self is keyword | ❌ Convention |
| Private attributes exist | ❌ Name mangling only |
super() calls parent | ❌ Calls next in MRO |
| Inheritance is mandatory | ❌ Composition preferred |
Final Mental Model (Lock This In)
4.4 Encapsulation
Encapsulation in Python is not about access restriction like Java/C++.
It is about controlling how data is accessed and modified.
Python enforces encapsulation by convention + object model, not by force.
4.4.1 Data Hiding
- Data hiding means preventing accidental or unsafe access to internal object state.
- Python does not truly hide data.
- Instead, Python relies on:
- Naming conventions
- Developer discipline
- Properties
class Account:
def __init__(self, balance):
self.balance = balance
Nothing stops external access:
acc = Account(1000)
acc.balance = -500
❗ Misinformation
❌ Python supports true data hiding
✔ Python supports controlled access, not enforced hiding
Why Python Avoids Strict Data Hiding
- Objects are dynamic
- Introspection is encouraged
- Debugging and testing are easier
- “We are all consenting adults” philosophy
4.4.2 Private Data Members
Python uses name mangling, not private memory.
class Demo:
def __init__(self):
self.__secret = 42
Internally, Python rewrites:
__secret → _Demo__secret
d = Demo()
print(d.__dict__)
You’ll see:
{'_Demo__secret': 42}
Access is still possible:
print(d._Demo__secret)
❗ Misinformation
❌ __var is private
✔ __var is name-mangled, not private
Single Underscore (_var)
- Indicates internal use
- No runtime effect
self._temp = 10
Used by convention only.
4.4.3 Getter and Setter Methods
Python prefers properties over explicit getters/setters.
❌ Java-style (not Pythonic):
def get_balance(self):
return self.balance
✔ Pythonic way:
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Invalid balance")
self._balance = value
Why this matters:
- Interface stays the same
- Internal logic can change
- Encapsulation without breaking code
4.5 Inheritance
Inheritance allows a class to reuse and extend behavior from another class.
Python supports:
- Single inheritance
- Multi-level inheritance
- Multiple inheritance
Inheritance works via Method Resolution Order (MRO).
4.5.1 Single Inheritance
One parent → one child.
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
def speak(self):
return "bark"
d = Dog()
d.speak()
Lookup process:
DogAnimalobject
4.5.2 Multi-Level Inheritance
Inheritance chain across multiple levels.
class A:
def show(self):
return "A"
class B(A):
pass
class C(B):
pass
print(C.__mro__)
Python searches top to bottom in MRO.
4.5.3 Multiple Inheritance
A class inherits from more than one parent.
class A:
def show(self):
return "A"
class B:
def show(self):
return "B"
class C(A, B):
pass
c = C()
c.show()
Python chooses method using C3 Linearization.
print(C.__mro__)
❗ Misinformation
❌ Python randomly chooses parent
✔ Python uses deterministic MRO
super() (Very Important)
class A:
def show(self):
print("A")
class B(A):
def show(self):
super().show()
print("B")
super() means:
Call the next class in MRO, not necessarily the parent.
4.6 Polymorphism
Polymorphism means:
Same interface, different behavior
Python achieves this via duck typing.
4.6.1 Method Overriding
Child class provides its own implementation.
class Shape:
def area(self):
return 0
class Square(Shape):
def area(self):
return 4
Method selection happens at runtime, not compile time.
4.6.2 Runtime Polymorphism
Python decides which method to run at runtime based on object type.
def print_area(shape):
print(shape.area())
print_area(Square())
No type checking.
Only method presence matters.
❗ Misinformation
❌ Python checks class type
✔ Python checks behavior
Operator Polymorphism
class Point:
def __init__(self, x):
self.x = x
def __add__(self, other):
return Point(self.x + other.x)
Same operator +, different behavior.
4.7 Abstraction
Abstraction focuses on what an object does, not how it does it.
Python uses Abstract Base Classes (ABC).
4.7.1 Data Abstraction Concept
- Prevents incomplete implementations
- Defines required methods
- Enforces design contracts
4.7.2 Abstract Classes
- Cannot be instantiated
- Serve as templates
from abc import ABC
class Shape(ABC):
pass
## Shape() → TypeError
4.7.3 Abstract Methods
Methods that must be implemented by subclasses.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Square(Shape):
def area(self):
return 4
Failure to implement raises error at instantiation time.
4.7.4 abc Module
The abc module provides:
ABCabstractmethod
from abc import ABC, abstractmethod
Key internal behavior:
- Abstract methods stored in
__abstractmethods__ - Class instantiation blocked if not overridden
Common Exam-Level Misinformation (Very Important)
| Myth | Reality |
|---|---|
| Python enforces private members | ❌ Convention only |
__var is private | ❌ Name mangling |
super() calls parent | ❌ Calls next in MRO |
| Multiple inheritance is unsafe | ❌ Safe with MRO |
| Abstraction is optional | ❌ Enforced via ABC |
Final Mental Model (Lock This In)
Ultra-Short Summary
- Encapsulation → controlled access via convention
- Inheritance → behavior reuse via MRO
- Polymorphism → runtime method selection
- Abstraction → enforced contracts via ABC
- Python favors flexibility over restriction
5. Python Exceptions
(Deep dive: runtime error model, stack frames, propagation, internals, and exam-level misconceptions)
5.1 Introduction to Exceptions
- An exception is a runtime event that disrupts the normal flow of program execution.
- Exceptions occur when Python encounters a situation it cannot resolve safely.
- Internally, an exception is an object derived from
BaseException.
print(type(Exception))
What Happens Internally
- Error condition detected
- Exception object is created
- Current execution stops
- Python searches for a handler
- If not found → program terminates
❗ Key truth
Exceptions are objects, not messages or errors alone.
5.2 Syntax Errors vs Exceptions
Syntax Errors
- Detected before execution
- Raised by the parser
- Program never starts running
## if True
## print("hi")
This raises SyntaxError during parsing.
Exceptions
- Occur during execution
- Program starts running successfully
- Error arises due to logic, input, or environment
print(10 / 0)
Raises ZeroDivisionError.
Core Difference
| Syntax Error | Exception |
|---|---|
| Compile-time | Runtime |
| Parser-level | Interpreter-level |
| Cannot be caught | Can be handled |
| Code doesn’t run | Code partially runs |
❗ Misinformation trap
❌ SyntaxError is an exception you can catch
✔ SyntaxError stops execution before runtime (except in special eval cases)
5.3 Exception Handling
Python handles exceptions using structured control flow.
Why Exception Handling Exists
- Prevent abrupt program termination
- Separate error-handling logic from normal logic
- Allow recovery and cleanup
5.3.1 try Block
- Wraps code that may fail
- Python monitors this block for exceptions
try:
x = int("abc")
Internal Behavior
- Bytecode execution is monitored
- If no exception → entire block runs
- If exception → execution jumps immediately
5.3.2 except Block
- Catches and handles specific exception types
- Matching is done using class hierarchy
try:
x = int("abc")
except ValueError:
print("Invalid conversion")
Exception Matching Rules
- Exact match or subclass match
- Order matters: more specific first
try:
x = 1 / 0
except ArithmeticError:
print("Arithmetic")
except ZeroDivisionError:
print("Zero division")
ArithmeticError catches first because it is a base class.
❗ Exam trap
❌ Python checks except blocks randomly
✔ Python checks top to bottom
5.4 Raising Exceptions
5.4.1 raise Keyword
- Used to manually trigger exceptions
- Can raise:
- Built-in exceptions
- Custom exceptions
raise ValueError("Invalid value")
Why raise Exists
- Enforce constraints
- Signal incorrect usage
- Propagate errors upward
Re-raising Exceptions
try:
x = 1 / 0
except:
print("Error occurred")
raise
- Preserves original traceback
- Useful for logging + propagation
❗ Misinformation
❌ raise creates a new exception always
✔ raise alone re-raises the same exception
5.5 Assertions
5.5.1 assert Statement
- Used for debug-time checks
- Not meant for runtime error handling
- Can be globally disabled
assert x > 0, "x must be positive"
How Assertions Work Internally
assert condition
is equivalent to:
if __debug__:
if not condition:
raise AssertionError
Disabling Assertions
python -O script.py
- Removes assertion checks
- Assertions must not be used for critical logic
❗ Exam trap
❌ Assertions always run
✔ Assertions can be optimized away
5.6 try–except–else
elseruns only if no exception occurs- Cleaner than putting code after
try
try:
x = int("10")
except ValueError:
print("Error")
else:
print("Success:", x)
Why else Exists
- Prevents accidental catching of errors
- Keeps success logic separate
❗ Misinformation
❌ else runs after except
✔ else runs only if except is skipped
5.7 finally Clause
- Executes no matter what
- Used for cleanup (files, locks, resources)
try:
x = 1 / 0
finally:
print("Cleanup")
Behavior Guarantees
- Runs after:
- Normal execution
- Handled exception
- Unhandled exception
- Even runs when
returnis used
def f():
try:
return 10
finally:
print("finally")
print(f())
Output:
finally
10
Exception Propagation (Deep Internals)
- Exceptions propagate up the call stack
- Each frame is checked for a handler
- Stack unwinds until:
- Handler found
- Or program terminates
def a():
b()
def b():
c()
def c():
1 / 0
a()
Python shows a traceback showing stack frames.
Exception Object Anatomy
Every exception object has:
- Type
- Message
- Traceback (
__traceback__) - Context (
__context__,__cause__)
try:
1 / 0
except Exception as e:
print(type(e))
Common Exception Hierarchy (Important)
BaseException
├── Exception
│ ├── ValueError
│ ├── TypeError
│ ├── ZeroDivisionError
│ └── IndexError
├── SystemExit
├── KeyboardInterrupt
└── GeneratorExit
❗ Exam trap
❌ Catch BaseException
✔ Catch Exception (never swallow system exits)
Common Misinformation (Very Important)
| Myth | Reality |
|---|---|
| Exceptions are slow | ❌ Only slow when raised |
| SyntaxError can be caught | ❌ Usually cannot |
| finally runs only on error | ❌ Always runs |
| assert is error handling | ❌ Debug only |
| except catches all errors | ❌ Depends on type |
Final Mental Model (Lock This In)
Ultra-Short Summary
- Exceptions are objects
- Syntax errors ≠ exceptions
trymonitors executionexcepthandles specific typesraisesignals errorsassertis for debuggingelseruns on successfinallyalways runs- Exceptions propagate via stack frames
6. Python File Handling
(Deep dive: OS interaction, file objects, buffering, encodings, resource management, and exam-level misconceptions)
6.1 Introduction to File Handling
- File handling allows a program to store data permanently on secondary storage (disk).
- Files are managed by the Operating System, not Python.
- Python acts as a high-level interface to OS-level file descriptors.
Key Idea (Very Important)
Python does not read/write files directly.
It requests the OS to do so via a file descriptor.
File Handling Pipeline (Internal View)
6.2 Opening Files
6.2.1 open() Function
open()is a built-in function that returns a file object.- Syntax:
file = open("data.txt", "r")
What Happens Internally
- Python requests OS to open the file
- OS returns a file descriptor (integer)
- Python wraps it inside a file object
- File pointer is positioned at start (usually)
print(type(file))
The returned object is an instance of _io.TextIOWrapper.
File Object Anatomy
A file object maintains:
- File descriptor
- Mode (read/write)
- Cursor position
- Buffer
- Encoding (for text files)
print(file.mode)
print(file.closed)
6.2.2 File Opening Modes
| Mode | Meaning |
|---|---|
r | Read (default) |
w | Write (overwrite) |
a | Append |
x | Create, fail if exists |
b | Binary mode |
t | Text mode |
r+ | Read + write |
w+ | Write + read |
a+ | Append + read |
open("file.txt", "rb")
Critical Misinformation
❌ w edits file
✔ w destroys existing content
6.3 Reading Files
6.3.1 read() Method
- Reads entire file or specified number of characters.
- Moves file pointer forward.
file = open("data.txt", "r")
content = file.read()
Explanation:
- Reads from current cursor to EOF
- Cursor moves to end
print(file.tell())
Partial Read
file.read(10)
Reads first 10 characters.
6.3.2 readline() Method
- Reads one line at a time
- Includes newline (
\n)
line = file.readline()
Useful for large files.
6.3.3 readlines() Method
- Reads all lines into a list
lines = file.readlines()
❗ Memory Warning
- Loads entire file into memory
- Avoid for large files
Iterating Over File (Best Practice)
for line in file:
print(line)
Python reads line-by-line efficiently using buffering.
6.4 Writing Files
6.4.1 write() Method
- Writes a string to file
- Returns number of characters written
file = open("out.txt", "w")
file.write("Hello\n")
❗ Must be a string in text mode.
Buffering Insight (Important)
- Data may stay in memory buffer
- Not immediately written to disk
- Flushed on:
close()flush()- Program exit
file.flush()
6.4.2 writelines() Method
- Writes a list of strings
- Does NOT add newline automatically
lines = ["one\n", "two\n", "three\n"]
file.writelines(lines)
6.5 Closing Files
6.5.1 close() Method
- Releases OS resources
- Flushes buffers
- Marks file as closed
file.close()
print(file.closed)
Why Closing Is Critical
- OS has limited file descriptors
- Unclosed files → resource leak
- Data loss risk due to buffering
❗ Misinformation
❌ Python closes files automatically
✔ Only sometimes (garbage collection is unreliable)
6.6 File Handling Best Practices
6.6.1 with Statement (Context Manager)
- Ensures file is always closed
- Even if an exception occurs
with open("data.txt", "r") as file:
data = file.read()
Internal Mechanism
__exit__() is called no matter what.
Why with Is Superior
- No forgotten
close() - Cleaner code
- Exception-safe
- Recommended by Python docs
6.6.2 Exception Handling While Closing Files
❌ Unsafe Pattern:
file = open("data.txt")
data = file.read()
file.close()
If read() fails → close() never runs.
✔ Safe Pattern:
try:
file = open("data.txt")
data = file.read()
finally:
file.close()
✔ Best Pattern (Preferred):
with open("data.txt") as file:
data = file.read()
Text vs Binary Files (Important)
open("image.png", "rb")
- Text mode → encoding/decoding
- Binary mode → raw bytes
data = open("file.bin", "rb").read()
print(type(data)) ## bytes
Encoding & Decoding (Common Pitfall)
open("data.txt", "r", encoding="utf-8")
❗ Encoding mismatch causes UnicodeDecodeError.
File Cursor Control
file.seek(0)
file.tell()
seek()→ move cursortell()→ current position
Exception Types in File Handling
| Exception | Cause |
|---|---|
FileNotFoundError | File missing |
PermissionError | No access rights |
IsADirectoryError | Opening directory |
IOError | I/O failure |
Common Exam-Level Misinformation
| Myth | Reality |
|---|---|
close() always runs | ❌ Not guaranteed |
w appends data | ❌ Overwrites |
readlines() is efficient | ❌ Memory-heavy |
| Files auto-close | ❌ GC unreliable |
| Text and binary are same | ❌ Encoding matters |
Final Mental Model (Lock This In)
Ultra-Short Summary
- Files are OS-managed resources
open()returns a file object- Reading moves cursor
- Writing is buffered
- Closing is mandatory
withguarantees cleanup- Exceptions do not auto-close files
- Text ≠ binary