Codetail

Article 10 of 15

Functions

Abstraction, scope, closures, decorators.

30 min read

Defining functions

A function is defined with def, a name, parameters in parentheses, and a body. It runs only when called. Every function returns a value: if there is no return, it returns None.

Python
1def greet(name):
2 return f"Hello, {name}!"
3
4message = greet("Alice")
5print(message) # Hello, Alice!
6
7# No return statement, implicitly returns None
8def log(text):
9 print(f"[LOG] {text}")
10
11result = log("started")
12print(result) # None

Type hints

Type hints annotate what types parameters and return values should be. They are not enforced at runtime, Python ignores them during execution, but they make code clearer, enable editor autocompletion, and let type checkers like mypy catch bugs before you run the code.

Python
1def add(x: int, y: int) -> int:
2 return x + y
3
4def greet(name: str, loud: bool = False) -> str:
5 msg = f"Hello, {name}!"
6 return msg.upper() if loud else msg
7
8def first_even(nums: list[int]) -> int | None:
9 return next((n for n in nums if n % 2 == 0), None)
10
11print(add(3, 4)) # 7
12print(greet("Alice", loud=True)) # HELLO, ALICE!
13print(first_even([1, 3, 5, 4, 6])) # 4
14print(first_even([1, 3, 5])) # None

Early return

Returning early on guard conditions keeps the main logic unindented and easy to read. Sometimes called the guard clause pattern.

Python
1# Nested version, hard to follow
2def process(data):
3 if data is not None:
4 if len(data) > 0:
5 return data[0] * 2
6 return None
7
8# Guard clause version, flat and clear
9def process(data):
10 if data is None:
11 return None
12 if len(data) == 0:
13 return None
14 return data[0] * 2
15
16print(process([5, 10])) # 10
17print(process([])) # None

Parameters

Python has five kinds of parameters. Understanding when to use each one makes function signatures clearer and more flexible.

Positional and keyword arguments

Python
1def connect(host, port, timeout=30):
2 print(f"connecting to {host}:{port} (timeout={timeout}s)")
3
4# Positional, order matters
5connect("localhost", 5432)
6
7# Keyword, order does not matter
8connect(port=5432, host="localhost")
9
10# Mix, positional first, then keyword
11connect("localhost", timeout=60, port=5432)

Never use a mutable object as a default value. Default values are created once when the function is defined, not on each call. def f(items=[]) shares the same list across all calls. Use def f(items=None) and set the default inside the body.

*args and **kwargs

*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict. The names args and kwargs are conventions, the * and ** are what matter.

Python
1def summarise(*args, **kwargs):
2 print("args:", args)
3 print("kwargs:", kwargs)
4
5summarise(1, 2, 3, name="Alice", role="admin")
6# args: (1, 2, 3)
7# kwargs: {'name': 'Alice', 'role': 'admin'}
8
9# Forwarding, pass everything through to another function
10def wrapper(*args, **kwargs):
11 print("before")
12 result = original(*args, **kwargs)
13 print("after")
14 return result

Keyword-only and positional-only

Python
1# After *, keyword-only (must be passed by name)
2def create_user(name, *, role="viewer", active=True):
3 print(f"{name} | role={role} | active={active}")
4
5create_user("Alice", role="admin")
6# create_user("Alice", "admin") # TypeError, role is keyword-only
7
8# Before /, positional-only (must be passed by position)
9def distance(x, y, /):
10 return (x ** 2 + y ** 2) ** 0.5
11
12print(distance(3, 4)) # 5.0
13# distance(x=3, y=4) # TypeError, x, y are positional-only

See how different call patterns pack into args and kwargs.

*args / **kwargs Explorerdef func(*args, **kwargs)
Pick a call pattern
pick a pattern above

Scope

When Python sees a name, it searches four scope levels in order: Local, Enclosing, Global, Built-in. It uses the first match it finds. If the name is not found in any scope, a NameError is raised.

