Modern Python Basics #5: Functions — argument patterns
If #4 Collections and comprehensions covered the tools for handling data, this post is about how to express the signatures of functions that take that data and do work. Python functions have a very rich notation for arguments — a strength, but also a source of confusion at first. This post gathers everything in one place.
The simplest function #
def greet(name: str) -> None:
print(f"hi, {name}!")
greet("커티스") # hi, 커티스!def keyword, then a colon and indented body. Writing type hints (: str, -> None) from #2 on every function is the modern Python promise.
return and multiple returns
#
def split_name(full: str) -> tuple[str, str]:
first, last = full.split(" ", 1)
return first, last
first, last = split_name("커티스 김")
print(first, last) # 커티스 김return a, b is actually return (a, b). The receiver unpacks it. Similar usefulness to JavaScript object destructuring, done with tuples.
If there’s no return, the function returns None. Whether you write it explicitly or not, the result is the same.
Default values #
def greet(name: str, greeting: str = "hi") -> None:
print(f"{greeting}, {name}!")
greet("커티스") # hi, 커티스!
greet("커티스", "안녕하세요") # 안녕하세요, 커티스!Parameters with defaults must come after parameters without.
def bad(x: int = 0, y: int) -> int: ...
# SyntaxError: non-default argument follows default argumentPitfall — mutable default #
This is one of Python’s most famous traps.
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← ?!
print(append_to(3)) # [1, 2, 3]The default [] is created once when the function is defined, and every call shares the same object. A new empty list isn’t created on each call.
Fix: use None as the default and create it inside the function.
def append_to(item, target: list[int] | None = None):
if target is None:
target = []
target.append(item)
return targetYou’ll see this pattern often. Memorize it.
Positional vs keyword calls #
When calling, you can pass arguments two ways.
def create_user(name: str, age: int, role: str = "member") -> dict:
return {"name": name, "age": age, "role": role}
# positional — by position
create_user("커티스", 30)
create_user("커티스", 30, "admin")
# keyword — by name
create_user(name="커티스", age=30)
create_user(age=30, name="커티스") # order doesn't matter
# Mixed (positional first)
create_user("커티스", age=30, role="admin")To keep intent visible at the call site, keyword calls are powerful. A call like create_user("커티스", 30, True, False, "admin") is hard to read; create_user(name="커티스", age=30, is_active=True, is_admin=False, role="admin") is immediately clear.
*args — variable positional arguments
#
A * before a parameter means “receive all remaining positional args as a tuple.”
def add(*nums: int) -> int:
total = 0
for n in nums:
total += n
return total
print(add(1, 2, 3)) # 6
print(add(1, 2, 3, 4, 5)) # 15
print(add()) # 0The conventional name is args, but you can use any meaningful name like *nums.
Spreading at the call site #
* also works at the call site. It unpacks a sequence into positional args.
def add(a: int, b: int, c: int) -> int:
return a + b + c
nums = [1, 2, 3]
print(add(*nums)) # spreads to 1, 2, 3 → 6**kwargs — variable keyword arguments
#
A ** means “receive all remaining keyword args as a dict.”
def configure(**options: str) -> None:
for key, value in options.items():
print(f"{key} = {value}")
configure(host="localhost", port="5432", db="myapp")
# host = localhost
# port = 5432
# db = myappSpreading at the call site #
** on the caller side unpacks a dict into keyword args.
def create_user(name: str, age: int, role: str) -> dict:
return {"name": name, "age": age, "role": role}
data = {"name": "커티스", "age": 30, "role": "admin"}
print(create_user(**data))Commonly used to pass an API response dict directly into a function.
All notations together #
def fn(a: int, b: int, *args: int, **kwargs: str) -> None:
print(a, b, args, kwargs)
fn(1, 2, 3, 4, 5, name="커티스", role="admin")
# 1 2 (3, 4, 5) {'name': '커티스', 'role': 'admin'}Order: regular args → *args → regular args (keyword-only) → **kwargs.
Keyword-only — using * alone
#
A bare * makes everything after it receivable only by keyword.
def create(name: str, *, role: str = "member", active: bool = True) -> dict:
return {"name": name, "role": role, "active": active}
create("커티스") # OK
create("커티스", role="admin") # OK
create("커티스", "admin") # ✗ TypeError
# create() takes 1 positional argument but 2 were givenStops calls like create("커티스", "admin", True). When there are several boolean flags, this forces meaningful calls.
Positional-only — using / alone (3.8+)
#
Conversely, parameters before / are positional only.
def power(base: float, exp: float = 2, /) -> float:
return base ** exp
power(3) # 9
power(2, 3) # 8
power(base=3) # ✗ TypeError — can't use base as keywordWhen to use it? When you want the freedom to rename parameters in the future. If callers can’t bind to names, library maintainers can rename them without breaking call sites. Most built-ins (abs(x), len(seq)) take this form.
All three together #
def f(pos1: int, pos2: int, /, normal: int, *, kw1: int, kw2: int) -> None:
print(pos1, pos2, normal, kw1, kw2)
# pos1, pos2 → position only
# normal → position or keyword
# kw1, kw2 → keyword only
f(1, 2, 3, kw1=4, kw2=5) # OK
f(1, 2, normal=3, kw1=4, kw2=5) # OK
f(pos1=1, pos2=2, ...) # ✗ pos1, pos2 can't be keyword
f(1, 2, 3, 4, 5) # ✗ kw1, kw2 can't be positionalHow to read:
- Before
/= positional-only - Between
/and*= regular - After
*= keyword-only
Lambdas — single-line functions #
lambda is an anonymous function. Can hold only a single expression.
square = lambda x: x ** 2
print(square(5)) # 25
# Common in callback positions like sorted's key
people = [("커티스", 30), ("스미스", 25), ("존", 40)]
people.sort(key=lambda p: p[1])
print(people)
# [('스미스', 25), ('커티스', 30), ('존', 40)]Assigning a lambda to a variable (square = lambda x: ...) is not recommended. Just write def square(x): .... The convention is to use lambda only where a name isn’t needed.
nonlocal and global — a quick tour of scope
#
To modify an outer variable from inside a function, an explicit declaration is required.
def outer():
count = 0
def inner():
nonlocal count # declare we modify outer's count
count += 1
inner()
inner()
print(count) # 2
outer()Without nonlocal, count += 1 creates a new local variable inside inner and outer’s count is unchanged. (Actually, you’ll get UnboundLocalError — assigning before reading.)
To modify a global from inside a function, use global. But it’s usually better to avoid globals entirely.
Functions are first-class objects #
A function can be assigned to a variable, passed as an argument, and returned.
def add(a: int, b: int) -> int:
return a + b
op = add # store function in a variable
print(op(2, 3)) # 5
def apply(fn, x: int, y: int) -> int:
return fn(x, y)
print(apply(add, 2, 3)) # 5This is the foundation for the older Decorator post and Closure post. In the modern series, we revisit decorators in the next series (Python Intermediate).
Callable type — typing functions that take functions
#
To annotate a function signature when you take a function as an argument, use Callable.
from collections.abc import Callable
def apply(fn: Callable[[int, int], int], x: int, y: int) -> int:
return fn(x, y)
apply(lambda a, b: a + b, 1, 2) # 3The form is Callable[[arg-types], return-type]. Writing arg types as a list feels strange at first.
docstrings — documentation strings #
A string on the first line of a function becomes its docstring.
def add(a: int, b: int) -> int:
"""두 정수를 더해 반환한다."""
return a + b
print(add.__doc__)
# 두 정수를 더해 반환한다.
help(add)Type hints fill in “what’s taken and returned”; docstrings are for “why / how to use”. A short, decisive one-liner is best.
Wrap-up #
Every argument pattern from this post:
- Default values — never put a mutable object (list/dict) as default; use
None+ create inside - Positional vs keyword calls — keyword calls reveal intent
*args— variable positional args, received as a tuple**kwargs— variable keyword args, received as a dict- Caller-side
*seq,**dict— spread arguments - After
*,is keyword-only — forces readability when there are several boolean flags - Before
/,is positional-only — preserves freedom to rename parameters lambdafor callback positions; don’t assign to a variablenonlocalto modify outer scope variables- Functions are first-class — type hint with
Callable[[...], ...] - docstring on the first line with
"""..."""
In the next post (#6 Errors and exception handling) we cover try/except/else/finally, user-defined exceptions, and the except* (exception group) added in Python 3.11.