Module 4: Functions and Program Structure
Defining and Calling Functions
Giving a piece of work a name and a contract, passing arguments by position and by keyword, and choosing defaults that do not lie about what is optional.
Lesson 13 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can define a function with required, defaulted, and keyword arguments, call it correctly, and read someone else's definition well enough to state its contract without reading a line of the body.
Why it matters
A function is the smallest unit of work that can be named, tested, reused, and replaced. Until code lives inside one, there is nothing to test in isolation and nothing to describe except "the script".
This also changes how you review generated code. A well-shaped function announces its contract in its first two lines: what it needs, what it gives back, and what it is called. Most of the time you can judge whether it belongs in your project before you read the implementation, and that is the difference between reviewing a change and merely approving it.
Concept
def name(parameters): introduces a function; the indented block below is its body. Nothing in the body runs until the function is called.
Parameters are the names in the definition; arguments are the values supplied at the call. They can be passed by position, in order, or by keyword, currency="EUR", in any order. Keyword arguments at a call site are self-documenting, which matters most for the arguments a reader cannot guess: search(records, True, False) tells you nothing, and search(records, include_closed=True, verbose=False) tells you everything.
A parameter can have a default: def summary(notice_id, currency="USD"). For positional parameters like those in this lesson, required parameters must come before defaulted parameters: Python fills positional arguments left to right and has no way to skip one.
When each call should start independently, avoid mutable defaults. A default is evaluated once, when the function is defined. A default list or dictionary is then shared by every call that relies on it, so mutating it can accumulate items across calls that were supposed to be independent. The convention is def collect(items=None): followed by if items is None: items = [] inside the body.
A docstring is a string on the first line of the body, and it is the contract in prose: what the function does, what it expects, what it returns, and what it does when the input is wrong. Write it before the body when you can; a docstring you cannot write is usually a function that does two things.
Read the code
def format_value(amount, currency="USD", show_cents=False):
"""Render an amount as display text.
amount: a number. currency: a three-letter code. show_cents: include
two decimal places when True. Returns a string; never prints.
"""
if show_cents:
return f"{amount:,.2f} {currency}"
return f"{amount:,.0f} {currency}"
print(format_value(185000))
print(format_value(185000, "EUR"))
print(format_value(185000, show_cents=True))
print(format_value(amount=4820.5, currency="GBP", show_cents=True))
Read the signature alone. One required parameter, two defaulted, so the function can be called with a single argument. The docstring says it returns a string and never prints, which tells the caller they are in charge of the output.
The four calls demonstrate the calling conventions in order: positional only; two positional; positional plus a keyword that skips over currency; and everything by keyword. The third call omits currency while setting show_cents by keyword. An equivalent all-positional call is format_value(185000, "USD", True); it must supply the currency explicitly.
Inspect argument binding and returned values
Arguments enter; a formatted string returns
Definition → call: Defining format_value stores its defaults. Calling it binds parameters and runs the body.
The body returns a string to its caller. In the worked example, the outer print displays that returned string.
1 · One positional argument
Call- format_value(185000)
amount- 185000 from position 1
currency- "USD" from its default
show_cents- False from its default
Returned to caller- "185,000 USD"
Both optional parameters use their stored defaults.
2 · Two positional arguments
Call- format_value(185000, "EUR")
amount- 185000 from position 1
currency- "EUR" from position 2
show_cents- False from its default
Returned to caller- "185,000 EUR"
Position 2 replaces currency; show_cents still uses its default.
3 · Positional plus keyword
Call- format_value(185000, show_cents=True)
amount- 185000 from position 1
currency- "USD" from its default
show_cents- True from keyword show_cents
Returned to caller- "185,000.00 USD"
The keyword names the third parameter while leaving currency omitted.
4 · All keyword arguments
Call- format_value(amount=4820.5, currency="GBP", show_cents=True)
amount- 4820.5 from keyword amount
currency- "GBP" from keyword currency
show_cents- True from keyword show_cents
Returned to caller- "4,820.50 GBP"
Every value is supplied by name; neither optional default is used.
Predict the output
Predict the four printed lines.
Check your prediction
185,000 USD
185,000 EUR
185,000.00 USD
4,820.50 GBP
The third line keeps the default currency while overriding the last parameter, which is exactly what keyword arguments are for. The fourth names all three, which is verbose and completely unambiguous, a reasonable trade at a call site a reader will meet rarely.
Modify the code
Change the signature to def format_value(currency="USD", amount, show_cents=False):, moving the defaulted parameter first. Predict what happens when you run the file.
What changes, and why
Nothing runs. Python reports a syntax error at the definition itself:
SyntaxError: parameter without a default follows parameter with a default
This is a syntax error, so no line of the file executes, not even the unrelated ones above. The rule exists because positional arguments are filled left to right: if currency came first with a default, a single-argument call could not say whether that argument was the currency or the amount. Refusing the definition is Python removing the ambiguity at the only point where it can.
Debug the bug
An assistant was asked for a function that collects notice identifiers into a list, with the list optional so callers can supply their own. It produced this and said each call starts from an empty list unless one is passed.
def collect_ids(record, seen=[]):
seen.append(record["notice_id"])
return seen
print(collect_ids({"notice_id": "A-1"}))
print(collect_ids({"notice_id": "A-2"}))
print(collect_ids({"notice_id": "A-3"}))
What's actually wrong
It prints:
['A-1']
['A-1', 'A-2']
['A-1', 'A-2', 'A-3']
Three calls that should each have produced a one-item list instead share a single list that grows forever.
The default value is evaluated once, when the def line runs, not once per call. Every call that does not pass seen receives the same list object, and append modifies it in place. In a long-running program this leaks memory and, worse, leaks one caller's data into another caller's results.
The standard fix:
def collect_ids(record, seen=None):
if seen is None:
seen = []
seen.append(record["notice_id"])
return seen
None acts as a sentinel: when seen is omitted or explicitly None, this function creates a fresh list; a supplied list is reused. For independent calls, prefer defaults such as numbers, strings, booleans or None. A tuple is safe from shared mutation only when it contains no mutable state; a tuple containing a list can still share that list across calls.
Try it yourself
Write one small function with two required parameters and one defaulted parameter, then call it twice, once relying on the default and once overriding it by name.
Loading this exercise…
Practical challenge (optional)
Optional: add a fourth parameter to your function, include_agency=True, and return a shorter line when it is False. Then write one sentence on whether a boolean flag that changes the shape of the return value is a good design, or whether two separate functions would be clearer. There is no single right answer, and having a position on it is what a code review needs from you.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What is the difference between a parameter and an argument?
- Why must positional parameters with defaults follow required positional parameters?
- When calls should build independent collections, why can
def f(items=[])cause a bug? - What can you learn from a function's signature and docstring alone?
Answers
- A parameter is a name in the definition; an argument is a value supplied at a call.
- For positional parameters, Python requires non-default parameters before defaulted ones. This lets you omit a trailing group of optional arguments without leaving a hole in positional binding.
- The default is created once, at definition time, and shared by every call that relies on it. If the function mutates that default, changes carry across calls that were intended to be independent.
- What it needs, what it returns, whether it prints or has other side effects, and which arguments are optional. Usually enough to judge whether it belongs in your project.
Sign in to track your progress on this exercise.
Summary and next step
A function names a unit of work, its signature is its contract, keyword arguments make call sites readable, defaults should avoid shared mutable state when calls need independence, and a docstring you cannot write signals a function doing two jobs. Next: what return really does, how scope decides which names a function can see, and why pure functions are so much easier to trust.