Python
1x = "global" # Global scope
2
3def outer():
4 x = "enclosing" # Enclosing scope
5
6 def inner():
7 x = "local" # Local scope
8 print(x) # local, found in Local first
9
10 inner()
11 print(x) # enclosing
12
13outer()
14print(x) # global

global and nonlocal

By default, assigning to a name inside a function creates a local variable. Use global to modify a module-level variable, and nonlocal to modify a variable in the enclosing function. Both are needed rarely, prefer returning values over modifying external state.

Python
1count = 0
2
3def increment():
4 global count # declare intent to modify the global
5 count += 1
6
7increment()
8increment()
9print(count) # 2
10
11# nonlocal, modifying a variable in the enclosing function
12def make_counter():
13 n = 0
14 def tick():
15 nonlocal n
16 n += 1
17 return n
18 return tick
19
20counter = make_counter()
21print(counter()) # 1
22print(counter()) # 2
23print(counter()) # 3

Click any variable below to see which scope Python resolves it from.

LEGB Scope Explorer
Built-in
Global
Enclosing
Local
click a variable to resolve it

Lambda and higher-order functions

A lambda is a small anonymous function defined in one expression. It can take any number of arguments but can only have a single expression as its body. Use it when a function is short, used once, and the name would add no clarity.

Python
1# Lambda syntax: lambda params: expression
2double = lambda x: x * 2
3add = lambda x, y: x + y
4
5print(double(5)) # 10
6print(add(3, 4)) # 7
7
8# Most common use, as a key function
9pairs = [(3, "banana"), (1, "apple"), (2, "cherry")]
10pairs.sort(key=lambda item: item[0])
11print(pairs) # [(1, 'apple'), (2, 'cherry'), (3, 'banana')]
12
13words = ["banana", "fig", "apple", "kiwi"]
14words.sort(key=lambda w: len(w))
15print(words) # ['fig', 'kiwi', 'apple', 'banana']

If you assign a lambda to a variable and reuse it, write a real def instead. Lambdas are for inline, one-off use. Assigning to a variable defeats the purpose and makes stack traces harder to read.

Functions as arguments

Python functions are first-class objects, you can pass them as arguments, return them, and store them in data structures. This enables a clean style where behavior is passed in rather than hardcoded.

Python
1def apply(func, items):
2 return [func(x) for x in items]
3
4print(apply(str.upper, ["hello", "world"])) # ['HELLO', 'WORLD']
5print(apply(abs, [-3, 1, -5, 2])) # [3, 1, 5, 2]
6
7# sorted with key= is the canonical example
8students = [
9 {"name": "Alice", "grade": 92},
10 {"name": "Bob", "grade": 78},
11 {"name": "Carol", "grade": 85},
12]
13ranked = sorted(students, key=lambda s: s["grade"], reverse=True)
14for s in ranked:
15 print(s["name"], s["grade"])

functools.partial

partial() creates a new function with some arguments pre-filled. It is cleaner than a lambda wrapper and preserves the original function's metadata.

Python
1from functools import partial
2
3def power(base, exp):
4 return base ** exp
5
6square = partial(power, exp=2)
7cube = partial(power, exp=3)
8
9print(square(4)) # 16
10print(cube(3)) # 27
11
12# Useful for callbacks that need extra context
13def log(level, message):
14 print(f"[{level}] {message}")
15
16info = partial(log, "INFO")
17error = partial(log, "ERROR")
18
19info("server started") # [INFO] server started
20error("connection lost") # [ERROR] connection lost

Closures

A closure is a function that remembers the variables from the scope where it was defined, even after that scope has finished executing. The inner function "closes over" the variables it references. This is how Python implements stateful functions without classes.

