Functions in Depth
Functions in Depth
A function is a named piece of work with a clear input and a clear output. The boring default is a short def, an explicit return, and arguments you can read at the call site. Fancy packing (*args, **kwargs) is for the edges, not for every helper.
Mental model
def binds a name to a function object. Parameters are the names in the def line. Arguments are the values you pass at the call. If you omit return, Python returns None.
Several values come back as a tuple. The caller unpacks them, or keeps the tuple.
A default fills in a parameter you skip. A keyword-only parameter (after a bare *) must be passed by name, so tax=0.1 cannot be confused with another number. *args gathers extra positional arguments into a tuple. **kwargs gathers extra keywords into a dict. A leading * or ** at the call site unpacks a sequence or a dict into arguments.
Worked examples
Case 1: def and return
Save as ticket_label.py. The function takes a ticket dict and returns a string. print happens in main, not inside label.
# ticket_label.py
def label(ticket):
return f"ticket {ticket['id']} → table {ticket['table']}"
def main():
t = {"id": 7, "table": 12}
text = label(t)
print(text)
print(label.__name__)
if __name__ == "__main__":
main()Run:
uv run python ticket_label.pyOutput:
ticket 7 → table 12
label
The function object has a __name__. You will need that later for decorators.
Case 2: Several values as a tuple
Save as split_shift.py. A shift string "09:00-17:00" splits into start and end. Returning two strings is returning one tuple.
# split_shift.py
def split_shift(text):
start, end = text.split("-", 1)
return start, end
def main():
pair = split_shift("09:00-17:00")
print(type(pair).__name__, pair)
start, end = split_shift("09:00-17:00")
print(start)
print(end)
if __name__ == "__main__":
main()Run:
uv run python split_shift.pyOutput:
tuple ('09:00', '17:00')
09:00
17:00
Name the two results at the call site. Do not invent a one-off class for two strings.
Case 3: Defaults
Save as add_tax.py. Most orders use the desk rate. A catering order can override it.
# add_tax.py
def with_tax(cents, rate=0.1):
return round(cents * (1 + rate))
def main():
print(with_tax(400))
print(with_tax(400, 0.0))
print(with_tax(400, rate=0.2))
if __name__ == "__main__":
main()Run:
uv run python add_tax.pyOutput:
440
400
480
Defaults are evaluated once, when def runs, not on every call. That fact is the trap at the end of this chapter.
Case 4: Keyword-only arguments
Save as charge.py. After the *, tax and tip must be passed by name. A second positional number is a TypeError, not a silent mix-up of tax and tip.
# charge.py
def charge(cents, *, tax=0.1, tip=0):
return round(cents * (1 + tax) + tip)
def main():
print(charge(400))
print(charge(400, tax=0.2, tip=50))
try:
charge(400, 0.2)
except TypeError as e:
print(type(e).__name__ + ":", e)
if __name__ == "__main__":
main()Run:
uv run python charge.pyOutput:
440
530
TypeError: charge() takes 1 positional argument but 2 were given
Use keyword-only parameters when two numbers would be easy to swap.
Case 5: *args, **kwargs, and unpacking
Save as announce.py. Extra words land in parts. Extra flags land in flags. At the call site, * unpacks a tuple and ** unpacks a dict.
# announce.py
def announce(*parts, **flags):
text = " ".join(str(p) for p in parts)
if flags.get("shout"):
text = text.upper()
return text
def main():
row = ("ticket", 7, "table", 12)
print(announce(*row))
opts = {"shout": True}
print(announce("desk", "open", **opts))
print(announce("quiet", "please"))
if __name__ == "__main__":
main()Run:
uv run python announce.pyOutput:
ticket 7 table 12
DESK OPEN
quiet please
Reach for *args / **kwargs when you are forwarding arguments to another function. Do not use them to avoid naming the three parameters you actually have.
The trap
A default list is one list, shared by every call that omits bag. The second add_item("tea") still sees the latte.
Save as shared_bag.py:
# shared_bag.py
def add_item(item, bag=[]):
bag.append(item)
return bag
def add_item_ok(item, bag=None):
if bag is None:
bag = []
bag.append(item)
return bag
def main():
a = add_item("latte")
b = add_item("tea")
print("shared:", a, b, a is b)
c = add_item_ok("latte")
d = add_item_ok("tea")
print("fresh:", c, d, c is d)
if __name__ == "__main__":
main()Run:
uv run python shared_bag.pyOutput:
shared: ['latte', 'tea'] ['latte', 'tea'] True
fresh: ['latte'] ['tea'] False
The boring default for a mutable default is None, then a new list (or dict, or set) inside the function.
The boring rule
- Return a value. Leave
printto the caller unless the function is the printer. - Several results: return a tuple and unpack it.
- Defaults for immutable values (
int,str,None,False) are fine. Mutable defaults are not. - Make easy-to-swap numbers keyword-only.
- Name parameters. Use
*args/**kwargsto forward, not to hide. - Unpack with
*and**at the call site when you already have a sequence or a dict.
Try this
- In
ticket_label.py, returnNoneon purpose (noreturn) and print the result. Then put thereturnback. - In
split_shift.py, add a third value: the length of the shift in hours, as a string like"8h". Unpack three names. - In
charge.py, add keyword-onlyservice=0. Show thatcharge(400, 1)still fails andcharge(400, service=1)works. - In
shared_bag.py, pass an explicit list intoadd_item_oktwice and confirm it does grow — that is the caller’s list, not a hidden default.