Frontend Labs
⋯

5. Functions: arguments, scope, and closures

5.1 Parameters in full

def connect(host: str, port: int = 443, *, timeout: float = 5.0, verify: bool = True) -> str:
    return f"{host}:{port} timeout={timeout} verify={verify}"

print(connect("example.com"))
print(connect("example.com", 8443, timeout=1.5))
connect("example.com", 8443, 1.5)
# TypeError: connect() takes from 1 to 2 positional arguments but 3 were given

Everything after a bare * is keyword-only. Use keyword-only arguments for flags and options; a call like connect(h, 443, 1.5, False) is unreadable, and connect(h, timeout=1.5, verify=False) explains itself. Parameters before a / are positional-only, which lets you rename them later without breaking callers:

def clamp(x: float, /, lo: float = 0.0, hi: float = 1.0) -> float:
    return max(lo, min(hi, x))

Variable arguments:

def log(msg: str, *args: object, **fields: object) -> None:
    print(msg % args if args else msg, fields)

log("user %s logged in from %s", "ada", "10.0.0.7", request_id="r-1", ms=12)
# user ada logged in from 10.0.0.7 {'request_id': 'r-1', 'ms': 12}

opts = {"timeout": 2.0, "verify": False}
print(connect("example.com", **opts))   # unpack a dict into keyword arguments
hosts = ["a", "b"]
print(*hosts, sep=", ")                 # unpack a list into positional arguments

5.2 Functions are objects

def double(x: int) -> int:
    return x * 2

ops = {"double": double, "square": lambda x: x * x}
print(ops["square"](7))                              # 49
print(sorted(["b10", "a2", "c1"], key=lambda s: int(s[1:])))  # ['c1', 'a2', 'b10']
print(list(map(double, [1, 2, 3])))                  # [2, 4, 6]

lambda is a one-expression anonymous function. Use it for short key= functions; give anything longer a name.

5.3 Scope: LEGB

A name is looked up in four scopes, in order: Local, Enclosing function, Global (module), Built-in.

x = "global"

def outer():
    x = "enclosing"
    def inner():
        print(x)          # finds the enclosing x
    inner()

outer()                   # enclosing

Assignment anywhere in a function makes the name local to the whole function, which produces a confusing error:

count = 0
def bump():
    count += 1            # assignment makes `count` local; reading it first fails
bump()
# UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

global count or nonlocal count (for an enclosing function) declare otherwise. Needing them is usually a sign the state belongs in an object or a return value.

5.4 Closures

An inner function remembers the variables of the scope where it was defined:

def make_counter():
    n = 0
    def counter() -> int:
        nonlocal n
        n += 1
        return n
    return counter

c = make_counter()
print(c(), c(), c())    # 1 2 3

The late-binding trap: closures capture variables, not values.

# WRONG: all three lambdas see the final value of i
fns = [lambda: i for i in range(3)]
print([f() for f in fns])         # [2, 2, 2]

# Right: bind the current value as a default argument
fns = [lambda i=i: i for i in range(3)]
print([f() for f in fns])         # [0, 1, 2]

Closures are the mechanism behind decorators (Ch 14) and callbacks.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36