Unit 4 · 7 teaching hours · 18 min read
Functions, Modules and Packages
What the syllabus asks for 7
- Defining and calling functions
- Scope: the LEGB rule
- Recursion
- Anonymous functions and functional tools
- Closures, decorators and generators
- Modules and packages
- Seeing the ideas: bricks, scopes and the call stack
A function is a named, reusable piece of code. Functions let you write a solution once and use it a hundred times, test it separately and give a complicated idea a short name.
Modules group related functions into files, and packages group modules into folders, which is how a large program is kept understandable.
- Defining and calling functions; parameters, default and keyword arguments,
*argsand**kwargs, return values. - Scope (LEGB), recursion, lambda,
map/filter, closures, decorators, generators. - Modules, packages, the
__name__idiom, and a tour of the standard library.
4.1 Defining and calling functions
def area(length, width=1):
"""Return the area of a rectangle."""
return length * width
print(area(5, 3), area(4), area(width=2, length=10))
print(area.__doc__)
15 4 20
Return the area of a rectangle.
def creates a function object. Parameters are the names in the definition; arguments are the values passed. Arguments can be positional or keyword (name=value); a parameter with a default may be omitted.
A function without return returns None. A function can return several values (really one tuple).
def stats(numbers):
return min(numbers), max(numbers), sum(numbers) / len(numbers)
lo, hi, mean = stats([4, 8, 15, 16, 23, 42])
print(lo, hi, round(mean, 2))
print(stats([1, 2, 3]))
4 42 18.0
(1, 3, 2.0)
Flexible parameter lists
def total(*args, **kwargs):
print("args:", args, "kwargs:", kwargs)
return sum(args) * kwargs.get("scale", 1)
print(total(1, 2, 3))
print(total(1, 2, 3, scale=10))
def greet(name, /, greeting="Hello", *, punctuation="!"):
return f"{greeting}, {name}{punctuation}"
print(greet("Sita"), greet("Ram", "Namaste", punctuation="."))
nums = [3, 4]
print(max(*nums), dict(**{"a": 1}))
args: (1, 2, 3) kwargs: {}
6
args: (1, 2, 3) kwargs: {'scale': 10}
60
Hello, Sita! Namaste, Ram.
4 {'a': 1}
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. Parameters before / are positional-only; those after a bare * are keyword-only. At a call, *seq and **mapping unpack.
The mutable-default trap
Default values are created once, when the function is defined. A mutable default is therefore shared between calls.
def bad(item, bucket=[]):
bucket.append(item)
return bucket
def good(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(bad(1), bad(2), bad(3))
print(good(1), good(2), good(3))
[1, 2, 3] [1, 2, 3] [1, 2, 3]
[1] [2] [3]
Arguments are passed by object reference
A function receives references to the caller's objects. Mutating a mutable argument changes the caller's object; rebinding the parameter name does not.
def modify(lst, n):
lst.append(99)
n += 1
lst = [0]
return n
a, k = [1, 2], 10
print(modify(a, k), a, k)
11 [1, 2, 99] 10
4.2 Scope: the LEGB rule
Names are looked up in order: Local (the current function), Enclosing (outer functions), Global (module), Built-in. Assigning to a name inside a function makes it local unless declared global or nonlocal.
x = "global"
def outer():
x = "enclosing"
def inner():
nonlocal x
x = "changed by inner"
inner()
return x
counter = 0
def bump():
global counter
counter += 1
bump(); bump()
print(outer(), x, counter)
changed by inner global 2
Avoid global where you can; pass values in and return results.
4.3 Recursion
A function that calls itself, with a base case that stops and a recursive case that moves toward it.
def fact(n):
return 1 if n <= 1 else n * fact(n - 1)
def fib(n, memo={0: 0, 1: 1}):
if n not in memo:
memo[n] = fib(n - 1) + fib(n - 2)
return memo[n]
def hanoi(n, a, b, c):
return 0 if n == 0 else hanoi(n - 1, a, c, b) + 1 + hanoi(n - 1, c, b, a)
print(fact(10), fib(50), hanoi(10, "A", "B", "C"))
import sys
print(sys.getrecursionlimit())
3628800 12586269025 1023
1000
The deliberate use of a mutable default for memo is a memoisation cache. Python limits recursion depth (about 1000 frames by default), so very deep recursion should be rewritten as a loop. fib(50) would take minutes without the cache.
4.4 Anonymous functions and functional tools
A lambda is a one-expression anonymous function: lambda x, y: x + y. Common with sorted, map, filter.
square = lambda x: x * x
nums = [1, 2, 3, 4, 5, 6]
print(square(7), list(map(square, nums)), list(filter(lambda n: n % 2, nums)))
words = ["fig", "banana", "kiwi", "apple"]
print(sorted(words, key=len), max(words, key=lambda w: w[-1]))
from functools import reduce
print(reduce(lambda a, b: a * b, nums), [n * n for n in nums if n % 2])
49 [1, 4, 9, 16, 25, 36] [1, 3, 5]
['fig', 'kiwi', 'apple', 'banana'] kiwi
720 [1, 9, 25]
Comprehensions are often clearer than map/filter.
4.5 Closures, decorators and generators
A closure is an inner function that remembers variables of the enclosing function. A decorator wraps a function to add behaviour.
import functools
def make_counter():
count = 0
def step():
nonlocal count
count += 1
return count
return step
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"{func.__name__}{args} -> {result}")
return result
return wrapper
@logged
def add(a, b):
return a + b
c = make_counter()
print(c(), c(), c())
add(2, 3)
1 2 3
add(2, 3) -> 5
A generator function uses yield to produce values one at a time and remembers its state between them. It is memory-efficient for long or infinite sequences.
def evens(limit):
n = 0
while n < limit:
yield n
n += 2
g = evens(10)
print(next(g), next(g), list(g))
print(sum(x * x for x in range(1_000_000)))
0 2 [4, 6, 8]
333332833333500000
4.6 Modules and packages
A module is a file of Python code (mylib.py). import mylib runs it once and gives access through mylib.name; from mylib import name imports a name directly; import mylib as m aliases it.
Python searches the current directory, then sys.path (installed packages, standard library).
The following program creates a small module on disk, imports it, and shows the __name__ idiom: a file's __name__ is "__main__" when it is run directly and its module name when imported, so test code can be guarded.
import importlib, pathlib, sys
pathlib.Path("shapes.py").write_text('''
PI = 3.14159
def circle_area(r):
return PI * r * r
if __name__ == "__main__":
print("running directly:", circle_area(1))
''')
sys.path.insert(0, ".")
import shapes
print(shapes.__name__, shapes.circle_area(2), shapes.PI)
from shapes import circle_area as ca
print(ca(10))
print(sorted(n for n in dir(shapes) if not n.startswith("_")))
shapes 12.56636 3.14159
314.159
['PI', 'circle_area']
A package is a directory containing modules (and traditionally an __init__.py); import pkg.sub.mod reaches into it. Install third-party packages with pip install name (use a virtual environment, python -m venv venv, to keep each project's packages separate).
A tour of the standard library
import math, random, datetime, collections, itertools, statistics, string
random.seed(7)
print(math.hypot(3, 4), math.factorial(6), round(math.log(100, 10), 3), math.isclose(0.1 * 3, 0.3))
print(random.randint(1, 6), random.choice("abc"), random.sample(range(10), 3))
d = datetime.date(2026, 11, 2)
print(d.strftime("%A %d %B %Y"), (d + datetime.timedelta(days=100)).isoformat())
print(collections.Counter("mississippi").most_common(2))
print(list(itertools.permutations("abc", 2))[:4], list(itertools.combinations(range(4), 2)))
print(statistics.mean([2, 4, 4, 6]), statistics.median([1, 3, 9]), string.ascii_lowercase[:5])
5.0 720 2.0 True
3 a [6, 0, 1]
Monday 02 November 2026 2027-02-10
[('i', 4), ('s', 4)]
[('a', 'b'), ('a', 'c'), ('b', 'a'), ('b', 'c')] [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
4 3 abcde
random.seed(7) makes the "random" numbers reproducible. Other useful modules: os and pathlib (files), sys, json, csv, re, sqlite3, argparse, logging, unittest, urllib, decimal, fractions.
4.7 Seeing the ideas: bricks, scopes and the call stack
A function is a brick: a small, named piece that you can build, test and reuse, and a module or package is a box of such bricks. The plate shows real bricks beside the scope rule that decides which names a function can see.
The figures then follow a recursive call down and back up the call stack, and show why a naive recursive Fibonacci repeats work that a cache removes.

from functools import lru_cache
import sys
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
@lru_cache(maxsize=None)
def fibc(n):
return n if n < 2 else fibc(n - 1) + fibc(n - 2)
print(fib(10), fibc(30), fibc.cache_info().misses)
print(sys.getrecursionlimit())
55 832040 31
1000
Recursion has a price (stack frames) and a trick that removes most of it (remembering answers). The plate runs both for real.

4.7.1 Eight advantages of using functions and modules
- Reuse of code. A function is written once and called many times, so a fee-calculation routine does not have to be copied into five places. When the rule changes, you edit one function. This lowers both the effort and the number of bugs.
- Easier problem solving. A large program is broken into small, named steps such as
read_marks(),average()andprint_report(). Each step can be understood on its own. This is the same idea that the Software Engineering book calls modular design. - Easier testing and debugging. A small function can be called with known inputs and its output checked at once (Unit 8). When a result is wrong, you know which brick to open. A fault inside one long script is much harder to find.
- Readable programs. A line such as
total = sum_of_digits(num)explains itself better than four lines of arithmetic. Good names serve as documentation. A new team member can follow the program from the top-level calls. - Hiding detail (abstraction). The caller needs to know what a function does, not how. A Nagarik App style function
verify_citizenship(no)may change its inside completely without changing the callers. The weakness is that a badly named function hides detail that a reader needs. - Controlled variables (scope). Names created inside a function are local, so two functions can both use
iortotalwithout clashing. The LEGB rule decides what each name means. This prevents many accidental changes to global values. - Teamwork through modules. Each student writes one module, such as
fees.pyorreport.py, and the group joins them withimport. A package groups related modules in a folder with__init__.py. Work can go on in parallel with fewer merge problems. - A huge ready-made library.
import math,datetime,random,json,csvandstatisticsgive hundreds of tested functions for free, andpipadds more from PyPI. Using a tested module is faster and safer than writing your own. Check the licence and the version of any third-party package before you depend on it.
Formula and fact sheet
def f(a, b=1, *args, **kw): return …; noreturn→None.- Arguments are references; never use a mutable default (use
None). - LEGB lookup;
global/nonlocalto rebind outer names. - Recursion = base case + progress; memoise repeated work.
lambda,map,filter,sorted(key=…); decorators wrap functions;yieldmakes generators.if __name__ == "__main__":guards script-only code.
Chapter summary
- Functions package logic, with flexible parameter passing, scopes that follow LEGB, and first-class function objects.
- Recursion, closures, decorators and generators are the building blocks of idiomatic Python.
- Modules and packages organise code across files; the standard library covers most everyday tasks.
Practice
- Write a function
is_palindrome(s)and test it on three words. - What is printed by a function that appends to a default empty list called three times? How do you fix it?
- Explain
*argsand**kwargswith an example. - Write a recursive function to compute the sum of the digits of a non-negative integer.
- Use
sortedwith alambdato sort names by length and then alphabetically. - What is a decorator? Write one that times a call (print only that it ran, to keep the output fixed).
- Explain LEGB and what
nonlocaldoes. - What does
if __name__ == "__main__":do, and why is it useful?
Answers and worked solutions
1.
def is_palindrome(s):
s = s.lower()
return s == s[::-1]
print([is_palindrome(w) for w in ("Level", "momo", "radar")])
[True, False, True]
2. The default list is created once, so the calls return [1], [1, 2], [1, 2, 3] (the same list). Fix: default to None and create a new list inside (Section 4.1).
3. *args gathers extra positional arguments into a tuple, **kwargs gathers extra keyword arguments into a dict:
def show(*args, **kwargs):
return len(args), sorted(kwargs)
print(show(1, 2, 3, a=1, b=2))
(3, ['a', 'b'])
4.
def digit_sum(n):
return n if n < 10 else n % 10 + digit_sum(n // 10)
print(digit_sum(98765))
35
5.
names = ["Sita", "Ram", "Hari", "Anita", "Bo"]
print(sorted(names, key=lambda s: (len(s), s)))
['Bo', 'Ram', 'Hari', 'Sita', 'Anita']
6. A decorator is a function that takes a function and returns a wrapped version:
import functools
def timed(f):
@functools.wraps(f)
def w(*a, **k):
r = f(*a, **k)
print(f"{f.__name__} ran")
return r
return w
@timed
def work(n):
return sum(range(n))
print(work(1000))
work ran
499500
7. Python looks for a name in the Local, then Enclosing, then Global and finally Built-in scope. nonlocal x in an inner function makes assignments to x change the enclosing function's variable instead of creating a local one.
8. The module-level __name__ is "__main__" only when the file is run directly. The guard lets a file be both a reusable module (imported without side effects) and a runnable script.
Model answers (5 and 10 marks)
Q1. Explain the kinds of function arguments in Python with a program. (5 marks)
A parameter is a name in the def line; an argument is the value passed in the call. Python offers five kinds.
- Positional: matched by position,
area(3, 4). - Keyword: matched by name,
area(h=4, w=3), so the order does not matter. - Default:
def area(w, h=1)uses 1 whenhis omitted. A default is evaluated once, when the function is defined, so never use a mutable default such as[]; useNoneand create the list inside. - Variable positional
*args: collects extra positional arguments into a tuple. - Variable keyword
**kwargs: collects extra keyword arguments into a dictionary.
The order in a definition is: normal parameters, defaults, *args, keyword-only, **kwargs. Python passes object references: a mutable argument such as a list can be changed inside the function, but rebinding the parameter name does not affect the caller.
def report(name, marks=0, *extra, **info):
print(name, marks, extra, info)
report("Sita")
report("Ram", 72, 5, 6, section="A", roll=7)
report(marks=90, name="Hari")
def add_item(item, box=None):
box = [] if box is None else box
box.append(item)
return box
print(add_item(1), add_item(2))
Sita 0 () {}
Ram 72 (5, 6) {'section': 'A', 'roll': 7}
Hari 90 () {}
[1] [2]
Q2. Explain the scope of variables and the LEGB rule. How do global and nonlocal work? (5 marks)
The scope of a name is the part of the program where it can be used. Python searches in the order Local, Enclosing, Global, Built-in (Plate 4.1b) and stops at the first match.
- Local: names assigned inside a function exist only while it runs.
- Enclosing: names of an outer function seen by an inner function (a closure).
- Global: names at the top level of the module.
- Built-in:
len,print,rangeand so on.
Reading a global name inside a function is allowed.
Assigning to a name inside a function creates a new local one, unless the name is declared. global x makes the assignment change the module-level x. nonlocal n makes it change the variable of the nearest enclosing function.
Without the declaration, assigning to a variable that is also read earlier in the function raises UnboundLocalError.
x = "global"
def outer():
y = "outer"
def inner():
nonlocal y
y = "changed by inner"
z = "inner"
return z, y, x, len(x)
result = inner()
return result, y
def bump():
global x
x = "global changed"
print(outer())
bump()
print(x)
(('inner', 'changed by inner', 'global', 6), 'changed by inner')
global changed
Advice: prefer parameters and return values to global variables, because they make a function easy to test.
Q3. What is recursion? Explain it with the factorial and Fibonacci programs, the call stack, and compare it with iteration. (10 marks)
Introduction. Recursion is a technique in which a function calls itself to solve a smaller copy of the same problem.
Every recursive function needs a base case, which returns without calling again, and a recursive case, which moves towards the base case.
Without a base case the calls never stop and Python raises RecursionError at its limit (1000 frames by default).
Factorial. n! = n x (n-1)!, with 0! = 1! = 1. The call factorial(4) pushes four frames; the base case returns 1 and the frames pop, giving 2, 6 and 24 (Fig. 4.1).
Fibonacci. fib(n) = fib(n-1) + fib(n-2) with fib(0) = 0 and fib(1) = 1. The plain version calls itself twice, so the same sub-problems are repeated: fib(5) makes 15 calls, and fib(30) makes more than 2.6 million (Fig. 4.2).
Memoisation with functools.lru_cache stores each result and reduces the work to 31 calls for fib(30).
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
def fib_loop(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(factorial(4), factorial(10))
print([fib(i) for i in range(10)])
print(fib(20) == fib_loop(20), fib_loop(90))
24 3628800
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
True 2880067194370816120
The call stack. Each call stores its parameters, local names and return address in a stack frame. Deep recursion uses a lot of memory, which is the reason for the limit.
Comparison.
| Point | Recursion | Iteration |
|---|---|---|
| Repetition by | function calls itself | loop statement |
| Termination | base case | loop condition |
| Memory | one stack frame per call | constant |
| Speed | slower (call overhead) | faster |
| Code | short for trees, divide and conquer | simple for counting |
| Risk | RecursionError, repeated work | infinite loop |
Where recursion is natural: tree and folder traversal, the Tower of Hanoi, quick sort and merge sort, and parsing expressions.
Conclusion. Use recursion when the problem is itself defined in terms of smaller copies of itself and the depth is small; otherwise use a loop, or add a cache when sub-problems repeat.