Chapter 1 of 6

Python Basics

Language Overview

Python Characteristics

Interpreted Language

Interpreted Language --- Definition

A programming language is called interpreted when its source code is executed directly by an interpreter program at runtime, statement by statement, without a prior separate compilation step that produces machine code.

Python uses a two-phase execution model:

  1. Compilation phase (automatic, transparent): source .py is compiled to platform-independent bytecode (.pyc) and cached in __pycache__/

  2. Interpretation phase: the Python Virtual Machine (PVM) reads bytecode instructions one by one and executes them

This is why Python is called interpreted: no explicit compile step is needed by the programmer, and errors surface at runtime.

diagram
Properties of Python's Interpreted Model
  • No explicit compile step — run with python script.py directly

  • Bytecode caching — __pycache__/script.cpython-3xx.pyc created automatically; re-used if source unchanged (checked via modification timestamp)

  • Platform independence — same .py runs on Windows, Linux, macOS (bytecode is not platform-specific)

  • Late error detection — syntax errors caught before run; runtime errors (TypeError, NameError …) only when that line executes

  • Dynamic code execution — eval() and exec() compile and run strings as Python code at runtime

  • Interactive REPL — possible because lines are interpreted immediately

Example 1: Runtime vs Compile-time Error Detection
# SyntaxError is caught BEFORE execution (static analysis)
# def bad(:          # SyntaxError -- Python detects before line 1 runs
 
# NameError only caught when the faulty line actually executes
def process(items):
    result = []
    for item in items:
        result.append(item * 2)   # line 4: runs fine
    return undefined_var           # line 5: NameError raised HERE
 
process([1, 2, 3])   # lines 1-4 execute successfully, crash on line 5
# NameError: name 'undefined_var' is not defined

Takeaway: Python executes code up to the point of failure. In a compiled language such code would be caught before any execution.

Example 2: Inspecting Bytecode with dis
import dis
 
def multiply(a, b):
    return a * b
 
dis.dis(multiply)
# Bytecode output (Python 3.11+):
#   3           RESUME          0
#   4           LOAD_FAST       0 (a)
#               LOAD_FAST       1 (b)
#               BINARY_OP       5 (*)
#               RETURN_VALUE

The dis module reveals the actual bytecode instructions the PVM executes. Each LOAD_FAST pushes a local variable onto the evaluation stack; BINARY_OP pops two operands, multiplies them, and pushes the result.

Example 3: Dynamic Execution with eval and exec
# eval: evaluates an expression string, returns result
expr = "3 ** 4 + len('hello')"
result = eval(expr)
print(result)   # 86
 
# exec: executes a block of Python code from a string
code = """
def greet(name):
    return f"Hello, {name}!"
 
message = greet("GATE")
"""
namespace = {}
exec(code, namespace)
print(namespace["message"])   # Hello, GATE!
 
# Practical: dynamic formula evaluator
formula = input("Enter formula: ")   # user types: 2*x + 3
x = 10
print(eval(formula))   # 23

eval/exec are possible only because Python is interpreted: bytecode is compiled and run at runtime from a string.

Dynamically Typed

Dynamic Typing --- Definition

In a dynamically typed language, type information is associated with values (objects), not with variables. A variable is merely a label (name binding) that can be rebound to any object of any type at any time.

Formally: Type checking is performed at runtime (not compile time). The type of a variable is determined by the type of the object it currently refers to.

Contrast with static typing (C, Java, Rust):

diagram
diagram
Properties of Dynamic Typing in Python
  • Variable has no type — x = 5 then x = "hi" is perfectly legal

  • Object has a type — every object carries type info: type(42) $\to$ <class 'int'>

  • Type checking at runtime — TypeError raised when an operation is attempted on an incompatible type

  • Duck typing — “if it walks like a duck and quacks like a duck…” — Python checks capabilities, not declared types

  • Optional static type hints (PEP 484) — x: int = 5 is a hint only; Python itself does not enforce it at runtime (tools like mypy do)

  • Identity vs equality — id(obj) returns unique identity; is checks identity, == checks value equality

Example 4: Variable Rebinding and type() / id()
x = 42
print(type(x), id(x))   # <class 'int'>  9789376
 
x = "hello"
print(type(x), id(x))   # <class 'str'>  140223847823984
 
x = [1, 2, 3]
print(type(x), id(x))   # <class 'list'> 140223847701248
 
x = lambda n: n**2
print(type(x), id(x))   # <class 'function'> 140223846982144
 
# id changes every time -- x now refers to a different object
# The variable 'x' itself has no type; its current referent does
Example 5: Duck Typing in Practice
# Any object with __len__ works -- no type declaration needed
def print_length(obj):
    print(f"Length: {len(obj)}")    # uses duck typing
 
print_length("Python")          # Length: 6  (str has __len__)
print_length([10, 20, 30])      # Length: 3  (list has __len__)
print_length({"a": 1, "b": 2})  # Length: 2  (dict has __len__)
# print_length(42)              # TypeError: 'int' has no len()
 
# Same function works for any "sized" object -- no isinstance() check!
import numpy as np
arr = np.array([1, 2, 3, 4])
print_length(arr)               # Length: 4
Example 6: Type Hints (PEP 484) --- Optional Static Annotation
# Type hints are ADVISORY ONLY -- Python does NOT enforce them
def add(a: int, b: int) -> int:
    return a + b
 
print(add(3, 4))       # 7   -- correct usage
print(add("x", "y"))   # xy  -- no runtime error! hint is ignored
 
# Runtime check (explicit) using isinstance
def safe_add(a: int, b: int) -> int:
    if not (isinstance(a, int) and isinstance(b, int)):
        raise TypeError(f"Expected int, got {type(a)}, {type(b)}")
    return a + b
 
safe_add("x", "y")
# TypeError: Expected int, got <class 'str'>, <class 'str'>

Use mypy or pyright externally to enforce type hints statically.

Strongly Typed

Strong Typing --- Definition

A language is strongly typed when it does not perform implicit type coercions between unrelated types. Operations on incompatible types raise a TypeError rather than silently converting one operand.

Formal distinction:

diagram

Python is both dynamically typed and strongly typed.

