Functions all 5 written
Arguments, the mutable-default trap, scope and closures: the machinery behind every abstraction later in the course.
14 Functions: arguments, returns and docstrings
A function names a piece of behaviour so you can use it without re-reading it. That is the whole purpose, and it is worth keeping in view: the point is not to avoid typing the same lines twice, it is to give a chunk of logic a name and a boundary.
Defining and calling
def area(width, height):
return width * height
print("area(3, 4) =", area(3, 4))
# The names in the definition are PARAMETERS; the values at the call are ARGUMENTS.
# Passing by keyword makes the call readable and order-independent.
print("by keyword: ", area(width=3, height=4))
print("reordered: ", area(height=4, width=3))
# A function is an object, so it can be handed around without being called.
operation = area
print("through another name:", operation(2, 5))
print("the function itself: ", area.__name__)
test("positional arguments work", area(3, 4), 12)
test("keyword arguments are order-independent", area(height=4, width=3), 12)
test("a function is a value", operation(2, 5), 10)
What it printed, when the build ran it
area(3, 4) = 12 by keyword: 12 reordered: 12 through another name: 10 the function itself: area ok positional arguments work ok keyword arguments are order-independent ok a function is a value
Returning is not printing
This is the distinction that takes longest to settle. print shows something to a
human and produces no value. return hands a value back to the calling code and shows
nothing. A function that prints its answer cannot be used in a calculation.
def returns_it(a, b):
return a + b
def prints_it(a, b):
print(a + b)
# The returned value can be used.
total = returns_it(2, 3) * 10
print("using the returned value:", total)
# The printed one cannot: the call evaluates to None.
result = prints_it(2, 3) # prints 5 as a side effect
print("what prints_it returned: ", result)
test("a returned value is usable", returns_it(2, 3) * 10, 50)
test("a printing function returns None", prints_it(2, 3), None)
What it printed, when the build ran it
using the returned value: 50 5 what prints_it returned: None ok a returned value is usable 5 ok a printing function returns None
Trying to use the None that came back is where this becomes an error rather than
a puzzle — and it is one of the most common beginner tracebacks.
def add(a, b):
print(a + b)
total = add(2, 3)
print(total + 1)
The traceback it really produced, when the build ran it
5
Traceback (most recent call last):
File "example.py", line 5, in <module>
print(total + 1)
~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
Every function returns something
There is no such thing as a function that returns nothing. Falling off the end, or writing a
bare return, both produce None. Knowing that removes the mystery from
the traceback above.
def no_return():
x = 1 # computes something and discards it
def bare_return():
return
def explicit_none():
return None
print("falling off the end:", no_return())
print("a bare return: ", bare_return())
print("returning None: ", explicit_none())
# return also exits immediately, which is what makes early returns useful.
def classify(n):
if n < 0:
return "negative"
if n == 0:
return "zero"
return "positive"
print()
print([classify(n) for n in (-1, 0, 5)])
test("all three are None", (no_return(), bare_return(), explicit_none()), (None, None, None))
test("return exits immediately", classify(-1), "negative")
What it printed, when the build ran it
falling off the end: None a bare return: None returning None: None ['negative', 'zero', 'positive'] ok all three are None ok return exits immediately
Early returns beat nested conditionals. Handling the awkward cases first
and returning keeps the main logic unindented and at the bottom, where it is easy to find. The
alternative — one if wrapping the whole body, then another inside it — pushes the
important code to the right and makes it harder to read the further you nest.
Returning several values
A function returns one object, but that object can be a tuple, and the comma is enough to make one. At the call site it is unpacked, so it reads as though several values came back.
def statistics(numbers):
return min(numbers), max(numbers), sum(numbers) / len(numbers)
low, high, mean = statistics([2, 4, 6, 8])
print(f"low={low} high={high} mean={mean}")
# It really is one tuple.
packed = statistics([2, 4])
print("as one object:", packed, type(packed).__name__)
# Beyond two or three values, names beat positions.
from typing import NamedTuple
class Stats(NamedTuple):
low: float
high: float
mean: float
def better_statistics(numbers):
return Stats(min(numbers), max(numbers), sum(numbers) / len(numbers))
s = better_statistics([2, 4, 6, 8])
print()
print("named:", s)
print("reachable by name:", s.mean)
test("three values unpack from one tuple", (low, high, mean), (2, 8, 5.0))
test("the return value is a tuple", type(packed).__name__, "tuple")
test("a NamedTuple keeps the names", s.mean, 5.0)
test("and is still a tuple", tuple(s), (2, 8, 5.0))
What it printed, when the build ran it
low=2 high=8 mean=5.0 as one object: (2, 4, 3.0) tuple named: Stats(low=2, high=8, mean=5.0) reachable by name: 5.0 ok three values unpack from one tuple ok the return value is a tuple ok a NamedTuple keeps the names ok and is still a tuple
Docstrings: documentation the language knows about
A string as the first statement of a function is its docstring. It is not a comment — it is
stored on the function, help() reads it, and every editor and documentation tool
uses it.
def discount(price, percent):
"""Return price reduced by percent.
Args:
price: the original amount, in whole currency units.
percent: how much to take off, 0-100.
Raises:
ValueError: if percent is outside 0-100.
"""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be 0-100, got {percent}")
return price * (1 - percent / 100)
print("discount(200, 10) =", discount(200, 10))
print()
print("the docstring is an attribute:")
print(discount.__doc__.splitlines()[0])
# A comment, by contrast, is discarded entirely.
def undocumented(x):
# this comment is not retrievable at runtime
return x
print()
print("a function with only a comment has __doc__ =", undocumented.__doc__)
test("the docstring is stored on the function", discount.__doc__.startswith("Return price reduced"), True)
test("a comment is not", undocumented.__doc__, None)
test("the function does its job", discount(200, 10), 180.0)
What it printed, when the build ran it
discount(200, 10) = 180.0 the docstring is an attribute: Return price reduced by percent. a function with only a comment has __doc__ = None ok the docstring is stored on the function ok a comment is not ok the function does its job
The docstring is also where you say what the function raises, which matters because that is part of its interface and is invisible from the signature.
def discount(price, percent):
"""Return price reduced by percent."""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be 0-100, got {percent}")
return price * (1 - percent / 100)
discount(200, 150)
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 7, in <module>
discount(200, 150)
~~~~~~~~^^^^^^^^^^
File "example.py", line 4, in discount
raise ValueError(f"percent must be 0-100, got {percent}")
ValueError: percent must be 0-100, got 150
Annotations: types as readable documentation
You can annotate parameters and the return value. Python does not check them at runtime — they are documentation that tools can read, and lesson 38 covers the checkers that act on them. Writing them makes a signature self-explanatory at no cost.
def repeat(text: str, times: int = 2) -> str:
"""Return text repeated, separated by spaces."""
return " ".join([text] * times)
print(repeat("hi"))
print(repeat("hi", 3))
# The annotations are stored, and readable.
print()
print("annotations:", repeat.__annotations__)
# But nothing checks them. Passing an int where str was annotated is allowed
# through, and fails later -- inside join, for a reason the signature never
# mentioned.
print()
try:
repeat(5, 2)
except TypeError as exc:
print("repeat(5, 2) was permitted, then failed inside join:")
print(" TypeError:", exc)
test("the function works as annotated", repeat("hi", 3), "hi hi hi")
test("annotations are recorded", repeat.__annotations__["times"], int)
test("the annotation did not reject the bad call",
"annotation" in str(repeat.__annotations__).lower(), False)
What it printed, when the build ran it
hi hi
hi hi hi
annotations: {'text': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
repeat(5, 2) was permitted, then failed inside join:
TypeError: sequence item 0: expected str instance, int found
ok the function works as annotated
ok annotations are recorded
ok the annotation did not reject the bad call
An annotation is a promise, not a guard. times: int does not
stop anyone passing a string, and the failure will happen later and further away. If a value
must really be an integer, validate it and raise — annotate and check, and let the
annotation document what the check enforces.
A function should do one thing
The most useful habit in this lesson is not syntax. A function that fetches data, formats it and prints it cannot be tested, reused or recombined. Three functions can be.
# One function doing three jobs.
def report_all(records):
for r in records:
if r["active"]:
print(f"{r['name']}: {r['score']}")
# The same work, separated. Each piece is testable on its own.
def active(records):
return [r for r in records if r["active"]]
def format_record(record):
return f"{record['name']}: {record['score']}"
def report(records):
for line in map(format_record, active(records)):
print(line)
data = [
{"name": "Ada", "score": 90, "active": True},
{"name": "Alan", "score": 85, "active": False},
]
report(data)
# And now the middle pieces can be checked directly, which the first version
# made impossible without capturing stdout.
test("active() filters", [r["name"] for r in active(data)], ["Ada"])
test("format_record() formats one record", format_record(data[0]), "Ada: 90")
test("the pieces compose", [format_record(r) for r in active(data)], ["Ada: 90"])
What it printed, when the build ran it
Ada: 90 ok active() filters ok format_record() formats one record ok the pieces compose
What to take from this lesson
returnhands a value back;printshows it and returnsNone. A function that prints its answer cannot be used in a calculation.- Every function returns something —
Noneif you do not say otherwise. - Return several values as a tuple, and use a
NamedTupleonce there are more than two or three. - A docstring is stored on the function and is the place to document what it raises.
- Annotations document types but are not enforced at runtime.
- Keep the computation separate from the printing, so it can be tested.
15 Default arguments, and the trap in the way they work
A default lets a parameter be omitted. The mechanism is one sentence
long and has a consequence almost nobody guesses: the default expression is evaluated
once, when the def line runs, and the same object is reused on every
later call.
The ordinary case
def greet(name, greeting="hello", punctuation="!"):
return f"{greeting}, {name}{punctuation}"
print(greet("Ada"))
print(greet("Ada", "hi"))
print(greet("Ada", punctuation="?")) # skip one, name the next
# Parameters with defaults must come after those without.
print()
print("defaults are visible on the function:", greet.__defaults__)
test("defaults fill in the omitted arguments", greet("Ada"), "hello, Ada!")
test("a keyword argument can skip over one", greet("Ada", punctuation="?"), "hello, Ada?")
test("the defaults are stored as a tuple", greet.__defaults__, ("hello", "!"))
What it printed, when the build ran it
hello, Ada!
hi, Ada!
hello, Ada?
defaults are visible on the function: ('hello', '!')
ok defaults fill in the omitted arguments
ok a keyword argument can skip over one
ok the defaults are stored as a tuple
The trap
A default that is a mutable object — a list, a dict, a set — is created once and shared by every call that relies on it. So a function that "starts with an empty list" does not: it starts with whatever the previous calls left in that one list.
def collect(item, into=[]): # the bug is on this line
into.append(item)
return into
print("first call: ", collect("a"))
print("second call:", collect("b"), "<- not ['b']")
print("third call: ", collect("c"), "<- it is accumulating")
# All three calls returned the SAME list object.
first = collect("d")
second = collect("e")
print()
print("are the returned lists the same object?", first is second)
# And it is visible on the function itself, which is the clinching evidence.
print("the default now holds:", collect.__defaults__)
test("the second call did not start empty", collect("x")[:2], ["a", "b"])
test("every call returns the same object", first is second, True)
test("the default itself has been mutated", len(collect.__defaults__[0]) > 1, True)
What it printed, when the build ran it
first call: ['a'] second call: ['a', 'b'] <- not ['b'] third call: ['a', 'b', 'c'] <- it is accumulating are the returned lists the same object? True the default now holds: (['a', 'b', 'c', 'd', 'e'],) ok the second call did not start empty ok every call returns the same object ok the default itself has been mutated
Why it happens, in one sentence. into=[] is evaluated when the
def statement executes — once, as the module loads — not each time the function is
called. The list it produced is stored on the function object and handed to every call that
omits the argument. Nothing resets it.
The fix
Use None as the default and create the real value inside the function. This is
the standard Python idiom and there is no shorter correct alternative.
def collect(item, into=None):
if into is None:
into = [] # a fresh list on every call that needs one
into.append(item)
return into
print("first call: ", collect("a"))
print("second call:", collect("b"), "<- correct")
print("third call: ", collect("c"))
# And passing one in still works, which is the point of the parameter.
existing = ["x"]
print()
print("with a list supplied:", collect("y", existing))
print("the caller's list was updated:", existing)
test("each call starts fresh", collect("z"), ["z"])
test("independent calls do not share", collect("p") is collect("q"), False)
test("a supplied list is still used", collect("y", ["x"]), ["x", "y"])
What it printed, when the build ran it
first call: ['a'] second call: ['b'] <- correct third call: ['c'] with a list supplied: ['x', 'y'] the caller's list was updated: ['x', 'y'] ok each call starts fresh ok independent calls do not share ok a supplied list is still used
Why if into is None and not if not into. This is
lesson 10's rule applying here exactly: if not into is also true for an empty list
that the caller passed deliberately, so it would silently replace the caller's list with a new
one and the caller would never see the appended item. Test for None.
# The subtly wrong fix.
def wrong(item, into=None):
if not into: # also true for a deliberately empty list
into = []
into.append(item)
return into
def right(item, into=None):
if into is None:
into = []
into.append(item)
return into
passed_to_wrong = []
returned_wrong = wrong("a", passed_to_wrong)
print("after wrong():")
print(" returned: ", returned_wrong)
print(" caller's list: ", passed_to_wrong, "<- still empty, the update went elsewhere")
passed_to_right = []
returned_right = right("a", passed_to_right)
print("after right():")
print(" returned: ", returned_right)
print(" caller's list: ", passed_to_right, "<- updated, as asked")
test("both return something that looks correct", (returned_wrong, returned_right), (["a"], ["a"]))
test("but the truthiness version left the caller's list empty", passed_to_wrong, [])
test("while the is-None version updated it", passed_to_right, ["a"])
What it printed, when the build ran it
after wrong(): returned: ['a'] caller's list: [] <- still empty, the update went elsewhere after right(): returned: ['a'] caller's list: ['a'] <- updated, as asked ok both return something that looks correct ok but the truthiness version left the caller's list empty ok while the is-None version updated it
The same trap, wearing different clothes
Once you know the mechanism you can predict the other cases. Any default whose value is computed at definition time is frozen at that moment — a timestamp is the one that causes real confusion in production.
import time
def stamped(event, when=time.time()): # evaluated ONCE, at definition
return (event, when)
a = stamped("first")
time.sleep(0.01)
b = stamped("second")
print("first: ", a[1])
print("second:", b[1])
print("identical timestamps:", a[1] == b[1], "<- frozen at import time")
# The fix is the same shape.
def stamped_correctly(event, when=None):
if when is None:
when = time.time()
return (event, when)
c = stamped_correctly("first")
time.sleep(0.01)
d = stamped_correctly("second")
print()
print("now they differ:", c[1] != d[1])
test("the computed default never changes", a[1], b[1])
test("the None version evaluates per call", c[1] != d[1], True)
What it printed, when the build ran it
first: 1790372925.922113 second: 1790372925.922113 identical timestamps: True <- frozen at import time now they differ: True ok the computed default never changes ok the None version evaluates per call
When a mutable default is actually fine
The rule is often taught as "never use a mutable default", which is close enough. The precise version is: the danger is mutating the shared object. A mutable default that is only read is harmless — though using a tuple makes that intent obvious and removes the risk entirely.
# Only ever read, never mutated. This is safe.
def render(items, separators=[", ", " and "]):
if len(items) < 2:
return "".join(items)
return separators[0].join(items[:-1]) + separators[1] + items[-1]
print(render(["a", "b", "c"]))
print(render(["a", "b", "c"])) # repeatable, because nothing was changed
# A tuple says "this is not going to be modified" and makes it true.
def render_safely(items, separators=(", ", " and ")):
if len(items) < 2:
return "".join(items)
return separators[0].join(items[:-1]) + separators[1] + items[-1]
print()
print("with a tuple default:", render_safely(["a", "b", "c"]))
test("repeated calls agree, because nothing is mutated",
render(["a", "b"]), render(["a", "b"]))
test("the tuple version behaves identically", render_safely(["a", "b", "c"]), render(["a", "b", "c"]))
What it printed, when the build ran it
a, b and c a, b and c with a tuple default: a, b and c ok repeated calls agree, because nothing is mutated ok the tuple version behaves identically
Defaults are evaluated in the enclosing scope, at definition time
limit = 10
def capped(value, ceiling=limit):
return min(value, ceiling)
print("with limit = 10:", capped(50))
limit = 100 # changing the variable afterwards...
print("after limit = 100:", capped(50), "<- the default did not follow")
# Because the VALUE was captured, not the name.
print("stored default:", capped.__defaults__)
test("the default captured the value at definition", capped(50), 10)
test("and is unaffected by the later reassignment", capped.__defaults__, (10,))
What it printed, when the build ran it
with limit = 10: 10 after limit = 100: 10 <- the default did not follow stored default: (10,) ok the default captured the value at definition ok and is unaffected by the later reassignment
What to take from this lesson
- A default is evaluated once, when the
defruns — not on each call. - Never use a mutable default you intend to modify. Use
Noneand build the real value inside. - Test the sentinel with
is None, not truthiness, or a deliberately empty argument gets silently replaced. - The same trap covers any computed default, including timestamps.
- A default captures the value of a name, not the name itself.
16 *args, **kwargs and keyword-only parameters
* and ** do the same job in two directions. In a
definition they collect leftover arguments; at a call they spread a collection
into arguments. Every confusing use of them is one of those two things.
Collecting: *args and **kwargs
def summarise(*args, **kwargs):
return f"args={args} kwargs={kwargs}"
print(summarise(1, 2, 3))
print(summarise(a=1, b=2))
print(summarise(1, 2, name="Ada"))
print(summarise())
# args is a TUPLE, kwargs is a DICT. The names are convention, not syntax.
def typed(*values, **options):
return type(values).__name__, type(options).__name__
print()
print("the types they collect into:", typed(1, x=2))
test("positional extras arrive as a tuple", summarise(1, 2), "args=(1, 2) kwargs={}")
test("keyword extras arrive as a dict", summarise(a=1), "args=() kwargs={'a': 1}")
test("both are empty when nothing extra is passed", summarise(), "args=() kwargs={}")
test("the collected types are tuple and dict", typed(1, x=2), ("tuple", "dict"))
What it printed, when the build ran it
args=(1, 2, 3) kwargs={}
args=() kwargs={'a': 1, 'b': 2}
args=(1, 2) kwargs={'name': 'Ada'}
args=() kwargs={}
the types they collect into: ('tuple', 'dict')
ok positional extras arrive as a tuple
ok keyword extras arrive as a dict
ok both are empty when nothing extra is passed
ok the collected types are tuple and dict
A genuine use: a function that accepts any number of values.
def longest(*words):
if not words:
return None
return max(words, key=len)
print("longest('a', 'abc', 'ab') =", longest("a", "abc", "ab"))
print("longest() =", longest())
# Compare with taking a list, which forces brackets at every call.
def longest_from_list(words):
return max(words, key=len) if words else None
print()
print("with *words: longest('a', 'abc') ->", longest("a", "abc"))
print("with a list: longest_from_list(['a']) ->", longest_from_list(["a"]))
test("any number of arguments is accepted", longest("a", "abc", "ab"), "abc")
test("including none at all", longest(), None)
What it printed, when the build ran it
longest('a', 'abc', 'ab') = abc
longest() = None
with *words: longest('a', 'abc') -> abc
with a list: longest_from_list(['a']) -> a
ok any number of arguments is accepted
ok including none at all
Spreading: the same symbols at a call
def area(width, height):
return width * height
dimensions = (3, 4)
settings = {"width": 5, "height": 6}
# * spreads a sequence into positional arguments.
print("area(*dimensions) =", area(*dimensions))
# ** spreads a mapping into keyword arguments.
print("area(**settings) =", area(**settings))
# Without the star, the whole container arrives as ONE argument.
try:
area(dimensions)
except TypeError as exc:
print()
print("area(dimensions) without the star:", exc)
test("* spreads a tuple into positions", area(*dimensions), 12)
test("** spreads a dict into keywords", area(**settings), 30)
test("the two are equivalent to writing them out", area(*dimensions), area(3, 4))
What it printed, when the build ran it
area(*dimensions) = 12 area(**settings) = 30 area(dimensions) without the star: area() missing 1 required positional argument: 'height' ok * spreads a tuple into positions ok ** spreads a dict into keywords ok the two are equivalent to writing them out
The symmetry, stated plainly. In a def, * and
** mean "gather the rest into this name". At a call they mean "unpack this into
separate arguments". Same symbols, opposite directions — and that is the whole feature.
Passing arguments straight through
The most common real use of both together is a wrapper that accepts anything and forwards it unchanged. This is exactly the shape every decorator has, which lesson 36 builds on.
calls = []
def audited(func):
def wrapper(*args, **kwargs):
calls.append((func.__name__, args, kwargs))
return func(*args, **kwargs)
return wrapper
def area(width, height=1):
return width * height
tracked = audited(area)
print("tracked(3, 4) =", tracked(3, 4))
print("tracked(5, height=2) =", tracked(5, height=2))
print()
print("what was recorded:")
for entry in calls:
print(" ", entry)
test("the wrapper forwards positional arguments", tracked(3, 4), 12)
test("and keyword arguments", tracked(5, height=2), 10)
test("while seeing every call", len(calls), 4)
What it printed, when the build ran it
tracked(3, 4) = 12
tracked(5, height=2) = 10
what was recorded:
('area', (3, 4), {})
('area', (5,), {'height': 2})
ok the wrapper forwards positional arguments
ok and keyword arguments
ok while seeing every call
Keyword-only parameters, which improve ordinary code
A bare * in a signature means "everything after this must be passed by name".
This matters because a positional boolean is unreadable at the call site:
send(msg, True, False) tells a reader nothing.
# Anything after the bare * must be named.
def send(message, *, urgent=False, retry=True):
flags = []
if urgent:
flags.append("urgent")
if retry:
flags.append("retry")
return f"{message} [{', '.join(flags) or 'none'}]"
print(send("hello"))
print(send("hello", urgent=True))
print(send("hello", urgent=True, retry=False))
# Positional booleans are now impossible, which is the point.
try:
send("hello", True)
except TypeError as exc:
print()
print("send('hello', True):", exc)
test("keyword arguments work", send("hello", urgent=True), "hello [urgent, retry]")
test("all flags can be set", send("hi", urgent=True, retry=False), "hi [urgent]")
test("defaults apply when omitted", send("hi"), "hi [retry]")
What it printed, when the build ran it
hello [retry]
hello [urgent, retry]
hello [urgent]
send('hello', True): send() takes 1 positional argument but 2 were given
ok keyword arguments work
ok all flags can be set
ok defaults apply when omitted
A rule worth adopting. If a parameter is a boolean or an option rather than
the thing being operated on, put it after a *. It costs one character and makes
every call site self-documenting — and it means you can reorder or add options later without
breaking anyone, because nobody could have been depending on the position.
Positional-only parameters, with /
The mirror image: everything before a / can only be passed positionally.
You will meet this in the standard library — len(obj) does not accept
len(obj=x) — and it is occasionally useful in your own code to keep a parameter name
free to change.
def clamp(value, low, high, /, *, inclusive=True):
limited = max(low, min(value, high))
return limited if inclusive else limited - 1
print("clamp(5, 0, 10) =", clamp(5, 0, 10))
print("clamp(50, 0, 10) =", clamp(50, 0, 10))
print("clamp(50, 0, 10, inclusive=False) =", clamp(50, 0, 10, inclusive=False))
errors = []
for call in ["clamp(value=5, low=0, high=10)", "clamp(5, 0, 10, False)"]:
try:
eval(call)
except TypeError as exc:
errors.append(f"{call} -> {exc}")
print()
for e in errors:
print(" ", e)
# The same restriction exists on built-ins.
try:
len(obj=[1, 2])
except TypeError as exc:
print()
print("len(obj=[1,2]):", exc)
test("positional calls work", clamp(50, 0, 10), 10)
test("the keyword-only option works", clamp(50, 0, 10, inclusive=False), 9)
test("both invalid forms were rejected", len(errors), 2)
What it printed, when the build ran it
clamp(5, 0, 10) = 5 clamp(50, 0, 10) = 10 clamp(50, 0, 10, inclusive=False) = 9 clamp(value=5, low=0, high=10) -> clamp() got some positional-only arguments passed as keyword arguments: 'value, low, high' clamp(5, 0, 10, False) -> clamp() takes 3 positional arguments but 4 were given len(obj=[1,2]): len() takes no keyword arguments ok positional calls work ok the keyword-only option works ok both invalid forms were rejected
The order everything must appear in
def everything(pos_only, /, normal, *args, kw_only, **kwargs):
return {
"pos_only": pos_only,
"normal": normal,
"args": args,
"kw_only": kw_only,
"kwargs": kwargs,
}
result = everything(1, 2, 3, 4, kw_only=5, extra=6)
for key, value in result.items():
print(f" {key:9} = {value!r}")
print()
print("the order is fixed: positional-only / normal, *args, keyword-only, **kwargs")
test("each part collected what it should",
(result["pos_only"], result["normal"], result["args"], result["kw_only"], result["kwargs"]),
(1, 2, (3, 4), 5, {"extra": 6}))
What it printed, when the build ran it
pos_only = 1
normal = 2
args = (3, 4)
kw_only = 5
kwargs = {'extra': 6}
the order is fixed: positional-only / normal, *args, keyword-only, **kwargs
ok each part collected what it should
Note that a parameter after *args is automatically keyword-only — there is no way
to reach it positionally, because *args has already swallowed everything.
def f(*args, required):
return args, required
f(1, 2, 3)
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 4, in <module>
f(1, 2, 3)
~^^^^^^^^^
TypeError: f() missing 1 required keyword-only argument: 'required'
Unpacking in other places
# Merging containers with the same symbols.
a = [1, 2]
b = [3, 4]
print("merged list: ", [*a, *b])
first = {"x": 1}
second = {"y": 2, "x": 99}
print("merged dict: ", {**first, **second}, "<- later wins")
# Splitting a sequence, which lesson 6 introduced. Same symbol again.
head, *tail = [1, 2, 3]
print("head/tail: ", head, tail)
# And in a function call, mixing spread with literal arguments.
def total(*numbers):
return sum(numbers)
print()
print("total(*a, 10, *b) =", total(*a, 10, *b))
test("lists merge with *", [*a, *b], [1, 2, 3, 4])
test("dicts merge with **, later winning", {**first, **second}["x"], 99)
test("spread and literals can be mixed in one call", total(*a, 10, *b), 20)
What it printed, when the build ran it
merged list: [1, 2, 3, 4]
merged dict: {'x': 99, 'y': 2} <- later wins
head/tail: 1 [2, 3]
total(*a, 10, *b) = 20
ok lists merge with *
ok dicts merge with **, later winning
ok spread and literals can be mixed in one call
What to take from this lesson
- In a definition,
*argsand**kwargscollect the extras as a tuple and a dict. At a call,*and**spread a collection into arguments. *args, **kwargstogether is how a wrapper forwards anything unchanged.- Put options and booleans after a bare
*so callers must name them. /makes the parameters before it positional-only; anything after*argsis keyword-only automatically.- The same symbols merge containers and split sequences.
17 Scope, closures and the LEGB rule
When Python meets a name it searches four places in a fixed order: Local, Enclosing, Global, Built-in. That order is the LEGB rule, and knowing it turns two of the most baffling errors in the language into obvious ones.
The four scopes, seen at once
shared = "global"
def outer():
shared = "enclosing"
def inner():
shared = "local"
return shared
return inner(), shared
inner_saw, outer_saw = outer()
print("inner saw: ", inner_saw)
print("outer saw: ", outer_saw)
print("module saw:", shared)
# With the local removed, the search moves outward one step at a time.
def find_enclosing():
shared = "enclosing"
def inner():
return shared # no local, so the enclosing one is found
return inner()
def find_global():
def inner():
return shared # no local or enclosing, so the global one
return inner()
print()
print("no local ->", find_enclosing())
print("no local or enclosing ->", find_global())
print("built-in ->", len("abc"), "(len came from the builtins scope)")
test("the innermost binding wins", inner_saw, "local")
test("and does not affect the enclosing one", outer_saw, "enclosing")
test("nor the global one", shared, "global")
test("with no local, the enclosing scope is used", find_enclosing(), "enclosing")
test("with neither, the global is used", find_global(), "global")
What it printed, when the build ran it
inner saw: local outer saw: enclosing module saw: global no local -> enclosing no local or enclosing -> global built-in -> 3 (len came from the builtins scope) ok the innermost binding wins ok and does not affect the enclosing one ok nor the global one ok with no local, the enclosing scope is used ok with neither, the global is used
Reading is easy; assigning changes everything
A function can read an outer name without ceremony. But assigning to a name anywhere in a function makes that name local for the whole function — decided when the function is compiled, before a single line of it runs.
count = 10
def just_reading():
return count * 2 # reads the global, no problem
def assigning():
count = 99 # creates a LOCAL, unrelated to the global
return count
print("reading the global: ", just_reading())
print("assigning locally: ", assigning())
print("the global is still:", count)
# The evidence: Python records which names are local at compile time.
print()
print("locals of assigning(): ", assigning.__code__.co_varnames)
print("globals just_reading() uses:", just_reading.__code__.co_names)
test("reading reaches the global", just_reading(), 20)
test("assigning creates a separate local", assigning(), 99)
test("leaving the global untouched", count, 10)
test("the compiler already knows 'count' is local in assigning()",
"count" in assigning.__code__.co_varnames, True)
What it printed, when the build ran it
reading the global: 20
assigning locally: 99
the global is still: 10
locals of assigning(): ('count',)
globals just_reading() uses: ('count',)
ok reading reaches the global
ok assigning creates a separate local
ok leaving the global untouched
ok the compiler already knows 'count' is local in assigning()
Now the error that follows from it. Because the assignment makes the name local everywhere in the function, reading it before that assignment runs is reading a local that has no value yet — not the global.
count = 10
def bump():
count = count + 1
return count
bump()
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 7, in <module>
bump()
~~~~^^
File "example.py", line 4, in bump
count = count + 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Read the error literally and it makes sense. "cannot access local variable
'count' where it is not associated with a value" — Python is telling you it decided
count is local (because the function assigns to it) and that the local has no value
at the moment you read it. The global count = 10 is never consulted. The same
applies to +=, which is an assignment.
global and nonlocal, and why to avoid the first
count = 0
def bump_global():
global count # "assignments here mean the module-level name"
count += 1
return count
print("calls:", bump_global(), bump_global(), bump_global())
print("the module-level count is now:", count)
# nonlocal targets the nearest ENCLOSING function scope, not the module.
def make_counter():
total = 0
def increment():
nonlocal total # not global -- the enclosing 'total'
total += 1
return total
return increment
counter = make_counter()
print()
print("independent counter:", counter(), counter(), counter())
# Two counters do not share state, because each call to make_counter made its own.
other = make_counter()
print("a second counter starts fresh:", other())
test("global lets a function rebind a module-level name", count, 3)
test("nonlocal rebinds the enclosing one", counter(), 4)
test("each closure has its own state", other(), 2)
What it printed, when the build ran it
calls: 1 2 3 the module-level count is now: 3 independent counter: 1 2 3 a second counter starts fresh: 1 ok global lets a function rebind a module-level name ok nonlocal rebinds the enclosing one ok each closure has its own state
Prefer returning a value to using global. A function that
rebinds module state can be called in the wrong order, cannot be tested in isolation, and makes
every caller depend on invisible history. nonlocal is more defensible because the
state is genuinely private to the closure — but a class (lesson 28) is usually clearer once there
is more than one piece of it.
Mutation is not assignment, which is the exception to all of this
You need global to rebind a name. You do not need it to mutate the
object the name already points at — because mutating is not assigning. This trips people in both
directions.
items = []
config = {"debug": False}
def add_item(x):
items.append(x) # mutating: no global needed
return len(items)
def replace_items():
global items
items = ["replaced"] # rebinding: global IS needed
def set_debug(value):
config["debug"] = value # mutating a dict: no global needed
add_item("a")
add_item("b")
set_debug(True)
print("after mutation:", items, config)
replace_items()
print("after rebinding:", items)
test("mutation reaches the module-level list without global", len(items), 1)
test("mutating a dict works the same way", config["debug"], True)
What it printed, when the build ran it
after mutation: ['a', 'b'] {'debug': True}
after rebinding: ['replaced']
ok mutation reaches the module-level list without global
ok mutating a dict works the same way
Closures capture names, not values
A closure remembers the variable from its enclosing scope, not a snapshot of what that variable held when the function was defined. That is usually what you want, and in a loop it is the cause of the most confusing bug in this lesson.
# Three functions, all closing over the same 'i'.
funcs = [lambda: i for i in range(3)]
print("expected [0, 1, 2], got:", [f() for f in funcs])
print("because all three look up the same 'i', which ended at 2")
# Fix one: a default argument, which captures the VALUE at definition time.
by_default = [lambda i=i: i for i in range(3)]
print()
print("with a default argument:", [f() for f in by_default])
# Fix two: a factory, giving each closure its own scope.
def make(value):
return lambda: value
by_factory = [make(i) for i in range(3)]
print("with a factory: ", [f() for f in by_factory])
# Fix three: functools.partial, which binds the argument now.
from functools import partial
by_partial = [partial(lambda v: v, i) for i in range(3)]
print("with functools.partial: ", [f() for f in by_partial])
test("the naive version captures the name, so all agree", [f() for f in funcs], [2, 2, 2])
test("a default argument captures the value", [f() for f in by_default], [0, 1, 2])
test("a factory gives each its own scope", [f() for f in by_factory], [0, 1, 2])
test("partial binds the argument immediately", [f() for f in by_partial], [0, 1, 2])
What it printed, when the build ran it
expected [0, 1, 2], got: [2, 2, 2] because all three look up the same 'i', which ended at 2 with a default argument: [0, 1, 2] with a factory: [0, 1, 2] with functools.partial: [0, 1, 2] ok the naive version captures the name, so all agree ok a default argument captures the value ok a factory gives each its own scope ok partial binds the argument immediately
You can inspect what a closure actually captured, which makes the mechanism concrete rather than mysterious.
def make_multiplier(factor):
def multiply(x):
return x * factor
return multiply
triple = make_multiplier(3)
print("triple(5) =", triple(5))
# The captured variables are stored on the function.
print()
print("free variables:", triple.__code__.co_freevars)
print("their cells: ", triple.__closure__)
print("the value inside:", triple.__closure__[0].cell_contents)
test("the closure works", triple(5), 15)
test("it records which names it closed over", triple.__code__.co_freevars, ("factor",))
test("and the captured value is retrievable", triple.__closure__[0].cell_contents, 3)
What it printed, when the build ran it
triple(5) = 15
free variables: ('factor',)
their cells: (<cell at 0x10ab1f340: int object at 0x10b3067f0>,)
the value inside: 3
ok the closure works
ok it records which names it closed over
ok and the captured value is retrievable
Comprehensions have their own scope
From lesson 12: a comprehension's loop variable does not leak. The reason is that a comprehension runs in its own implicit function scope — which also means it can read enclosing names but assigning inside it stays inside it.
multiplier = 10
# The comprehension can read an enclosing name.
scaled = [n * multiplier for n in range(4)]
print("scaled:", scaled)
# Its loop variable does not escape.
print("is 'n' defined out here?", "n" in dir())
# A plain for loop's variable does.
for m in range(4):
pass
print("is 'm' defined out here?", "m" in dir())
# The walrus operator is the deliberate exception: it assigns to the ENCLOSING scope.
values = [1, 2, 3, 4, 5]
filtered = [squared for n in values if (squared := n * n) > 4]
print()
print("filtered:", filtered)
print("'squared' leaked on purpose, holding:", squared)
test("the comprehension reads the enclosing name", scaled, [0, 10, 20, 30])
test("its loop variable does not leak", "n" in dir(), False)
test("a for loop's variable does", "m" in dir(), True)
test("the walrus assigns outward deliberately", squared, 25)
What it printed, when the build ran it
scaled: [0, 10, 20, 30] is 'n' defined out here? False is 'm' defined out here? True filtered: [9, 16, 25] 'squared' leaked on purpose, holding: 25 ok the comprehension reads the enclosing name ok its loop variable does not leak ok a for loop's variable does ok the walrus assigns outward deliberately
What to take from this lesson
- Names are looked up Local, Enclosing, Global, Built-in, in that order.
- Assigning to a name anywhere in a function makes it local for the whole function. Reading it
before the assignment is
UnboundLocalError. globalrebinds a module-level name;nonlocalrebinds an enclosing function's. Prefer returning values to either.- Mutating an object needs no declaration; rebinding a name does.
- Closures capture the variable, not its value — which is why a loop produces identical
closures unless you bind the value with a default, a factory, or
partial.
18 lambda, map and filter, and when to use none of them
A lambda is a function written as an expression. It exists
because sometimes you need a function in a place where a def would be awkward — as an
argument, mostly. Outside that, a named function is almost always better, and this lesson is as
much about when not to use these tools as how.
What a lambda is
# These two are the same function, written two ways.
def double_def(x):
return x * 2
double_lambda = lambda x: x * 2
print("def version: ", double_def(5))
print("lambda version:", double_lambda(5))
# A lambda can take any arguments a def can.
add = lambda a, b=10, *rest: a + b + sum(rest)
print()
print("add(1) =", add(1))
print("add(1, 2) =", add(1, 2))
print("add(1, 2, 3, 4) =", add(1, 2, 3, 4))
# The difference that shows up in a traceback: a lambda has no useful name.
print()
print("def name: ", double_def.__name__)
print("lambda name:", double_lambda.__name__)
test("both compute the same thing", double_def(5), double_lambda(5))
test("a lambda takes defaults and *args too", add(1, 2, 3, 4), 10)
test("but it has no meaningful name", double_lambda.__name__, "<lambda>")
What it printed, when the build ran it
def version: 10 lambda version: 10 add(1) = 11 add(1, 2) = 3 add(1, 2, 3, 4) = 10 def name: double_def lambda name: <lambda> ok both compute the same thing ok a lambda takes defaults and *args too ok but it has no meaningful name
Never assign a lambda to a name. double = lambda x: x * 2 is
strictly worse than def double(x): return x * 2 — same length, but the
def version gets a real name in every traceback, can hold a docstring, and can grow
to two lines later without being rewritten. PEP 8 says this explicitly, and the reason is the
<lambda> above: when it fails in production you will want to know which one
failed.
A lambda can only be one expression
This is the real limitation. No statements — no if block, no loop, no assignment,
no return. A conditional expression is allowed because it is an expression, and
that is where lambdas start becoming unreadable.
classify = lambda n:
if n > 0:
return "positive"
return "negative"
The traceback it really produced, when the build ran it
File "example.py", line 1
classify = lambda n:
^
SyntaxError: invalid syntax
# A conditional expression is legal, and fine at this size.
sign = lambda n: "positive" if n > 0 else "negative"
print("sign(5) :", sign(5))
print("sign(-5):", sign(-5))
# Nested, it stops being readable -- and this is where people stop and shouldn't.
messy = lambda n: "positive" if n > 0 else ("zero" if n == 0 else "negative")
print()
print("the nested version works:", [messy(n) for n in (5, 0, -5)])
# The same logic as a def, which anyone can read.
def describe(n):
if n > 0:
return "positive"
if n == 0:
return "zero"
return "negative"
print("the def version agrees: ", [describe(n) for n in (5, 0, -5)])
test("a conditional expression is allowed in a lambda", sign(5), "positive")
test("nesting works but should not be written", [messy(n) for n in (5, 0, -5)],
["positive", "zero", "negative"])
test("the def version is equivalent", [describe(n) for n in (5, 0, -5)],
[messy(n) for n in (5, 0, -5)])
What it printed, when the build ran it
sign(5) : positive sign(-5): negative the nested version works: ['positive', 'zero', 'negative'] the def version agrees: ['positive', 'zero', 'negative'] ok a conditional expression is allowed in a lambda ok nesting works but should not be written ok the def version is equivalent
Where lambda genuinely belongs: key=
Sorting, max, min and groupby all take a function that
extracts what to compare. Writing that inline is exactly the case lambda was added for.
people = [
{"name": "Ada", "age": 36},
{"name": "Alan", "age": 41},
{"name": "Grace", "age": 36},
]
by_age = sorted(people, key=lambda p: p["age"])
print("by age: ", [p["name"] for p in by_age])
oldest = max(people, key=lambda p: p["age"])
print("oldest: ", oldest["name"])
# Sorting by two things: return a tuple, and it compares left to right.
by_age_then_name = sorted(people, key=lambda p: (p["age"], p["name"]))
print("age, then name:", [p["name"] for p in by_age_then_name])
# Descending on one field, ascending on another: negate the numeric one.
mixed = sorted(people, key=lambda p: (-p["age"], p["name"]))
print("age desc, name asc:", [p["name"] for p in mixed])
test("key= extracts what to compare", [p["name"] for p in by_age][0], "Ada")
test("max takes the same key function", oldest["name"], "Alan")
test("a tuple key sorts by several fields", [p["name"] for p in by_age_then_name],
["Ada", "Grace", "Alan"])
test("negating reverses one field only", [p["name"] for p in mixed], ["Alan", "Ada", "Grace"])
What it printed, when the build ran it
by age: ['Ada', 'Grace', 'Alan'] oldest: Alan age, then name: ['Ada', 'Grace', 'Alan'] age desc, name asc: ['Alan', 'Ada', 'Grace'] ok key= extracts what to compare ok max takes the same key function ok a tuple key sorts by several fields ok negating reverses one field only
For the common cases — pulling out an item or an attribute — the standard library has named functions that are faster and say what they mean.
from operator import itemgetter, attrgetter
from collections import namedtuple
people = [
{"name": "Ada", "age": 36},
{"name": "Alan", "age": 41},
]
# itemgetter replaces lambda p: p["age"]
print("with a lambda: ", [p["name"] for p in sorted(people, key=lambda p: p["age"])])
print("with itemgetter: ", [p["name"] for p in sorted(people, key=itemgetter("age"))])
# It can take several keys at once, returning a tuple.
print("two keys at once: ", itemgetter("name", "age")(people[0]))
# attrgetter does the same for attributes.
Person = namedtuple("Person", "name age")
records = [Person("Ada", 36), Person("Alan", 41)]
print()
print("with attrgetter: ", [p.name for p in sorted(records, key=attrgetter("age"))])
test("itemgetter matches the lambda", sorted(people, key=itemgetter("age"))[0]["name"], "Ada")
test("it can extract several keys", itemgetter("name", "age")(people[0]), ("Ada", 36))
test("attrgetter does the same for attributes",
[p.name for p in sorted(records, key=attrgetter("age"))], ["Ada", "Alan"])
What it printed, when the build ran it
with a lambda: ['Ada', 'Alan']
with itemgetter: ['Ada', 'Alan']
two keys at once: ('Ada', 36)
with attrgetter: ['Ada', 'Alan']
ok itemgetter matches the lambda
ok it can extract several keys
ok attrgetter does the same for attributes
map and filter, and the comprehension that replaces them
numbers = [1, 2, 3, 4, 5]
# map applies a function to every item; filter keeps the ones that pass a test.
doubled_map = list(map(lambda n: n * 2, numbers))
evens_filter = list(filter(lambda n: n % 2 == 0, numbers))
# The comprehension versions, which most Python programmers find clearer.
doubled_comp = [n * 2 for n in numbers]
evens_comp = [n for n in numbers if n % 2 == 0]
print("map: ", doubled_map)
print("comprehension:", doubled_comp)
print()
print("filter: ", evens_filter)
print("comprehension:", evens_comp)
# Combining both is where map/filter gets hard to read...
combined_mf = list(map(lambda n: n * 2, filter(lambda n: n % 2 == 0, numbers)))
# ...and the comprehension stays flat.
combined_comp = [n * 2 for n in numbers if n % 2 == 0]
print()
print("map(filter(...)):", combined_mf, " <- read it right to left")
print("comprehension: ", combined_comp, " <- read it left to right")
test("map and the comprehension agree", doubled_map, doubled_comp)
test("filter and the comprehension agree", evens_filter, evens_comp)
test("and so do the combined forms", combined_mf, combined_comp)
What it printed, when the build ran it
map: [2, 4, 6, 8, 10] comprehension: [2, 4, 6, 8, 10] filter: [2, 4] comprehension: [2, 4] map(filter(...)): [4, 8] <- read it right to left comprehension: [4, 8] <- read it left to right ok map and the comprehension agree ok filter and the comprehension agree ok and so do the combined forms
When map is the better choice. When the function already exists
and has a name. map(str.strip, lines) is cleaner than
[line.strip() for line in lines], and map(int, values) is cleaner than
[int(v) for v in values] — there is no lambda and no repeated loop variable. The
moment you write map(lambda ...), a comprehension is clearer.
lines = [" ada ", " alan", "grace "]
values = ["1", "2", "3"]
# No lambda needed: the function already has a name.
print("map with an existing function:", list(map(str.strip, lines)))
print("the comprehension equivalent: ", [line.strip() for line in lines])
print()
print("map(int, ...):", list(map(int, values)))
# map is lazy, which matters on large inputs.
import sys
lazy = map(int, range(100_000))
eager = [int(n) for n in range(100_000)]
print()
print("map object: ", f"{sys.getsizeof(lazy):>7,} bytes")
print("list result: ", f"{sys.getsizeof(eager):>7,} bytes")
# map over two iterables at once, which a comprehension needs zip for.
print()
print("map over two:", list(map(lambda a, b: a + b, [1, 2], [10, 20])))
test("map with a named function is tidy", list(map(str.strip, lines)), ["ada", "alan", "grace"])
test("map is lazy, so it holds nothing", sys.getsizeof(lazy) < sys.getsizeof(eager), True)
test("map can walk two iterables", list(map(lambda a, b: a + b, [1, 2], [10, 20])), [11, 22])
What it printed, when the build ran it
map with an existing function: ['ada', 'alan', 'grace'] the comprehension equivalent: ['ada', 'alan', 'grace'] map(int, ...): [1, 2, 3] map object: 56 bytes list result: 800,984 bytes map over two: [11, 22] ok map with a named function is tidy ok map is lazy, so it holds nothing ok map can walk two iterables
Lambdas capture names, exactly like any other closure
The late-binding behaviour from lesson 17 is not a lambda quirk — it applies to every function — but it is where people meet it, because lambdas are what end up in loops.
# The same trap as lesson 17, since a lambda is just a function.
multipliers = [lambda x: x * i for i in range(1, 4)]
print("all use the final i:", [f(10) for f in multipliers])
# Bind the value with a default argument.
fixed = [lambda x, i=i: x * i for i in range(1, 4)]
print("bound per lambda: ", [f(10) for f in fixed])
test("late binding affects lambdas too", [f(10) for f in multipliers], [30, 30, 30])
test("a default argument fixes it", [f(10) for f in fixed], [10, 20, 30])
What it printed, when the build ran it
all use the final i: [30, 30, 30] bound per lambda: [10, 20, 30] ok late binding affects lambdas too ok a default argument fixes it
What to take from this lesson
- A lambda is a one-expression function with no name. It cannot contain statements.
- Never assign a lambda to a name — use
def, and get a real name in tracebacks. - Use a lambda as a
key=argument, and preferitemgetterorattrgetterfor the common extractions. - Prefer a comprehension to
map(lambda ...)orfilter(lambda ...); prefermapwhen the function already has a name. - Lambdas close over names, so the loop trap from lesson 17 applies to them too.