Skip to main content

Unit - 2

TITLE

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 f in 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 def statement is executed at runtime.
  • When Python encounters def:
    1. The function body is compiled into bytecode
    2. A function object is created
    3. 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:

  1. Create a stack frame
  2. Allocate local namespace (dictionary)
  3. Bind arguments → parameters
  4. Execute bytecode
  5. Return value
  6. 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)

  1. Positional arguments
  2. Keyword arguments
  3. Default arguments
  4. args
  5. *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 object
  • sqrt → 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:

  • a is bound to integer object 10
  • x receives a reference to the same integer initially
  • x = x + 1 creates a new integer object (11)
  • x is rebound locally
  • a still points to 10

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:

  • data and lst reference the same list object
  • append() 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)

  1. Function call creates a new stack frame
  2. A local namespace (dictionary) is allocated
  3. Parameter names are bound to object references
  4. Bytecode executes
  5. 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:

  • a unchanged → integers are immutable
  • b changed → lists are mutable
  • x and a initially reference the same object
  • Rebinding x creates 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 (5 to 256) 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)

StatementReality
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 ← 2
  • exp ← 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:

  • msg is 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:

  • acc is 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)

  • args collects 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:

  • args is a tuple → immutable
  • Length depends on how many arguments are passed

Argument Packing & Unpacking

nums = [1, 2, 3]
total(*nums)
  • nums unpacks 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 ← 1
  • b ← 2
  • Remaining → args

3.4.2 Arbitrary Keyword Arguments (*kwargs)

  • *kwargs collects 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):

  1. Required positional
  2. Default positional
  3. args
  4. Keyword-only arguments
  5. *kwargs

Common Misinformation & Exam Traps

MythReality
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 class keyword.

  • Class body executes immediately, just like def.

class Person:
species = "Human"

def greet(self):
return "Hello"

What happens internally:

  1. Class body executes top-down
  2. Attributes and methods collected in a namespace dict
  3. type() is called to create the class object
  4. 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:

  1. __new__() → allocates memory (returns object)
  2. __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:

  1. p.__dict__
  2. Person.__dict__
  3. Parent classes (__mro__)
  4. 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)

SyntaxMeaning
namepublic
_nameinternal
__namename-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)

MythReality
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:

  1. Dog
  2. Animal
  3. object

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:

  • ABC
  • abstractmethod
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)

MythReality
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

  1. Error condition detected
  2. Exception object is created
  3. Current execution stops
  4. Python searches for a handler
  5. 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 ErrorException
Compile-timeRuntime
Parser-levelInterpreter-level
Cannot be caughtCan be handled
Code doesn’t runCode 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

  • else runs 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 return is 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)

MythReality
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
  • try monitors execution
  • except handles specific types
  • raise signals errors
  • assert is for debugging
  • else runs on success
  • finally always 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

  1. Python requests OS to open the file
  2. OS returns a file descriptor (integer)
  3. Python wraps it inside a file object
  4. 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

ModeMeaning
rRead (default)
wWrite (overwrite)
aAppend
xCreate, fail if exists
bBinary mode
tText 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 cursor
  • tell() → current position

Exception Types in File Handling

ExceptionCause
FileNotFoundErrorFile missing
PermissionErrorNo access rights
IsADirectoryErrorOpening directory
IOErrorI/O failure

Common Exam-Level Misinformation

MythReality
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
  • with guarantees cleanup
  • Exceptions do not auto-close files
  • Text ≠ binary

𝕮𝖚𝖗𝖗𝖎𝖈𝖚𝖑𝖚𝖒