diagram
Strong Typing Rules in Python
  • No implicit string$\leftrightarrow$int coercion: "5" + 3 raises TypeError; must use int("5") + 3

  • Boolean is a subtype of int: True behaves as 1 and False as 0 (this is the only notable implicit coercion)

  • Explicit conversion always required: str(), int(), float(), list() etc.\ must be called manually

  • Comparison across types returns False: 1 == "1" is False (not a TypeError) but 1 < "1" raises TypeError

  • + for numbers means addition; for strings means concatenation: mixing raises TypeError

  • Operator overloading is explicit: classes define __add__, __mul__ etc.\ to control behaviour

Example 7: Strong Typing --- Implicit Coercion Blocked
# ------ TypeError cases (strong typing) ------
try:
    result = "Age: " + 25          # can't concat str + int
except TypeError as e:
    print(e)   # can only concatenate str (not "int") to str
 
try:
    result = "3" * 1.5             # str * float not defined
except TypeError as e:
    print(e)   # can't multiply sequence by non-int of type 'float'
 
# ------ Explicit conversion (correct way) ------
result = "Age: " + str(25)         # "Age: 25"
result = int("3") * 2              # 6
result = float("3.14") + 1         # 4.14
 
# ------ bool IS int (one exception) ------
print(True + 1)       # 2   (True == 1)
print(False * 10)     # 0   (False == 0)
print(sum([True, True, False, True]))  # 3  (useful for counting)
Example 8: Cross-type Comparisons
# == works across types (returns False, not error)
print(1 == "1")       # False
print(1 == 1.0)       # True  (int and float compare by value)
print(True == 1)      # True  (bool is subtype of int)
print(True == 1.0)    # True
 
# < / > raise TypeError for unrelated types (Python 3)
try:
    print(1 < "2")
except TypeError as e:
    print(e)   # '<' not supported between instances of 'int' and 'str'
 
# In Python 2, this used to silently return an arbitrary ordering!
# Python 3 fixed this -- another example of stronger type discipline.

Everything is an Object

Python's Unified Object Model

In Python, every value is an object — an instance of some class. This includes:

  • Primitive values: 42, 3.14, True, None

  • Collections: list, tuple, dict, set

  • Functions and lambda expressions

  • Classes themselves (instances of type)

  • Modules and packages

  • Frames, code objects, tracebacks

Every object has:

  1. Identity — unique integer, returned by id(obj) (CPython: memory address)

  2. Type — the class it belongs to, returned by type(obj)

  3. Value — the data it holds

All objects ultimately inherit from the built-in class object.

diagram
First-Class Objects in Python

In Python, first-class object means an entity that can be:

  • Assigned to a variable: f = len

  • Passed as an argument: map(str, [1,2,3])

  • Returned from a function: closures, decorators

  • Stored in a data structure: ops = [add, sub, mul]

  • Has attributes and methods: f.__name__, (42).bit_length()

This applies to functions, classes, modules, and all built-in types.

Example 9: Functions and Classes as Objects
# Functions are objects
def square(n):
    return n ** 2
 
print(type(square))          # <class 'function'>
print(square.__name__)       # square
print(square.__code__.co_varnames)  # ('n',)
 
% Assign function to a variable
f = square
print(f(7))                  # 49
 
% Store functions in a list
ops = [abs, str, type, id]
for op in ops:
    print(op(-5))            # 5 / '-5' / <class 'int'> / 9789280
 
% Classes are objects too (instances of 'type')
class Dog:
    sound = "Woof"
 
print(type(Dog))             # <class 'type'>
print(Dog.__bases__)         # (<class 'object'>,)
print(Dog.__dict__['sound']) # Woof
 
% Create a class dynamically using type()
Cat = type("Cat", (object,), {"sound": "Meow", "legs": 4})
c = Cat()
print(c.sound, c.legs)       # Meow 4
Example 10: Integers and Strings Have Methods --- They Are Objects
# int methods
n = 255
print(n.bit_length())        # 8   (255 = 1111_1111)
print(n.to_bytes(2, 'big'))  # b'\x00\xff'
print((1_000_000).bit_count()) # 7 (Python 3.10+)
 
# float methods
x = 3.75
print(x.is_integer())        # False
print(x.as_integer_ratio())  # (15, 4)
 
# str methods
s = "gate exam"
print(s.upper())             # GATE EXAM
print(s.title())             # Gate Exam
print(s.count('a'))          # 2
 
# bool is int subclass
print(isinstance(True, int))  # True
Example 11: Modules and None Are Objects
import math
 
# Module is an object
print(type(math))              # <class 'module'>
print(math.__name__)           # math
print(math.__file__)           # /usr/lib/python3.x/lib-dynload/...
print(dir(math)[:5])           # ['__doc__', '__loader__', ...]
 
# None is a singleton object
print(type(None))              # <class 'NoneType'>
print(id(None) == id(None))    # True  (same singleton)
 
# Checking for None: use 'is', not '=='
result = None
if result is None:             # correct idiom
    print("No result yet")

Indentation Defines Blocks

Indentation as Block Structure

Python uses consistent indentation (whitespace at the start of a line) to delimit code blocks, replacing the braces {} used by C, Java, and many other languages.

Rule: All statements in a block must be indented by the same number of spaces. The block begins after a colon (:) on the preceding line and ends when indentation decreases back to the enclosing level.

Recommendation (PEP 8): use 4 spaces per indentation level. Never mix tabs and spaces (raises TabError in Python 3).

diagram
Indentation Rules and Common Errors
  • Colon triggers a new block: after if, elif, else, for, while, def, class, with, try/except

  • Consistent spacing required: mixing 2-space and 4-space indentation inside the same block raises IndentationError

  • Tabs vs spaces: Python 3 forbids mixing tabs and spaces (TabError)

  • pass placeholder: used to create an empty block: def todo(): pass

  • Continuation lines: long expressions can span multiple lines inside parentheses, brackets, or braces — indentation of continuation lines is flexible

  • One-liner blocks: allowed but not recommended: if x > 0: print(x)

Example 12: Correct vs Incorrect Indentation
# CORRECT: consistent 4-space blocks
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)
 
print(factorial(5))   # 120
 
# ─── ERRORS ───────────────────────────────────────────────────
# IndentationError: unexpected indent
# def broken():
#     x = 1
#       y = 2     # <-- extra spaces, not a new block!
 