Python
1def make_multiplier(factor):
2 def multiply(x):
3 return x * factor # factor is captured from the enclosing scope
4 return multiply
5
6double = make_multiplier(2)
7triple = make_multiplier(3)
8
9print(double(5)) # 10
10print(triple(5)) # 15
11
12# Each closure has its own captured variable
13print(double(10)) # 20
14print(triple(10)) # 30

Closures with nonlocal state

Python
1def make_accumulator(initial=0):
2 total = initial
3
4 def add(n):
5 nonlocal total
6 total += n
7 return total
8
9 return add
10
11acc = make_accumulator()
12print(acc(10)) # 10
13print(acc(5)) # 15
14print(acc(20)) # 35
15
16# Independent accumulators
17a = make_accumulator(100)
18b = make_accumulator()
19print(a(10)) # 110
20print(b(10)) # 10

The loop closure gotcha

Closures capture variables by reference, not by value. Inside a loop, all the closures share the same variable, and by the time they run, the loop has finished and the variable holds its final value.

Python
1# Bug, all functions print 3
2funcs = []
3for i in range(4):
4 funcs.append(lambda: i) # all capture the same 'i'
5
6print([f() for f in funcs]) # [3, 3, 3, 3]
7
8# Fix 1, default argument captures the current value
9funcs = []
10for i in range(4):
11 funcs.append(lambda i=i: i)
12
13print([f() for f in funcs]) # [0, 1, 2, 3]
14
15# Fix 2, factory function
16def make_fn(n):
17 return lambda: n
18
19funcs = [make_fn(i) for i in range(4)]
20print([f() for f in funcs]) # [0, 1, 2, 3]

Decorators

A decorator is a function that takes another function and returns a replacement. The @ syntax is shorthand for func = decorator(func). Decorators let you add behavior, logging, timing, caching, authentication, to functions without touching their code.

Python
1# A decorator is just a function that wraps another
2def log_calls(func):
3 def wrapper(*args, **kwargs):
4 print(f"calling {func.__name__}")
5 result = func(*args, **kwargs)
6 print(f"{func.__name__} returned {result}")
7 return result
8 return wrapper
9
10# @log_calls is the same as: add = log_calls(add)
11@log_calls
12def add(x, y):
13 return x + y
14
15add(3, 4)
16# calling add
17# add returned 7

Preserving metadata with @wraps

Without @wraps, the decorated function loses its name and docstring. Always use it.

Python
1from functools import wraps
2
3def log_calls(func):
4 @wraps(func) # copies __name__, __doc__, etc.
5 def wrapper(*args, **kwargs):
6 print(f"calling {func.__name__}")
7 return func(*args, **kwargs)
8 return wrapper
9
10@log_calls
11def add(x: int, y: int) -> int:
12 """Add two numbers."""
13 return x + y
14
15print(add.__name__) # add (not 'wrapper')
16print(add.__doc__) # Add two numbers.

Decorators with arguments

Python
1from functools import wraps
2
3def retry(times=3):
4 def decorator(func):
5 @wraps(func)
6 def wrapper(*args, **kwargs):
7 for attempt in range(times):
8 try:
9 return func(*args, **kwargs)
10 except Exception as e:
11 if attempt == times - 1:
12 raise
13 print(f"attempt {attempt + 1} failed: {e}, retrying...")
14 return wrapper
15 return decorator
16
17@retry(times=3)
18def flaky_task():
19 import random
20 if random.random() < 0.7:
21 raise RuntimeError("network error")
22 return "done"

Stacking decorators

Python
1from functools import wraps, lru_cache
2
3def timer(func):
4 @wraps(func)
5 def wrapper(*args, **kwargs):
6 import time
7 start = time.perf_counter()
8 result = func(*args, **kwargs)
9 print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
10 return result
11 return wrapper
12
13# Applied bottom-up: lru_cache first, then timer
14@timer
15@lru_cache(maxsize=128)
16def fib(n):
17 if n <= 1: return n
18 return fib(n - 1) + fib(n - 2)
19
20fib(30)