Functions as Values
Functions as Values
A function is a value. You can pass it, store it, and return it. The boring default is a named def you hand to sorted, min, or a small dispatch dict. Keep lambda tiny. Prefer a comprehension over map and filter.
Mental model
A callable is anything you can follow with (...). Function objects are callables. So are methods. sorted does not know about tickets; it only knows how to compare keys. You pass key=some_function and sorted calls that function once per item.
A dispatch table is a dict from a name to a function. Instead of a long if/elif chain that you forget to extend, you look up the function and call it.
A lambda is a one-expression function with no name. If it needs a statement, a name, or more than one expression, use def.
map and filter apply a function to an iterable and return an iterator. A list comprehension does the same job with ordinary for and if. That is the form a teammate will edit at 5 p.m.
Worked examples
Case 1: Pass a function to sorted
Save as sort_tickets.py. by_table is an ordinary function. sorted calls it for each ticket.
# sort_tickets.py
def by_table(ticket):
return ticket["table"]
def main():
tickets = [
{"id": 9, "table": 12},
{"id": 3, "table": 4},
{"id": 7, "table": 12},
]
for t in sorted(tickets, key=by_table):
print(f"ticket {t['id']} table {t['table']}")
if __name__ == "__main__":
main()Run:
uv run python sort_tickets.pyOutput:
ticket 3 table 4
ticket 9 table 12
ticket 7 table 12
Equal tables keep their original order. sorted is stable.
Case 2: A dict of functions
Save as dispatch.py. The desk has three verbs. Each verb is a function. Unknown verbs raise KeyError, which you turn into a ValueError with a clear message.
# dispatch.py
def open_ticket(n):
return f"opened table {n}"
def pay_ticket(n):
return f"paid table {n}"
def close_ticket(n):
return f"closed table {n}"
HANDLERS = {
"open": open_ticket,
"pay": pay_ticket,
"close": close_ticket,
}
def run(verb, n):
try:
fn = HANDLERS[verb]
except KeyError:
raise ValueError(f"unknown verb {verb!r}") from None
return fn(n)
def main():
print(run("open", 12))
print(run("pay", 12))
try:
run("refund", 12)
except ValueError as e:
print(type(e).__name__ + ":", e)
if __name__ == "__main__":
main()Run:
uv run python dispatch.pyOutput:
opened table 12
paid table 12
ValueError: unknown verb 'refund'
Add a verb by writing a function and one dict line. Do not grow a twelve-branch if.
Case 3: A tiny lambda
Save as tiny_lambda.py. The key is one attribute lookup. A lambda is acceptable here. The next line uses a named function for the same job so you can see both.
# tiny_lambda.py
def by_id(ticket):
return ticket["id"]
def main():
tickets = [
{"id": 9, "table": 12},
{"id": 3, "table": 4},
]
by_lambda = sorted(tickets, key=lambda t: t["id"])
by_def = sorted(tickets, key=by_id)
print([t["id"] for t in by_lambda])
print([t["id"] for t in by_def])
if __name__ == "__main__":
main()Run:
uv run python tiny_lambda.pyOutput:
[3, 9]
[3, 9]
If the key grows a second line, delete the lambda and keep the def.
Case 4: map / filter versus a comprehension
Save as open_tables.py. Both styles produce the same list. The comprehension is the one you keep.
# open_tables.py
def main():
tickets = [
{"id": 9, "table": 12, "status": "open"},
{"id": 3, "table": 4, "status": "paid"},
{"id": 7, "table": 12, "status": "open"},
]
mapped = list(map(lambda t: t["id"], tickets))
filtered = list(filter(lambda t: t["status"] == "open", tickets))
ids = [t["id"] for t in tickets]
open_ids = [t["id"] for t in tickets if t["status"] == "open"]
print("map:", mapped)
print("filter ids:", [t["id"] for t in filtered])
print("comp:", ids)
print("comp open:", open_ids)
if __name__ == "__main__":
main()Run:
uv run python open_tables.pyOutput:
map: [9, 3, 7]
filter ids: [9, 7]
comp: [9, 3, 7]
comp open: [9, 7]
map and filter are not wrong. They are just noisier once the function is a lambda. A comprehension already has for and if.
The trap
Assigning a lambda to a name is a def with worse traceback names and no statements. ruff will flag it (E731). Use def.
Save as named_lambda.py:
# named_lambda.py
def main():
bump = lambda cents: cents + 50
print(bump(400))
print(bump.__name__)
if __name__ == "__main__":
main()Run:
uv run python named_lambda.pyOutput:
450
<lambda>
The fix is a one-line def:
# named_def.py
def bump(cents):
return cents + 50
def main():
print(bump(400))
print(bump.__name__)
if __name__ == "__main__":
main()Run:
uv run python named_def.pyOutput:
450
bump
Same work. A real name in traces and in help.
The boring rule
- Pass named functions into
sorted,min,max, and your own helpers. - A small dict of functions is better than a growing
if/elifladder. lambdais for a single expression you will not reuse. Do not assign it to a name.- Prefer
[... for ... in ... if ...]overlist(map(...))andlist(filter(...)). - If the callable needs a body, it needs a
def.
Try this
- In
sort_tickets.py, sort by(table, id)so table 12’s tickets come out 7 then 9. A named function that returns a tuple is fine. - In
dispatch.py, addvoidthat returns"voided table {n}"and call it. - In
open_tables.py, dropmapandfilter. Keep only the comprehensions. Print tickets whose table is 12. - Rewrite
named_lambda.pysobumpalso prints nothing extra — just return the value — usingdef.