# IndentationError: expected an indented block
# def empty():
# print("hi")     # nothing indented after def
 
# TabError (Python 3): inconsistent use of tabs and spaces
# def mixed():
#     x = 1       # 4 spaces
# 	y = 2     # 1 tab  -->  TabError!
Example 13: Nested Blocks and pass
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
 
# Three levels of nesting
for i, row in enumerate(matrix):       # level 1 block
    for j, val in enumerate(row):      # level 2 block
        if val % 2 == 0:               # level 3 block
            print(f"[{i}][{j}] = {val} (even)")
        else:
            pass                       # deliberate no-op
 
# Output:
# [0][1] = 2 (even)
# [1][0] = 4 (even)
# [1][2] = 6 (even)
# [2][1] = 8 (even)
Example 14: Line Continuation Inside Parentheses
# Long expressions: parentheses allow free indentation
result = (
    10
    + 20
    + 30
    + 40
)
print(result)   # 100
 
# Long function call
output = sorted(
    ['banana', 'apple', 'cherry', 'date'],
    key=len,
    reverse=True
)
print(output)   # ['banana', 'cherry', 'apple', 'date']
 
# Backslash continuation (avoid when possible)
total = 10 + \
        20 + \
        30
print(total)    # 60

CPython: Reference Implementation & Execution Model

CPython --- The Reference Implementation

CPython is the official, reference implementation of Python, written in C. When people say “Python”, they almost always mean CPython.

Other implementations exist for specific use cases:

diagram

All conforming implementations follow the Python Language Reference (docs.python.org/3/reference).

diagram

}

The Global Interpreter Lock (GIL)

The GIL is a mutex (lock) in CPython that allows only one thread to execute Python bytecode at a time, even on multi-core hardware.

Why it exists: CPython's memory management (reference counting) is not thread-safe. The GIL prevents race conditions by serialising access.

Consequences:

  • CPU-bound multithreading gives no speedup (threads take turns holding the GIL)

  • I/O-bound multithreading works well (GIL is released during I/O waits)

  • Multiprocessing (multiprocessing module) bypasses GIL — each process has its own GIL

  • C extensions can release the GIL manually (NumPy does this)

Python 3.13+: experimental “free-threaded” build (–disable-gil) is in progress under PEP 703.

CPython Memory Management Model
  • Reference Counting: every object stores ob_refcnt; when count drops to zero the object is immediately deallocated

  • Cyclic GC: a generational garbage collector handles reference cycles (objects referencing each other) that reference counting alone cannot reclaim

  • Small-integer cache: CPython caches integers $-5$ to $256$; id(5) is id(5) is always True

  • String interning: short strings and identifiers are interned (one copy in memory); sys.intern() forces interning

  • Memory allocator: CPython has its own arena/pool allocator (obmalloc) for objects $\le 512$ bytes

Example 15: Checking CPython Version and Implementation
import sys
import platform
 
# Which implementation?
print(sys.implementation.name)  # cpython
print(sys.version)              # 3.11.5 (default, ...) [GCC 12.3.0]
 
# Detailed version info
print(sys.version_info)
# sys.version_info(major=3, minor=11, micro=5, ...)
 
# Platform
print(platform.python_implementation())  # CPython
print(platform.python_version())         # 3.11.5
 
# Check if we can use free-threaded mode (3.13+)
print(getattr(sys, '_is_gil_enabled', lambda: True)())  # True (GIL on)
Example 16: Reference Counting and sys.getrefcount
import sys
 
x = []                        # ref count = 1 (x)
print(sys.getrefcount(x))     # 2 (x + getrefcount argument)
 
y = x                         # ref count = 2 (x and y)
z = x                         # ref count = 3
print(sys.getrefcount(x))     # 4 (x, y, z + getrefcount arg)
 
del y                         # ref count = 3
del z                         # ref count = 2
# When ref count reaches 0, object is immediately freed
 
# Small-int cache: same object for -5..256
a = 100; b = 100
print(a is b)    # True  (same cached object)
print(id(a) == id(b))  # True
 
a = 1000; b = 1000
print(a is b)    # False  (outside cache range, different objects)
Example 17: GIL Effect --- CPU-bound Threads vs Processes
import threading
import multiprocessing
import time
 
def count_down(n):
    while n > 0:
        n -= 1
 
N = 50_000_000
 
# ── Single thread ──────────────────────────────────────────
t0 = time.perf_counter()
count_down(N)
print(f"Single  : {time.perf_counter()-t0:.2f}s")   # ~1.8s
 
