Part 1 · Foundations · Module 05
Functions
A function is a named, reusable piece of behaviour. Functions are how you stop repeating yourself, how you give a name to an idea, and how you split a big problem into small ones you can test one at a time. Almost every line of professional Python lives inside one.
- Define functions with
def, return values, and explain what a call does step by step - Use positional, keyword, default,
*args,**kwargs, keyword-only, and positional-only parameters - Explain the mutable-default trap and fix it with the
Nonesentinel idiom - Predict which variable a name refers to using the LEGB rule, and use
globalandnonlocalcorrectly - Pass functions as values, write lambdas, and use
key=functions withsorted - Document functions with Google-style docstrings and type hints
- Distinguish pure functions from side effects, and read a simple recursive function
Defining and calling a function
You have already called many functions: print(), len(),
input(). Now you will write your own. A function definition starts with the
keyword def, a name, a pair of parentheses holding the parameters, and a colon.
The indented block underneath is the body.
def fahrenheit(celsius):
"""Convert a Celsius temperature to Fahrenheit."""
return celsius * 9 / 5 + 32
print(fahrenheit(100)) # 212.0
print(fahrenheit(-40)) # -40.0
Three things happen when Python runs this file:
- The
defstatement runs once. It does not execute the body. It creates a function object and binds the namefahrenheitto it, exactly like an assignment. - A call
fahrenheit(100)creates a fresh frame: a private workspace where the parametercelsiusis bound to the argument100. The body runs inside it. returnends the call immediately and hands a value back to the caller. The frame is thrown away, and the call expressionfahrenheit(100)is replaced by that value.
If a function reaches the end of its body without a return, or executes a bare
return, it returns None. This is a classic beginner surprise: printing is not
returning.
shout_print displays text as a side effect and returns None.
shout_return gives the value back so the caller can store it, combine it, or test it. Prefer
returning: the caller can always print a returned value, but it can never recover a value that was only
printed.
A function can return several values by returning a tuple; the caller unpacks it:
Parameters, arguments, and defaults
Two words that people often mix up:
- A parameter is the name in the definition:
def area(width, height). - An argument is the value supplied in a call:
area(3, 4).
Arguments can be passed positionally (matched by order) or by keyword (matched by name). Keyword arguments make calls self-explanatory and let you skip the order:
def area(width, height):
return width * height
area(3, 4) # positional
area(width=3, height=4) # keyword
area(3, height=4) # mixed: positional ones must come first
area(height=4, width=3) # keyword order does not matter
A default value makes a parameter optional. Parameters with defaults must come after those without.
Python checks every call against the signature and raises TypeError for mismatches. Read
these messages: they say exactly what is wrong.
The mutable-default trap
Here is the most famous Python gotcha. Predict the output before running it.
Most people expect ['a'] then ['b']. The real answer is ['a'] then
['a', 'b']. Why? A default value is evaluated once, when the def statement
runs, and stored on the function object. Every call that omits tags gets that
same list object. Appending mutates the shared list, so changes leak between calls.
Step through it in the visualizer: watch both calls' tags point at a single list.
The idiomatic fix uses None as a sentinel (a special marker meaning "no value
given") and creates a new list inside the body, which runs on every call:
Flexible signatures: *args, **kwargs, /, and *
Sometimes you do not know how many arguments a caller will pass. print accepts any number;
so can your functions.
*argscollects extra positional arguments into a tuple.**kwargscollects extra keyword arguments into a dict.
The names args and kwargs are conventions; the stars do the work.
The stars also work in the other direction, at the call site: *seq spreads a sequence
into positional arguments, and **mapping spreads a dict into keyword arguments.
point = (3, 4)
options = {"sep": " | ", "end": "!\n"}
print(*point, **options) # same as print(3, 4, sep=" | ", end="!\n") → 3 | 4!
Keyword-only and positional-only parameters
A bare * in a signature means "everything after this must be passed by keyword". A
/ means "everything before this must be passed by position". Together they let you design calls
that are hard to misuse.
def transfer(amount, /, *, source, target, dry_run=False):
...
transfer(100, source="checking", target="savings") # OK
transfer(100, "checking", "savings") # TypeError: source and target are keyword-only
transfer(amount=100, source="a", target="b") # TypeError: amount is positional-only
Why would you want this?
- Keyword-only parameters stop callers from confusing similar arguments.
transfer(100, "savings", "checking")would silently move money the wrong way; forcingsource=andtarget=makes the call read like a sentence. Boolean flags such asdry_runshould almost always be keyword-only. - Positional-only parameters let you rename a parameter later without breaking callers,
and are used when a name would be meaningless (
len(obj), notlen(obj=x)). Many built-ins are positional-only;help(len)showslen(obj, /).
The full order of a signature is:
def f(pos_only, /, normal, default=1, *args, kw_only, kw_default=2, **kwargs): ...
Scope: where names live (LEGB)
A scope is a region of code where a set of names is visible. Each function call gets its
own local scope, so a variable created inside a function does not exist outside it, and two
functions can both use a name like total without interfering.
When Python meets a name, it searches four scopes in order, called LEGB:
Step through this program and watch the frames. Notice that the global x and each function's
x are different variables.
Assignment makes a name local
Reading a global inside a function is fine. Assigning to a name anywhere in a function makes that name local for the whole function body, decided when the function is compiled. That produces this confusing error:
To rebind a module-level name from inside a function, declare it global. To rebind a name from
an enclosing function, declare it nonlocal. Use both sparingly: a function that quietly changes
globals is hard to test and reason about. Usually it is better to take the value as a parameter and return
the new value.
# Avoid: hidden shared state
total = 0
def add(n):
global total
total += n# Prefer: data in, data out
def add(total, n):
return total + n
total = add(0, 5)Functions are values: first-class functions and lambda
In Python a function is an ordinary object, as real as an int or a list. You can bind it to another name, store it in a list or dict, pass it to another function, and return it from a function. Languages with this property are said to have first-class functions.
Functions that return functions (closures)
An inner function remembers the enclosing variables it uses, even after the outer function has returned. The combination is a closure. You will use this pattern for decorators in M15.
lambda: tiny anonymous functions
lambda params: expression creates a function without a def or a name. The body
must be a single expression whose value is returned automatically. Lambdas shine as short, throwaway
arguments, especially the key= argument of sorted, min, and
max, which tells them what to compare.
Docstrings and type hints
A docstring is a string literal that is the first statement of a function. Python stores it
as fn.__doc__, and tools such as help(), editors, and documentation generators
display it. This course uses the Google style: a one-line summary, a blank line, then
Args:, Returns:, and Raises: sections.
Type hints (annotations) state what types a function expects and returns. Python does not enforce them at run time; they are documentation that tools can check. A type checker such as mypy (introduced in M16) reads them and reports mistakes before you run the code.
Common hint forms you will meet now: int, float, str,
bool, None, list[int], dict[str, float],
tuple[int, int], and unions written with a bar: int | None ("an int or None").
Pure functions and side effects
A pure function has two properties: its result depends only on its arguments, and it
changes nothing outside itself. len, abs, and fahrenheit are pure.
Anything else a function does besides computing its return value is a side effect: printing,
writing a file, mutating an argument, changing a global, reading the clock, or asking for input.
# Impure: mutates its argument
def add_bonus(scores):
for i in range(len(scores)):
scores[i] += 5
return scores# Pure: returns a new list
def add_bonus(scores):
return [s + 5 for s in scores]Pure functions are easier to test (same input, same output, every time), easier to reuse, and safe to call
in any order. Programs need side effects, of course, or they would do nothing visible. The professional habit
is to push side effects to the edges: a small main() reads input and prints
output, and calls pure functions that do the real work.
A first look at recursion
A function may call itself. This is recursion, and it fits problems that contain smaller copies of themselves. Every recursive function needs two parts:
- a base case that answers directly without recursing, and
- a recursive case that makes the problem smaller and calls itself.
The factorial n! = n × (n−1) × … × 1 is the classic example, because
n! = n × (n−1)! and 0! = 1.
def factorial(n):
if n == 0: # base case
return 1
return n * factorial(n - 1) # recursive case: a smaller problem
Each call waits for the one below it. Expand the call tree to see the calls go down to the base case and the results multiply back up.
Now step through the real code: the tracer shows a stack of frames, one per active call, each with its own
n.
Forget the base case, or fail to make progress towards it, and the calls never stop. Python protects itself
with a recursion limit (about 1000 frames by default) and raises RecursionError:
Practice
Exercises run in the browser and are graded instantly. Start with the first and work down.
Write greet(name, greeting="Hello") that returns (not prints) a string like
"Hello, Ada!". Surrounding whitespace in name should be stripped.
Hint
Use an f-string and name.strip().
Show solution
def greet(name, greeting="Hello"):
return f"{greeting}, {name.strip()}!"
This function leaks state between calls. Fix it so each call without items starts from a new
empty list, while passing a list explicitly still appends to (and returns) that list.
Hint
Default to None and test if items is None:.
Show solution
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Write summarize(*numbers, decimals=2) returning a tuple (smallest, largest,
mean), with the mean rounded to decimals. With no numbers, return None.
decimals must be keyword-only (it is, automatically, because it follows *numbers).
Hint
numbers is a tuple: use min,
max, sum, and len.
Show solution
def summarize(*numbers, decimals=2):
if not numbers:
return None
return min(numbers), max(numbers), round(sum(numbers) / len(numbers), decimals)
Write apply_discount(price, /, *, percent) that returns price reduced by
percent percent, rounded to 2 decimals. price must be positional-only and
percent keyword-only. Raise ValueError if percent is outside 0–100.
Hint
The signature is def apply_discount(price, /, *, percent):.
Show solution
def apply_discount(price, /, *, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
return round(price * (1 - percent / 100), 2)
Write make_counter(start=0, step=1) that returns a function. Each call of the returned
function advances the count by step and returns the new value. Separate counters must be
independent. Do not use globals.
Hint
Define an inner function that declares nonlocal
count, then return the inner function (without calling it).
Show solution
def make_counter(start=0, step=1):
count = start
def counter():
nonlocal count
count += step
return count
return counter
Write a recursive function digit_sum(n) that returns the sum of the decimal
digits of a non-negative integer, for example digit_sum(4096) == 19. Do not convert to a
string. Hint: the last digit is n % 10 and the rest is n // 10.
Hint
Base case: n < 10 returns n.
Show solution
def digit_sum(n):
if n < 10:
return n
return n % 10 + digit_sum(n // 10)
Mini-project: a bill-splitting library
Quiz
Recap
defbinds a name to a function object; each call gets a new frame;returnsupplies the value (defaultNone).- Arguments match parameters by position or by keyword; defaults make parameters optional.
- Defaults are evaluated once. Use
Noneas the default for anything mutable. *argsis a tuple of extra positionals,**kwargsa dict of extra keywords;/and*mark positional-only and keyword-only sections.- Names resolve L → E → G → B. Assignment makes a name local unless declared
global/nonlocal. - Functions are values: store them, pass them (
key=), return them (closures).lambdais for short expressions. - Document with Google-style docstrings and type hints; prefer pure functions and push side effects to the edges.
- Recursion = base case + smaller recursive case.