Functions and structure
Defining Functions and Passing Arguments
What a def statement builds, how positional and keyword arguments differ, and how defaults, *args and the / and * markers decide what callers can do.
Most people learn def on the first day and then spend a year guessing at
call sites. The signature is the part of a function other code touches, and it is usually
the part written last and changed most often. A parameter inserted in the middle, a
default that works the first time and not the second, a function that prints its answer
instead of returning it: each of those turns a working program into one that fails
somewhere else.
A function carries a contract. The parameters say what the caller must supply and what
the body may assume; the return says what the caller gets back. Python lets
you write that contract in several ways, from a bare positional parameter to a
keyword-only argument with a default, and every choice trades flexibility now for the
freedom to change the code later.
The examples below are worth running yourself. Print the function object, break a call on purpose, read the message. Those messages are the contract being enforced, and they carry more information than they appear to.
A def statement builds a function object and binds a name to it
Nothing in the body runs when the function is defined. The interpreter compiles the body, creates a function object, stores on it everything it will need later, and assigns the name. All of that is inspectable.
def shout(text):
return text.upper() + "!"
print(shout)
print(type(shout))
print(shout.__name__)
print(shout.__defaults__)
print(sorted(["banana", "fig", "apple"], key=len))
<function shout at 0x10812f320>
<class 'function'>
shout
None
['fig', 'apple', 'banana']
The hexadecimal number is the object's address, and it changes on every run. What
matters is that shout is a name pointing at an object of type
function, in the same way that a variable holds an int.
__name__ keeps the name given at definition time, and
__defaults__ is where default values live, which is None here
because this function has none.
Because the function is a value, it can be handed to another function.
key=len passes the built-in len to sorted, which
calls it once per element and orders the words by length. That single mechanism is what
key= arguments, callbacks and decorators are made of, and it is also the
reason a def inside a loop can behave unexpectedly. The next lesson takes
that case apart.
Two things are settled when the def runs and never again at call time:
the parameter names and the stored defaults. Almost every argument bug is one of those
two coming back.
Positional and keyword arguments describe the same call from two directions
def describe(word, prefix="word"):
return f"{prefix}: {word}"
print(describe("alpha"))
print(describe("alpha", "token"))
print(describe(word="alpha", prefix="token"))
print(describe(prefix="token", word="alpha"))
word: alpha
token: alpha
token: alpha
token: alpha
Four calls, one result, and they are not equally good. The first two bind by position, so the caller has to know the order. The last two bind by name, so the caller has to know the names. A positional call is shorter to write; a keyword call survives someone reordering the parameters six months later.
The rule I use: if the value does not say what it is, name it.
open(path, "w") reads fine because a mode string is recognisable.
save(path, True, False) is a puzzle. Parameters whose meaning the value already
carries can stay positional, and everything else should be named at the call site.
One consequence catches people out. Parameter names are part of the API for every
caller that uses keywords, so renaming prefix to label breaks
those callers even though the function still works. Worth remembering before tidying up
a signature in a codebase with more than one author.
Python enforces the contract from the other side as well. Leave out a parameter with no default and the interpreter names it:
def describe(word, prefix="word"):
return f"{prefix}: {word}"
print(describe())
Traceback (most recent call last):
File "describe.py", line 5, in <module>
print(describe())
~~~~~~~~^^
TypeError: describe() missing 1 required positional argument: 'word'
That message is the missing half of the contract. It distinguishes a required parameter from one with a default, and it names the one you failed to supply.
A default value is computed once, when the def statement runs
Defaults are not recalculated per call. The expression after the =
executes once, when the interpreter reaches the def, and the result is
stored on the function object. Use a call that would return something different each time
and the effect is visible:
import time
def stamp(label, started=time.strftime("%H:%M:%S")):
return f"{label} at {started}"
print(stamp("first"))
time.sleep(1.1)
print(stamp("second"))
time.sleep(1.1)
print(stamp("third"))
first at 11:58:45
second at 11:58:45
third at 11:58:45
Three calls spread over more than two seconds, one clock reading.
time.strftime ran when the def statement executed and the string
it produced was stored as the default. If you want the time of the call, the work has to
move into the body:
def stamp(label, started=None):
if started is None:
started = time.strftime("%H:%M:%S")
return f"{label} at {started}"
With a string or a number the difference never shows, which is how the rule gets learned wrong. The next lesson applies the same rule to a list, where the shared value is not invisible at all. For now, hold on to the mechanism: a default is a value stored on the function object, not an expression stored with it.
A default cannot mean "the caller passed nothing"
By the time the body runs, a parameter with a default holds either the caller's
value or the stored one, and the body cannot tell the difference. None is
the usual sentinel, because a caller will not pass it without meaning to.
*args and **kwargs are for forwarding, not for skipping a decision
def total(*values):
return sum(values)
def describe(name, **attrs):
pairs = ", ".join(f"{key}={value}" for key, value in attrs.items())
return f"{name} ({pairs})"
print(total(1, 2, 3))
print(total(*(1, 2, 3)))
print(describe("widget", colour="red", size=4))
print(describe("widget", **{"colour": "red", "size": 4}))
6
6
widget (colour=red, size=4)
widget (colour=red, size=4)
Inside the body, values is a tuple of everything passed positionally and
attrs is a dictionary of everything passed by keyword. The *
and ** at the call site do the opposite, spreading a sequence or a mapping
across the parameters. That symmetry is what makes these two useful for a function that
passes its arguments along to another one, which is how wrappers and decorators are
built.
They are a bad substitute for naming parameters. Hand total a list and
the failure appears somewhere unrelated to the call:
def total(*values):
return sum(values)
print(total([1, 2, 3]))
Traceback (most recent call last):
File "total.py", line 5, in <module>
print(total([1, 2, 3]))
File "total.py", line 2, in total
return sum(values)
TypeError: unsupported operand type(s) for +: 'int' and 'list'
values became the one-element tuple ([1, 2, 3],).
sum starts from 0, tries to add a list, and stops. The function
offered no protection against the mistake because it never stated what it accepts.
Compare the two signatures a reader meets cold. def total(*values) and
def total(values) promise different things, and only the second matches what
the body does. Keep *args for cases where the count genuinely varies, such
as a logger formatting a message or a wrapper forwarding a call. A signature that is
mostly **kwargs is a function whose contract lives in a docstring, where
nothing enforces it.
Slash and star in a signature fix which parameters have names
Two markers in a parameter list control how arguments may be passed. A /
makes everything before it positional-only. A * makes everything after it
keyword-only.
def scale(value, /, factor=1, *, unit="x"):
return f"{value * factor}{unit}"
print(scale(3, factor=2, unit="px"))
print(scale(value=3))
6px
Traceback (most recent call last):
File "scale.py", line 6, in <module>
print(scale(value=3))
~~~~~~~~~^^^^^^^
TypeError: scale() got some positional-only arguments passed as keyword arguments: 'value'
value cannot be passed by name at all. unit can only be
passed by name. factor sits between the markers and accepts either. Replace
that last call with a third positional argument and the rule bites from the other side:
print(scale(3, 2, "px"))
Traceback (most recent call last):
File "scale.py", line 6, in <module>
print(scale(3, 2, "px"))
~~~~~~~~~^^^^^^^^^^
TypeError: scale() takes from 1 to 2 positional arguments but 3 were given
Use / when the parameter name is an implementation detail you want to stay
free to change. The standard library does exactly that: len(obj) works and a
keyword call for that parameter does not, because the name was never part of the deal.
Use * when you want call sites that explain themselves, and when you expect
to add parameters later. A new keyword-only parameter with a default cannot change the
meaning of any existing call, because no existing call could have been passing it.
Return a tuple when one call has more than one answer
def bounds(values):
return min(values), max(values)
print(bounds([4, 9, 2]))
low, high = bounds([4, 9, 2])
print(low, high)
(2, 9)
2 9
The comma builds the tuple. The parentheses around the return value are there for readability only. A caller can index the result or assign its two values to two names, and the second form is better because a change to the shape raises an error instead of silently handing back the wrong field.
Two or three values that belong together work well as a tuple. More than that, or a tuple whose positions have to be remembered, is a small record that deserves a name. Module 4 covers the decorator that turns a class into one in a few lines, and by then the tuple has usually started to hurt.
Printing is a side effect, and a function that prints cannot be composed
def double(value):
print(value * 2)
returned = double(3)
print("double returned:", returned)
print(returned + 1)
6
double returned: None
Traceback (most recent call last):
File "double.py", line 7, in <module>
print(returned + 1)
~~~~~~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
double(3) wrote 6 to the terminal and returned
None, because a function without a return gives back
None. The value existed for a moment and went to stdout, where the rest of
the program cannot reach it. Anything that wants the number has to parse text or
recompute it.
A function that returns can be checked with a one-line assertion in the next module,
used inside an expression, and formatted by whoever knows what the output should look
like. A function that prints is usable once, from a place where printing is the point.
Keep the print calls in main(), the entry point, and let every
function underneath hand back values.
Doing both is the worst version. The return value ends up looking like an afterthought and half the callers ignore it, so the function has two contracts and only one of them is documented.
Write a function with this signature and no print inside it:
def summarise(scores, /, *, missing=0):
...
It takes a sequence of scores where a score may be None for a result
that never arrived. It returns a tuple of three values: how many scores were counted,
their total, and their average. A None counts as the
missing value.
Call it three ways: with a plain list, with missing=9, and with the
first argument passed by keyword. Write down the error message for the third call
before you run it.
Then answer: what does your function do with an empty list, and is that the behaviour you want?
Show the solution and the reasoning
def summarise(scores, /, *, missing=0):
filled = [missing if score is None else score for score in scores]
total = sum(filled)
return len(filled), total, total / len(filled)
print(summarise([8, None, 10]))
print(summarise([8, None, 10], missing=9))
count, total, average = summarise([7, 9])
print(count, total, average)
(3, 18, 6.0)
(3, 27, 9.0)
2 16 8.0
scores is positional-only because the name belongs to you rather than
to the caller. You can rename it to results next month and no call site
breaks, which is the same freedom len keeps for itself.
missing is keyword-only because summarise(scores, 9) reads as
though nine were a second set of scores. Making the caller write
missing=9 removes that ambiguity, and the * means a future
option can be added without a positional call quietly binding to it.
The function returns a tuple rather than printing because three of the four things you might do with the result (assert on it, format it, add it to a larger report) happen outside the function. Returning also keeps the rounding decision at the call site, where whoever formats the output knows how many decimals it needs.
The empty list is the case worth thinking about. summarise([]) raises
ZeroDivisionError: division by zero, because nothing here guards the
average. Two answers are defensible: raise a ValueError naming the broken
contract, or return (0, 0, 0.0) and let the caller decide. An unguarded
division that the caller discovers in production is not one of them.
The third call fails with TypeError: summarise() got some positional-only
arguments passed as keyword arguments: 'scores'. That message is the interpreter
reading the / marker back to you.
Progress
Mark this lesson done and it stays marked in this browser.