# ── Two threads (GIL prevents true parallelism) ────────────
t0 = time.perf_counter()
t1 = threading.Thread(target=count_down, args=(N//2,))
t2 = threading.Thread(target=count_down, args=(N//2,))
t1.start(); t2.start(); t1.join(); t2.join()
print(f"Threads : {time.perf_counter()-t0:.2f}s")   # ~1.9s  (not faster!)
 
# ── Two processes (each has own GIL) ──────────────────────
t0 = time.perf_counter()
p1 = multiprocessing.Process(target=count_down, args=(N//2,))
p2 = multiprocessing.Process(target=count_down, args=(N//2,))
p1.start(); p2.start(); p1.join(); p2.join()
print(f"Processes: {time.perf_counter()-t0:.2f}s")  # ~0.9s  (2x faster!)

Conclusion: For CPU-bound tasks, use multiprocessing to escape the GIL. For I/O-bound tasks (network, disk), threading or asyncio is sufficient.

Example 18: The ast Module --- Inspecting the Parse Tree
import ast
 
source = """
x = 10
y = x * 2 + 1
print(y)
"""
 
tree = ast.parse(source)
print(ast.dump(tree, indent=2))
# Module(body=[
#   Assign(targets=[Name(id='x', ...)], value=Constant(value=10)),
#   Assign(targets=[Name(id='y', ...)],
#          value=BinOp(
#            left=BinOp(left=Name(id='x'), op=Mult(), right=Constant(2)),
#            op=Add(), right=Constant(1))),
#   Expr(value=Call(func=Name(id='print'), args=[Name(id='y')]))
# ])
 
# Walk the AST to find all function calls
for node in ast.walk(tree):
    if isinstance(node, ast.Call):
        if isinstance(node.func, ast.Name):
            print(f"Call: {node.func.id}")   # Call: print

This shows exactly what CPython's parser produces before the compiler converts the AST to bytecode instructions.

Running Python

Interactive Mode (REPL)

REPL --- Read-Eval-Print Loop

REPL stands for Read-Eval-Print Loop. Python's interactive interpreter starts when you run python3 (or python) with no arguments. Each expression you type is read, evaluated, and its result printed before the prompt reappears.

Key features:

  • Immediate feedback — ideal for exploration and debugging

  • _ (underscore) stores the last expression result

  • help(obj) shows inline documentation

  • dir(obj) lists all attributes/methods

  • Enhanced REPLs: IPython, Jupyter Notebook (cells = mini-REPL)

  • Exit with exit(), quit(), or Ctrl+D (Unix) / Ctrl+Z (Windows)

diagram
Example 19: REPL --- Underscore and Quick Exploration
>>> 100 / 7
14.285714285714286
>>> round(_, 2)         # _ holds last result
14.29
>>> import math
>>> math.factorial(10)
3628800
>>> _ / math.factorial(9)   # _ = 3628800
10.0
>>> type(_)
<class 'float'>
>>> dir(math)[:6]
['__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']

Script Mode and __name__

Script Mode and if __name__ == "__main__"

Script mode: Python executes a .py file top to bottom when run as:
python3 script.py

Every module has a built-in attribute __name__:

  • When a file is run directly: __name__ is set to "__main__"

  • When a file is imported: __name__ is set to the module name (e.g.\ "utils")

The guard if __name__ == "__main__": lets you write code that runs only when the file is the entry point, not when it is imported as a library. This is the standard Python idiom for making a file both importable and executable.

diagram
Script Mode Execution Characteristics
  • Command-line arguments passed via sys.argv (list of strings; sys.argv[0] is the script name)

  • python3 -c "print('hello')" runs a one-liner without a file

  • python3 -m module runs a module as a script (e.g.\ python3 -m http.server)

  • python3 -i script.py runs the script then drops into REPL

  • Return code: 0 on success; non-zero on error (sys.exit(code))

Example 20: Script with __main__ Guard
# ---------- utils.py ----------
def add(a, b):
    return a + b
 
def multiply(a, b):
    return a * b
 
if __name__ == "__main__":
    # Only runs when: python3 utils.py
    # Skipped when:   import utils
    print("Testing utils module...")
    print(add(3, 4))       # 7
    print(multiply(3, 4))  # 12
# ---------- main.py ----------
import utils                  # __name__ in utils is "utils"
                               # guard block does NOT run
 
result = utils.add(10, 20)
print(result)                  # 30
Example 21: Command-line Arguments with sys.argv
# calculator.py
import sys
 
if len(sys.argv) != 4:
    print("Usage: python3 calculator.py <num1> <op> <num2>")
    sys.exit(1)
 
a  = float(sys.argv[1])
op =       sys.argv[2]
b  = float(sys.argv[3])
 
ops = {'+': a+b, '-': a-b, '*': a*b, '/': a/b}
if op not in ops:
    print(f"Unknown operator: {op}")
    sys.exit(2)
 
print(f"{a} {op} {b} = {ops[op]}")
 
# Usage:  python3 calculator.py 10 + 3.5
# Output: 10.0 + 3.5 = 13.5

Variables & Assignment

Naming Rules

Identifier Rules

Python Identifier Rules

An identifier is a name given to a variable, function, class, or module. Python's lexical rules:

  1. Must begin with a letter (a–z, A–Z) or underscore (_)

  2. Subsequent characters may be letters, digits (0–9), or underscores

  3. No spaces, hyphens, or special characters

  4. Case-sensitive: count, Count, COUNT are three distinct names

  5. Reserved keywords cannot be used as identifiers

Keywords (Python 3.12): False, None, True, and, as, assert, async, await, break, class, continue, def, del, elif, else, except, finally, for, from, global, if, import, in, is, lambda, nonlocal, not, or, pass, raise, return, try, while, with, yield

diagram
Naming Conventions (PEP 8)
  • snake_case — variables and functions: total_marks, get_data()

  • PascalCase (CapWords) — classes: NeuralNetwork, DataLoader

  • UPPER_SNAKE_CASE — constants: MAX_EPOCHS, PI

Example 22: Identifier Validity and keyword Module
import keyword
 
# Check all keywords
print(keyword.kwlist)
# ['False', 'None', 'True', 'and', 'as', 'assert', ...]
 
# Check if a name is a keyword
print(keyword.iskeyword("for"))      # True
print(keyword.iskeyword("forEach"))  # False  (valid identifier)
print(keyword.iskeyword("GATE"))     # False
 
# Python is case-sensitive
Value = 100
value = 200
VALUE = 300
print(Value, value, VALUE)   # 100 200 300 -- three separate variables
 
# Soft keywords (Python 3.10+): valid identifiers, context-dependent
# match, case, type -- can still be used as variable names
match = 5       # valid (match is a soft keyword)
case  = "test"  # valid
print(match, case)

Assignment Mechanics

Variable as Name Binding

Variable = Name Bound to an Object

Unlike C/Java where a variable is a typed memory box, a Python variable is simply a name stored in a namespace dictionary that refers (points) to an object in memory.

Assignment does not copy data — it binds a name to an existing (or newly created) object.

Consequences:

  • Re-assigning a name does not modify the old object

  • Multiple names can point to the same object

  • Passing a variable to a function passes the reference (call-by-object-reference / call-by-sharing)

diagram
Assignment Forms in Python
  • Simple: x = 10

  • Multiple (chained): a = b = c = 0 — all names bound to the same object

  • Augmented: x += 1 — equivalent to x = x + 1 for immutables; in-place mutation for mutables (list, set, dict)

  • Tuple/simultaneous: a, b = 1, 2 — RHS fully evaluated before any binding

  • Extended unpacking: first, *rest = iterable — * captures remaining items as a list

  • Walrus operator (:=, PEP 572, Python 3.8+): assigns and returns in one expression — used in while/if

Example 23: All Assignment Forms in Action
# Simple
x = 10
 
# Multiple (all point to the same object)
a = b = c = []
a.append(1)
print(b)           # [1]  -- b IS a (same object!)
 
# Augmented assignment
n = 5
n += 3             # n = 8
n **= 2            # n = 64
n //= 7            # n = 9
print(n)           # 9
 
# Tuple / simultaneous swap
x, y = 10, 20
x, y = y, x       # swap -- RHS evaluated first as (20,10)
print(x, y)        # 20 10
 
# Extended unpacking
first, *middle, last = [1, 2, 3, 4, 5]
print(first)       # 1
print(middle)      # [2, 3, 4]
print(last)        # 5
 
# Nested unpacking
(a, b), c = (1, 2), 3
print(a, b, c)     # 1 2 3
 
# Walrus operator (Python 3.8+)
data = [1, 3, 7, 9, 2]
if (n := len(data)) > 3:
    print(f"List has {n} elements")   # List has 5 elements
Example 24: Augmented Assignment --- Mutable vs Immutable Difference
# IMMUTABLE (int, str, tuple): += creates a new object
x = 5
print(id(x))   # e.g., 9789408
x += 1
print(id(x))   # different id -- new int object created
 
# MUTABLE (list): += mutates IN-PLACE (__iadd__)
lst = [1, 2, 3]
print(id(lst))   # e.g., 140391234
lst += [4, 5]    # calls lst.__iadd__([4,5]) -- same object!
print(id(lst))   # SAME id
print(lst)       # [1, 2, 3, 4, 5]
 
# Compare with lst = lst + [4,5] which creates a NEW list
lst2 = [1, 2, 3]
old_id = id(lst2)
lst2 = lst2 + [4, 5]   # creates new object
print(id(lst2) == old_id)  # False

Object Identity vs Equality

== vs is --- Equality vs Identity
  • == calls __eq__: tests whether two objects have the same value

  • is compares id(): tests whether two names refer to the exact same object in memory

Rule of thumb:

  • Use == for value comparison in almost all cases

  • Use is only for None, True, False singletons

  • Never use is to compare integers, strings, or custom objects (behaviour depends on implementation internals)

diagram
Integer Caching (CPython Implementation Detail)
  • CPython pre-allocates singleton objects for integers $\mathbf{-5}$ to $\mathbf{256}$

  • Any reference to these values in CPython always points to the same cached object

  • Outside this range, each literal may create a new object

  • This is an implementation detail of CPython, not guaranteed by the Python language specification

  • String interning works similarly: short strings and identifiers are often interned (same object); can be forced with sys.intern()

Example 25: is vs == --- When They Differ
# Cached integers: is works (coincidentally)
a, b = 100, 100
print(a == b)    # True  (same value)
print(a is b)    # True  (same cached object in CPython)
 
# Uncached integers: is WRONG to use
c = 1000
d = 1000
print(c == d)    # True   (correct: value comparison)
print(c is d)    # False  (different objects -- NEVER rely on this!)
 
# None is a singleton -- use 'is'
def maybe():
    pass   # returns None implicitly
 
result = maybe()
print(result == None)    # True  (works but bad style)
print(result is None)    # True  (correct idiom)
print(result is not None)# False
 
# List comparison
x = [1, 2, 3]
y = [1, 2, 3]
z = x
print(x == y)    # True   (same values)
print(x is y)    # False  (different objects)
print(x is z)    # True   (z IS x -- same object)
Example 26: id() and Memory Address
import sys
 
x = "hello"
y = "hello"
# CPython interns short string literals
print(x is y)         # True (interned)
print(id(x), id(y))   # same address
 
# Force separate objects
a = "hello world"
b = "hello world"
# May or may not be interned -- don't rely on it
print(a is b)         # True in some Python versions (compile-time intern)
 
# Mutable objects are never shared (different id)
p = [1, 2]
q = [1, 2]
print(p is q)         # False always
print(id(p) != id(q)) # True
 
# sys.intern forces string interning
import sys
s1 = sys.intern("gate exam result")
s2 = sys.intern("gate exam result")
print(s1 is s2)       # True -- guaranteed by intern

Data Types

Numeric Types

int --- Arbitrary-Precision Integer

int --- Definition and Precision

Python's int type stores arbitrary-precision integers — there is no fixed size, no overflow, no INT_MAX. The only limit is available memory.

Internally CPython represents small integers as C long and switches to a multi-digit “bignum” representation for large values (using base $2^{30}$ digits).

Literal forms:

diagram

Underscore separator allowed anywhere for readability: 1_000_000, 0xFF_AA_BB.

diagram
Key int Properties and Methods
  • No overflow: 2**1000 is exact (a 302-digit number)

  • bin(n), oct(n), hex(n): convert to string in respective base

  • int(s, base): parse a string in given base: int("FF", 16) $\to$ 255

  • n.bit_length(): number of bits needed to represent $n$

  • n.bit_count() (3.10+): count of set bits (popcount)

  • n.to_bytes(length, byteorder): convert to bytes object

  • divmod(a,b): returns (a//b, a%b) in one call

  • Underscore literal: 1_000_000 for readability (ignored by Python)

Example 27: int --- Literals, Large Numbers, Bases
# Bases
print(0b1010)         # 10   (binary)
print(0o17)           # 15   (octal)
print(0xFF)           # 255  (hex)
print(0b1111_1111)    # 255  (binary with underscore)
 
# Arbitrary precision
big = 2 ** 100
print(big)            # 1267650600228229401496703205376
print(type(big))      # <class 'int'>
 
# No overflow (contrast with C int)
print(10 ** 50)
# 100000000000000000000000000000000000000000000000000
 
# int methods
n = 255
print(bin(n))               # '0b11111111'
print(oct(n))               # '0o377'
print(hex(n))               # '0xff'
print(n.bit_length())       # 8
print((0b101010).bit_count())  # 3  (three 1-bits)
 
# Parsing from string
print(int("FF", 16))   # 255
print(int("1010", 2))  # 10
print(int("17", 8))    # 15
 
# divmod
q, r = divmod(17, 5)
print(q, r)            # 3  2  (17 = 3*5 + 2)

float --- IEEE 754 Double Precision

float --- Definition and IEEE 754

Python's float corresponds to a C double (64-bit IEEE 754):

  • 1 sign bit, 11 exponent bits, 52 mantissa bits

  • Range: approximately $\pm 1.8 \times 10^{308}$

  • Precision: $\approx 15$–17 significant decimal digits

Key consequence: many decimal fractions cannot be represented exactly in binary floating point: \[\texttt{0.1 + 0.2 = 0.30000000000000004} \neq 0.3\] This is not a Python bug — it is fundamental to IEEE 754.

diagram
float Properties and Best Practices
  • Special values: float('inf'), float('-inf'), float('nan')

  • NaN: nan != nan is True; check with math.isnan(x)

  • Approximate comparison: use math.isclose(a, b, rel_tol=1e-9) never == for floats

  • round(x, n): rounds to $n$ decimal places (banker's rounding for .5: rounds to nearest even)

  • Decimal module: exact decimal arithmetic (financial/scientific use)

  • fractions.Fraction: exact rational arithmetic

  • Scientific notation: 1.5e3 $= 1500.0$, 2.5e-4 $= 0.00025$

Example 28: Float Precision and math.isclose
import math
 
% Floating-point representation error
print(0.1 + 0.2)              # 0.30000000000000004
print(0.1 + 0.2 == 0.3)       # False  (!!)
print(math.isclose(0.1+0.2, 0.3))  # True  (correct way)
 
% Special values
inf = float('inf')
nan = float('nan')
print(inf > 10**100)     # True
print(inf + inf)         # inf
print(inf - inf)         # nan
print(nan == nan)        # False  (NaN is never equal to anything)
print(math.isnan(nan))   # True
 
% Exact decimal arithmetic
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))   # 0.3  (exact!)
 
% Fraction arithmetic
from fractions import Fraction
print(Fraction(1,3) + Fraction(1,6))     # 1/2  (exact)
 
% round() uses banker's rounding
print(round(0.5))   # 0  (rounds to even)
print(round(1.5))   # 2  (rounds to even)
print(round(2.5))   # 2  (rounds to even)
print(round(3.5))   # 4  (rounds to even)
print(round(3.14159, 2))   # 3.14

complex --- Complex Numbers

complex --- Definition

Python has native complex number support. A complex literal is written as a + bj (using j for the imaginary unit $i$): \[\texttt{z = 3 + 4j} \quad\Rightarrow\quad z = 3 + 4i\] Both real and imaginary parts are stored as float.

complex Properties
  • z.real: real part (float)

  • z.imag: imaginary part (float)

  • abs(z): modulus $|z| = \sqrt{a^2 + b^2}$

  • z.conjugate(): returns $a - bj$

  • complex(a, b): constructor: complex(3, 4) $\to$ (3+4j)

  • All arithmetic operators work: +, -, *, /

  • cmath module for complex-aware sqrt, exp, log

Example 29: Complex Numbers --- Arithmetic and cmath
import cmath, math
 
z1 = 3 + 4j
z2 = 1 - 2j
 
print(z1.real, z1.imag)    # 3.0  4.0
print(abs(z1))             # 5.0  (= sqrt(9+16))
print(z1.conjugate())      # (3-4j)
 
% Arithmetic
print(z1 + z2)   # (4+2j)
print(z1 * z2)   # (11-2j)  = (3*1-4*(-2)) + (3*(-2)+4*1)j
print(z1 / z2)   # (-1+2j)
 
% cmath: sqrt of negative number
print(cmath.sqrt(-1))       # 1j
print(cmath.sqrt(-4))       # 2j
print(cmath.exp(1j * math.pi))  # (-1+1.2e-16j)  ≈ Euler's formula e^(iπ)=-1

bool --- Boolean Type

bool --- Definition and Subclass Relationship

bool is a subclass of int: \[\texttt{True} \equiv 1, \quad \texttt{False} \equiv 0\]

Only two instances exist: True and False (singletons).

Truthiness — every object has a boolean interpretation in a conditional:

  • Falsy: 0, 0.0, 0j, "", [], {}, set(), frozenset(), None, range(0)

  • Truthy: everything else (non-zero numbers, non-empty containers, etc.)

Customise via __bool__ or __len__ in user-defined classes.

diagram
bool Properties
  • isinstance(True, int) returns True

  • Arithmetic: True + True == 2, False * 100 == 0

  • Useful for counting: loops tracking expressions where variable is greater than 0

  • bool(x) converts any object to True/False

  • Always use is True / is False only when you need exact boolean singleton check; otherwise use truthiness directly (if x: not if x == True:)

Example 30: bool Arithmetic and Counting
# bool is int subclass
print(type(True))            # <class 'bool'>
print(isinstance(True, int)) # True
print(True + True)           # 2
print(True * 10)             # 10
print(False + 5)             # 5
 
# Counting with bools
scores = [85, 42, 91, 55, 73, 88, 39]
passing = sum(s >= 60 for s in scores)
print(f"{passing} out of {len(scores)} passed")  # 4 out of 7 passed
 
# Truthiness in conditions
for val in [0, 1, "", "hi", [], [0], None, 0.0, {}, {"a":1}]:
    print(f"bool({val!r:12}) = {bool(val)}")
# bool(0)          = False
# bool(1)          = True
# bool('')         = False
# bool('hi')       = True
# ...

NoneType

None --- The Null Singleton

None is the sole instance of NoneType. It represents the absence of a value.

Always check with is, never ==:

  • x is None — correct

  • x == None — works but violates PEP 8 (and can be overridden by a class's __eq__)

Functions with no return statement implicitly return None.

None Properties
  • Singleton: id(None) is constant; None is None always True

  • Falsy: bool(None) == False

  • Common uses: default function argument, sentinel value, uninitialised state

  • Not the same as 0, False, "", [] (though all are falsy)

Example 31: None --- Implicit Return and Sentinel
# Implicit None return
def no_return():
    x = 10   # does something but no return
 
result = no_return()
print(result)           # None
print(type(result))     # <class 'NoneType'>
 
# None as default / sentinel
def search(lst, target):
    for i, val in enumerate(lst):
        if val == target:
            return i
    return None   # explicit None when not found
 
idx = search([10, 20, 30], 20)
if idx is not None:
    print(f"Found at index {idx}")   # Found at index 1
 
# None as optional argument default
def greet(name, title=None):
    if title is None:
        return f"Hello, {name}!"
    return f"Hello, {title} {name}!"
 
print(greet("Alice"))             # Hello, Alice!
print(greet("Alice", "Dr."))      # Hello, Dr. Alice!

Type Conversion

Implicit vs Explicit Type Conversion
  • Implicit (coercion): Python automatically converts when safe and unambiguous (only a few cases: integer added to float yields a float; bool in arithmetic)

  • Explicit (casting): programmer manually calls a constructor function to convert types

Key explicit conversion functions:

diagram
diagram
Type Inspection Functions
  • type(x): returns the exact class; does not consider inheritance (type(True) is bool, not int)

  • isinstance(x, T): returns True if x is an instance of T or any subclass of T; accepts a tuple of types: isinstance(x, (int, float))

  • issubclass(A, B): returns True if class A is a subclass of B; issubclass(bool, int) is True

  • Prefer isinstance over type(x) == for type-checking (more Pythonic; respects inheritance)

Example 32: Explicit Conversion --- Common Cases
# int conversions
print(int(3.9))       # 3   (truncates toward zero, NOT floor)
print(int(-3.9))      # -3  (truncates toward zero)
print(int("42"))      # 42
print(int("0xFF", 16))# 255
print(int(True))      # 1
 
# float conversions
print(float("3.14"))  # 3.14
print(float("1e-5"))  # 1e-05
print(float(True))    # 1.0
 
# str conversions
print(str(100))       # '100'
print(str([1,2,3]))   # '[1, 2, 3]'
print(str(None))      # 'None'
 
# bool conversions
print(bool(0))        # False
print(bool(-1))       # True
print(bool(""))       # False
print(bool("False"))  # True  (non-empty string!)
 
# list / set / tuple
print(list("gate"))   # ['g', 'a', 't', 'e']
print(set([1,2,2,3])) # {1, 2, 3}
print(tuple(range(5)))# (0, 1, 2, 3, 4)
 
# chr and ord
print(ord('A'))       # 65
print(chr(65))        # 'A'
print(chr(ord('a')+5))# 'f'
Example 33: type() vs isinstance()
print(type(True) == int)        # False  (bool, not int)
print(type(True) == bool)       # True
print(isinstance(True, int))    # True   (bool is subclass of int)
print(isinstance(True, bool))   # True
 
# isinstance with tuple of types
def process(x):
    if isinstance(x, (int, float)):
        return x ** 2
    elif isinstance(x, str):
        return x.upper()
    else:
        raise TypeError(f"Unsupported: {type(x)}")
 
print(process(5))       # 25
print(process(3.14))    # 9.8596
print(process("gate"))  # GATE
 
% issubclass
print(issubclass(bool, int))     # True
print(issubclass(int, object))   # True
print(issubclass(float, int))    # False

Operators

Arithmetic Operators

All Arithmetic Operators

Python Arithmetic Operators
diagram

Critical points:

  • / always returns float: 6/2 = 3.0

  • // floors towards negative infinity: -7 // 2 = -4 (not $-3$)

  • % sign matches divisor: -7 % 2 = 1 (not $-1$)

  • Invariant: (a // b) * b + (a % b) == a always holds

  • ** is right-associative: 2**3**2 = 2**(3**2) = 512

diagram
Floor Division and Modulo Sign Rules
  • Floor division rounds towards negative infinity (not towards zero):
    7 // 2 = 3,   -7 // 2 = -4,   7 // -2 = -4,   -7 // -2 = 3

  • Modulo sign matches the divisor (not dividend):
    7 % 2 = 1,   -7 % 2 = 1,   7 % -2 = -1,   -7 % -2 = -3

  • Invariant check: (a // b) * b + a % b == a

  • ** is right-associative: a**b**c = a**(b**c)

Example 34: Arithmetic --- Tricky Floor Division and Modulo
# True division always returns float
print(7 / 2)     # 3.5
print(6 / 2)     # 3.0  <-- float, not int!
print(6 / 3)     # 2.0
 
# Floor division towards -inf
print( 7 // 2)   #  3
print(-7 // 2)   # -4  (not -3!)
print( 7 //-2)   # -4
print(-7 //-2)   #  3
 
# Modulo sign follows divisor
print( 7 % 2)    #  1
print(-7 % 2)    #  1  (not -1!)
print( 7 %-2)    # -1
print(-7 %-2)    # -3
 
# Invariant: always holds
for a, b in [(7,2),(-7,2),(7,-2),(-7,-2)]:
    assert (a//b)*b + a%b == a, "invariant broken!"
print("Invariant holds for all cases")
 
# ** right-associative
print(2 ** 3 ** 2)   # 512  = 2**(3**2) = 2**9
print((2**3) ** 2)   # 64   = 8**2
 
# Exponentiation with float
print(16 ** 0.5)     # 4.0  (square root)
print(8 ** (1/3))    # 2.0  (cube root)

Comparison Operators

Comparison and Chaining

Comparison Operators

All comparison operators return True or False:

diagram

Chaining: Python allows chaining: 1 < x < 10 is evaluated as (1 < x) and (x < 10) — both conditions must hold. No redundant evaluation.

Example 35: Comparison Chaining
x = 5
# Chained comparison
print(1 < x < 10)       # True   == (1<5) and (5<10)
print(1 < x < 5)        # False  == (1<5) and (5<5) = True and False
print(1 < 2 < 3 < 4)    # True
print(10 > x >= 5)      # True
 
# Useful for range checks
def classify_score(score):
    if 90 <= score <= 100:
        return "A"
    elif 70 <= score < 90:
        return "B"
    elif 50 <= score < 70:
        return "C"
    else:
        return "F"
 
print(classify_score(85))   # B
print(classify_score(50))   # C
print(classify_score(100))  # A

Logical Operators

Short-Circuit Evaluation

Logical Operators and Short-Circuit Evaluation
diagram

Critical: and and or return the last evaluated 1386operand, not necessarily a boolean. This enables idiomatic patterns.

Short-circuit prevents evaluation of later expressions if result is already determined.

diagram
Logical Operator Patterns (Idiomatic Python)
  • Default value: x = val or default — use default if val is falsy

  • Conditional call: obj and obj.method() — only call if obj is not None/falsy

  • Ternary equivalent: a if cond else b (preferred over cond and a or b which has a bug when a is falsy)

  • Guard check: lst and lst[0] — safe first-element access

  • All-falsy: not any(lst) is equivalent to all(not x for x in lst)

Example 36: Short-Circuit Evaluation --- Return Value and Side Effects
# and returns first falsy or last value
print(0 and 5)        # 0     (0 is falsy -- short-circuit)
print(3 and 5)        # 5     (3 truthy; return last: 5)
print(3 and 0)        # 0     (3 truthy; return last: 0)
print("" and "hi")    # ''    (empty string falsy)
 
# or returns first truthy or last value
print(0 or 5)         # 5     (0 falsy; try next)
print(3 or 5)         # 3     (3 truthy -- short-circuit)
print(0 or "")        # ''    (both falsy; return last)
print([] or {})       # {}    (both falsy; return last)
 
# Practical patterns
name = ""
display = name or "Anonymous"
print(display)   # Anonymous
 
config = None
value = config and config.get("key")
print(value)     # None  (config is None, short-circuit)
 
# Short-circuit prevents errors
lst = []
first = lst and lst[0]   # safe: lst is falsy, lst[0] never evaluated
print(first)             # []
 
# NOT always returns bool
print(not 0)     # True
print(not 5)     # False
print(not "")    # True
print(not [1])   # False

Bitwise Operators

Bit-Level Operations

Bitwise Operators
diagram
diagram
Example 37: Bitwise Operators --- Practical Uses
a, b = 0b1010, 0b1100   # 10 and 12
 
print(bin(a & b))    # 0b1000  = 8   (AND: bits set in both)
print(bin(a | b))    # 0b1110  = 14  (OR:  bits set in either)
print(bin(a ^ b))    # 0b0110  = 6   (XOR: bits set in exactly one)
print(bin(~a))       # -0b1011 = -11 (NOT: ~n = -(n+1))
 
# Shifts = fast multiply/divide by powers of 2
print(3 << 2)        # 12   = 3 * 4
print(12 >> 2)       # 3    = 12 // 4
print(1 << 8)        # 256  = 2^8
 
# Practical: check if a number is even (fast)
n = 18
print(n & 1)         # 0 --> even (last bit is 0)
n = 17
print(n & 1)         # 1 --> odd
 
# Bit masking -- extract lower 4 bits
value = 0b10110111
lower4 = value & 0b00001111
print(bin(lower4))   # 0b111  = 7
 
# Swap without temp using XOR
x, y = 5, 9
x ^= y;  y ^= x;  x ^= y
print(x, y)          # 9 5

Identity & Membership Operators

is, in and their Negations

Identity and Membership Operators
diagram

Complexity of in:

diagram
Example 38: Identity and Membership --- All Containers
# Membership in various containers
print(3 in [1, 2, 3, 4])          # True  (list)
print(5 not in (1, 2, 3))         # True  (tuple)
print("gate" in {"gate", "exam"}) # True  (set)
print("name" in {"name": "Alice"})# True  (dict keys)
print("lo" in "hello")            # True  (substring)
 
# Performance: set lookup O(1) vs list O(n)
import time
data_list = list(range(10_000_000))
data_set  = set(range(10_000_000))
 
t0 = time.perf_counter()
_ = 9_999_999 in data_list
print(f"list: {time.perf_counter()-t0:.4f}s")   # ~0.1s
 
t0 = time.perf_counter()
_ = 9_999_999 in data_set
print(f"set : {time.perf_counter()-t0:.6f}s")   # ~0.000001s
 
# is: identity
a = [1, 2, 3]
b = a           # same object
c = [1, 2, 3]  # equal but different object
 
print(a is b)   # True
print(a is c)   # False
print(a == c)   # True

Operator Precedence

Full Precedence Table

Python Operator Precedence (Highest to Lowest)
diagram
diagram
Example 39: Precedence --- Surprising Evaluations
# ** before unary minus
print(-2 ** 2)     # -4  = -(2**2), NOT (-2)**2 = 4
print((-2) ** 2)   # 4   -- explicit override
 
# Comparison before logical
print(2 + 3 > 4 and 10 - 1 < 15)
# = (5 > 4) and (9 < 15) = True and True = True
 
# not before and before or
print(True or False and False)
# = True or (False and False) = True or False = True
print(not False or True and False)
# = (not False) or (True and False)
# = True or False = True
 
# Tricky: chained comparison
print(1 < 2 == 2 < 3)   # True (chained: 1<2 and 2==2 and 2<3)
 
# is before not
x = None
print(x is not None)   # False  (is not is ONE operator, not "is" and "not")
print(not x is None)   # False  but means not (x is None) -- confusing!
# Always use: x is not None  (not: not x is None)
 
# Bitwise before comparison
print(2 & 3 == 3)    # True: 2 & (3 == 3) = 2 & True = 2 & 1 = 0? No!
# Actually: (2 & 3) == 3 ... wait: precedence: == is LOWER than &
# 2 & 3 = 2; 2 == 3 = False  -- correct parse
print((2 & 3) == 3)  # False  (2 & 3 = 2, not 3)
print(2 & (3 == 3))  # 2 & True = 2 & 1 = 0... as int, but this is confusing
# Use parentheses whenever mixing bitwise and comparison
Example 40: Precedence --- Using Parentheses Correctly
# Misleading without parens
result = 5 + 3 * 2 ** 2 - 1
# = 5 + 3 * 4 - 1
# = 5 + 12 - 1
# = 16
print(result)   # 16
 
# With explicit parens for clarity
result = 5 + (3 * (2 ** 2)) - 1
print(result)   # 16 (same)
 
# Different meaning with different parens
print((5 + 3) * (2 ** 2) - 1)   # 8 * 4 - 1 = 31
print(5 + 3 * 2 ** (2 - 1))     # 5 + 3*2 = 11
 
# Short-circuit with mixed operators
x = 5
print(x > 3 and x < 10 or x == 5)
# = (x>3 and x<10) or (x==5)
# = (True and True) or True
# = True or True = True
 
# Rule: when in doubt, use parentheses!