Notifications

No notifications

/Phase 3

Functions in Python

Functions in Python

Functions are reusable blocks of code that perform a specific task. They help you write DRY (Don't Repeat Yourself) code, improve readability, and make programs modular.

Defining Functions

def greet(name):
    return f"Hello, {name}!"

print(greet("Alice")) # Hello, Alice!

Parameters & Arguments

TypeSyntaxExample
Positionaldef f(a, b)f(1, 2)
Defaultdef f(a, b=10)f(1) → b is 10
Keyworddef f(a, b)f(b=2, a=1)
\*argsdef f(*args)Tuple of extra positional args
\*\*kwargsdef f(**kwargs)Dict of extra keyword args

Return Values

def divide(a, b):
    if b == 0:
        return None
    return a / b, a % b   # Returns a tuple

quotient, remainder = divide(17, 5)

Lambda Functions

Anonymous one-line functions:

square = lambda x: x ** 2
add = lambda a, b: a + b

Higher-Order Functions

nums = [1, 2, 3, 4, 5]
evens = list(filter(lambda x: x % 2 == 0, nums))
doubled = list(map(lambda x: x * 2, nums))

Decorators

Decorators wrap a function to extend its behavior without modifying it:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logger def add(a, b): return a + b

Closures

A closure is a function that remembers variables from its enclosing scope:

def multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

double = multiplier(2) print(double(5)) # 10

> Tip: Keep functions short and focused — each function should do one thing well. Use docstrings to document parameters and return values.

On this page

Detailed Theory

Functions are *named, reusable bundles of code*. Whenever you find yourself copy-pasting the same logic, that logic wants to be a function. Python's functions are also unusually flexible — first-class objects, default args, keyword-only args, decorators — so it pays to learn them properly.

What a Function Actually Is

def greet(name):
    return f"hi, {name}"

print(greet("Alice")) # hi, Alice

A function is an object created by def. It has a name, a parameter list, a body, and (optionally) a return value. Calling it runs the body with the arguments bound to parameters.

Parameters & Arguments

def order(item, qty=1, *, gift=False):
    ...

order("book") # positional order("book", 3) # positional + positional order("book", qty=3) # positional + keyword order("book", qty=3, gift=True) # full keyword

Key ideas:

  • Positional — matched by order.
  • Keyword — matched by name (qty=3).
  • Default valueqty=1 makes qty optional.
  • Keyword-only — anything after * must be passed by name.

Returning Values

def divide(a, b):
    if b == 0:
        return None              # explicit "no result"
    return a / b

def stats(nums): return min(nums), max(nums) # tuple — unpack at the call site low, high = stats([3, 1, 4, 1, 5])

No return → returns None. Multiple values are returned as a tuple.

Beginner Mistakes to Skip

1. Mutable default arguments. def f(items=[]): shares one list across all calls. Use items=None and assign inside. 2. return inside a loop without thinking. A function exits as soon as return runs. 3. Missing return. If you forget it, the function returns None and the caller gets a confusing AttributeError later. 4. Shadowing builtins as parameter names. def f(list, dict, type): works but breaks readability; rename them. 5. Modifying caller's list "by accident". Lists are passed by reference — mutating inside the function changes it outside. Copy if you need isolation. 6. Calling a function vs referencing it. schedule(do_thing) vs schedule(do_thing()) — the former passes the function, the latter passes its result.

Intermediate: *args and **kwargs

def log(level, *messages, **fields):
    print(level, messages, fields)

log("INFO", "started", "port=8080", user="alice") # INFO ('started', 'port=8080') {'user': 'alice'}

  • *args — collect extra positional args into a tuple.
  • kwargs — collect extra keyword** args into a dict.
  • At call sites, the same syntax unpacks an iterable / dict: f(*nums), f(**config).

Intermediate: Type Hints & Docstrings

def discount(price: float, pct: float = 10.0) -> float:
    """Return price after applying a percentage discount."""
    return price * (1 - pct / 100)

Hints are optional but make IDE autocomplete and mypy/pyright checks much sharper. Docstrings (the triple-quoted line right after def) show up in help(fn) and on hover — future-you will thank present-you.

Intermediate: Scope (LEGB)

When Python looks up a name inside a function, it checks Local → Enclosing → Global → Built-in:

x = 10                 # global
def outer():
    x = 20             # enclosing
    def inner():
        # x = 30       # would shadow as local
        print(x)        # prints 20 (enclosing)
    inner()

  • global x — rebind the global from inside a function.
  • nonlocal x — rebind the nearest enclosing x (used in closures).

Intermediate: Lambdas

square = lambda x: x * x
sorted(users, key=lambda u: u["age"])

lambda is a one-expression anonymous function. Use it for short callbacks; for anything multi-line, write a proper def.

Advanced: First-Class Functions & Higher-Order

Functions are objects — you can pass them around:

def apply_twice(fn, x):
    return fn(fn(x))

apply_twice(str.upper, "hi") # 'HI'

handlers = {"GET": handle_get, "POST": handle_post} handlers[method](request)

Dispatch tables and callback patterns are everywhere in real code (sorting keys, event handlers, route maps).

Advanced: Closures & nonlocal

def counter(start=0):
    n = start
    def step():
        nonlocal n
        n += 1
        return n
    return step

c = counter() c(); c(); c() # 1, 2, 3

A closure is a function that captures variables from its enclosing scope. Useful for stateful callbacks without classes.

Advanced: Decorators

A decorator is a function that takes a function and returns a (usually wrapped) function. The @ syntax is sugar:

import functools, time

def timed(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): t = time.perf_counter() out = fn(*args, **kwargs) print(f"{fn.__name__} took {time.perf_counter()-t:.3f}s") return out return wrapper

@timed def heavy(n): return sum(range(n))

functools.wraps preserves the original name and docstring. Standard-library decorators worth knowing: @staticmethod, @classmethod, @property, @functools.lru_cache, @functools.cached_property, @dataclass.

Advanced: Recursion (and Its Limits)

def factorial(n):
    return 1 if n <= 1 else n * factorial(n - 1)

Python's default recursion depth is ~1000 (sys.getrecursionlimit()). For deeper logic, iterate instead. Python doesn't optimise tail calls.

Advanced: Pure Functions & Side Effects

A pure function:

  • Returns the same output for the same input.
  • Doesn't mutate external state, files, or globals.
Pure functions are easy to test, parallelise, and reason about. Push side effects (DB writes, prints, network calls) to the edges of your program; keep the core pure when you can.

Practice Path

1. Write mean(nums) and median(nums) with type hints and docstrings; test on a list and an empty list (handle the empty case explicitly). 2. Write a decorator @retry(times=3) that re-runs a function on exception up to N times; use it on a flaky function. 3. Build a closure-based make_counter() that returns a function whose count survives across calls. 4. Demonstrate the mutable-default trap and fix it with the None pattern.