"Functions Are Values" — Arguments, Closures and Decorators
No more single-method interfaces. Positional, keyword, *args and **kwargs; the mutable-default trap; lambdas; closures and LEGB scoping; and decorators — Python's ten-line answer to Spring AOP — for timing, retries and caching.
Story Opening
Sentinel needed a rule engine: a set of hand-written fraud rules that run before the ML model, catching the obvious cases cheaply. Arjun did what fifteen years of Java had trained him to do. He wrote a FraudRule abstract base class with an evaluate() method, three subclasses, and a RuleRegistry that held instances of each.
Priya scrolled through it, then typed a replacement in the PR comment:
def high_amount(txn): return txn["amount"] > 10_000
def foreign_card(txn): return txn["card_country"] != txn["merchant_country"]
RULES = [high_amount, foreign_card]
txn = {"amount": 15_000, "card_country": "US", "merchant_country": "IN"}print([rule.__name__ for rule in RULES if rule(txn)]) # -> ['high_amount', 'foreign_card']“You don’t need a class to hold a function,” she said. “The function is the object.”
Java gave us lambdas in Java 8, but they’re always secretly an instance of some functional interface. In Python, functions are first-class objects with attributes, identity, and a type. Once that sinks in, a whole category of design patterns — Strategy, Command, Template Method, most of AOP — collapses into a few lines.
Java → Python: The Quick Map
| Java | Python |
|---|---|
Function<T,R>, Predicate<T>, … | Any callable — no interface needed |
| Strategy / Command pattern | Pass a function |
| Method overloading | Default and keyword arguments |
Varargs String... args | *args |
| Builder pattern for optional params | Keyword arguments |
Lambda x -> x * 2 | lambda x: x * 2 (single expression only) |
| Effectively-final captured variables | Closures (and nonlocal to rebind) |
| Spring AOP / annotations + proxies | Decorators |
@Cacheable | functools.cache / lru_cache |
Functions Are Objects
def risk_score(amount: float) -> float: """Return a naive risk score in [0, 1].""" return min(amount / 10_000, 1.0)
# A function is an object: it has a type, attributes, and can be bound to other names.scorer = risk_scoreprint(scorer(2_500)) # -> 0.25print(type(risk_score).__name__) # -> functionprint(risk_score.__name__) # -> risk_scoreprint(risk_score.__doc__) # -> Return a naive risk score in [0, 1].
# Store functions in data structures — a dispatch table replaces a switch/factory.converters = { "INR": lambda amt: amt, "USD": lambda amt: amt * 83.0, "EUR": lambda amt: amt * 90.0,}print(converters["USD"](10)) # -> 830.0
# Return functions from functions (a factory).def threshold_rule(limit: float): def rule(txn: dict) -> bool: return txn["amount"] > limit return rule
over_5k = threshold_rule(5_000)print(over_5k({"amount": 7_000})) # -> TrueArguments: Everything Java Doesn’t Have
Python has no method overloading. Instead it has a rich argument system that removes the need for overloads, builders and telescoping constructors.
def score(amount, currency="INR", *, explain=False): # ^positional ^default ^ everything after '*' is KEYWORD-ONLY result = amount / 1000 return (result, f"{amount} {currency}") if explain else result
print(score(500)) # -> 0.5print(score(500, "USD")) # -> 0.5print(score(amount=500, currency="EUR")) # -> 0.5 (keywords in any order)print(score(500, explain=True)) # -> (0.5, '500 INR')
try: score(500, "USD", True) # explain cannot be passed positionallyexcept TypeError as e: print(e) # -> score() takes from 1 to 2 positional arguments but 3 were givenTip — Make boolean flags and rarely used options keyword-only with
*.score(500, explain=True)is self-documenting;score(500, "INR", True)is a riddle.
*args and **kwargs
def log_event(event_type, *args, **kwargs): # args -> tuple of extra positional arguments # kwargs -> dict of extra keyword arguments print(event_type, args, kwargs)
log_event("DECLINE", "T1", 51, channel="card", retry=False)# -> DECLINE ('T1', 51) {'channel': 'card', 'retry': False}
# The same symbols UNPACK at the call site:def transfer(source, target, amount): return f"{source}->{target}: {amount}"
positional = ["ACC1", "ACC2"]named = {"amount": 99.0}print(transfer(*positional, **named)) # -> ACC1->ACC2: 99.0You’ll see **kwargs constantly in ML libraries: a wrapper accepts arbitrary options and forwards them to the underlying model (model = Wrapper(**config)). It’s flexible but kills IDE autocompletion — prefer explicit parameters in your own APIs.
Positional-only parameters
A / marks everything before it as positional-only. You’ll see it in library signatures like len(obj, /); it lets authors rename parameters without breaking callers.
def clamp(value, /, low=0.0, high=1.0): return max(low, min(value, high))
print(clamp(1.7)) # -> 1.0print(clamp(-3, low=-1)) # -> -1Deep Dive: The Mutable Default Argument Trap
This is the most famous Python gotcha, and it catches experienced Java developers precisely because they assume Java-like semantics.
Default values are evaluated once — when the def statement executes — not on every call.
def add_tag(txn_id, tags=[]): # the [] is created ONCE, at definition time tags.append(txn_id) return tags
print(add_tag("T1")) # -> ['T1']print(add_tag("T2")) # -> ['T1', 'T2'] (the SAME list, shared across calls!)Remember Part 1: def is a statement that creates a function object. The default list is stored on that object (add_tag.__defaults__) and reused forever. The idiom is to use None as a sentinel:
def add_tag(txn_id, tags=None): if tags is None: tags = [] # a fresh list on every call tags.append(txn_id) return tags
print(add_tag("T1")) # -> ['T1']print(add_tag("T2")) # -> ['T2']The same rule applies to any default that’s computed: def log(ts=datetime.now()) freezes the timestamp at import time. Immutable defaults (0, "INR", None, tuples) are perfectly safe.
Lambdas
A lambda is an anonymous function restricted to a single expression — no statements, no assignments, no multi-line bodies. That’s deliberate: if it needs more, give it a name with def.
txns = [{"id": "T1", "amount": 300}, {"id": "T2", "amount": 50}]
# Good use: short key functionsprint(min(txns, key=lambda t: t["amount"])["id"]) # -> T2
# map/filter exist, but comprehensions are usually clearer in Pythondoubled = list(map(lambda t: t["amount"] * 2, txns))also_doubled = [t["amount"] * 2 for t in txns] # preferredprint(doubled == also_doubled) # -> True
# Anti-pattern: binding a lambda to a name. Just use def (better tracebacks, docstrings).# is_big = lambda t: t["amount"] > 100 # flagged by linters (E731)def is_big(t): return t["amount"] > 100Scope: LEGB and Closures
Python resolves names in four scopes, in order: Local → Enclosing function → Global (module) → Built-in. Only functions, classes and modules create scopes — if and for blocks don’t.
rate = 83.0 # Global (module) scope
def make_converter(fee): # 'fee' lives in the Enclosing scope def convert(usd): # 'usd' is Local return usd * rate + fee # 'rate' found in Global; 'round' would be Built-in return convert
to_inr = make_converter(fee=15)print(to_inr(10)) # -> 845.0A closure is a function that remembers variables from its enclosing scope after that scope has finished — like a Java lambda capturing an effectively-final local. The difference: Python closures can rebind captured variables with nonlocal.
def make_counter(): count = 0 def increment(): nonlocal count # without this, 'count += 1' creates a new LOCAL -> error count += 1 return count return increment
counter = make_counter()counter(); counter()print(counter()) # -> 3Gotcha — assignment makes a name local. If a function assigns to a name anywhere in its body, that name is local for the whole body. Reading it before the assignment raises
UnboundLocalError, even if a global of the same name exists. Usenonlocal(enclosing) orglobal(module) when you genuinely intend to rebind — and prefer returning values instead.
Late binding in loops
Closures capture variables, not values. All three lambdas below see the final value of limit:
rules = [lambda amt: amt > limit for limit in (100, 500, 1000)]print([r(600) for r in rules]) # -> [False, False, False] (all use limit=1000)
# Fix: bind the current value as a default argument (evaluated at definition time).rules = [lambda amt, limit=limit: amt > limit for limit in (100, 500, 1000)]print([r(600) for r in rules]) # -> [True, True, False]Java avoids this by forcing captured variables to be effectively final. Python lets you shoot yourself in the foot, then hands you the default-argument trick as a bandage.
Deep Dive: Decorators — AOP Without the Proxy
A decorator is a function that takes a function and returns a (usually wrapped) function. The @ syntax is pure sugar:
def timed(func): # a do-nothing decorator, just to show the mechanics return func
@timeddef train(): ...
# is exactly the same as:def train(): ...train = timed(train)No proxies, no bytecode weaving, no container. And unlike Spring AOP, there’s no self-invocation problem: the name train now is the wrapper, so every call goes through it.
Building one step by step
import functoolsimport time
def timed(func): """Print how long each call to 'func' takes."""
@functools.wraps(func) # copies __name__, __doc__ etc. onto the wrapper def wrapper(*args, **kwargs): # accept ANY signature and forward it start = time.perf_counter() try: return func(*args, **kwargs) finally: # runs even if func raises elapsed_ms = (time.perf_counter() - start) * 1000 print(f"{func.__name__} took {elapsed_ms:.1f} ms")
return wrapper
@timeddef build_features(n): """Pretend to build n features.""" return sum(i * i for i in range(n))
result = build_features(200_000) # prints e.g. "build_features took 9.8 ms"print(build_features.__name__) # -> build_features (thanks to functools.wraps)Gotcha — Forget
@functools.wrapsand every decorated function reports its name aswrapper. Logs, debuggers and frameworks that introspect names (pytest, Click, FastAPI) get confused.
Decorators with arguments: a retry policy
@retry(times=3) needs one more layer: retry(times=3) is called first and must return the actual decorator. Three nested functions: configuration → decorator → wrapper.
import functoolsimport time
def retry(times=3, delay_s=0.0, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions as e: if attempt == times: raise # re-raise the last failure unchanged print(f"attempt {attempt} failed: {e}; retrying") time.sleep(delay_s) return wrapper return decorator
calls = {"n": 0}
@retry(times=3, exceptions=(ConnectionError,))def fetch_feature_vector(customer_id): calls["n"] += 1 if calls["n"] < 3: # fail twice, then succeed raise ConnectionError("feature store timeout") return [0.1, 0.7, 0.2]
print(fetch_feature_vector("C42"))# attempt 1 failed: feature store timeout; retrying# attempt 2 failed: feature store timeout; retrying# [0.1, 0.7, 0.2]Stacking order
Decorators apply bottom-up (closest to the function first), and run top-down at call time — like nested interceptors.
def tag(label): def decorator(func): def wrapper(): return f"<{label}>{func()}</{label}>" return wrapper return decorator
@tag("outer")@tag("inner")def payload(): return "data"
print(payload()) # -> <outer><inner>data</inner></outer>Built-in decorators you’ll use constantly
import functools
@functools.cache # unbounded memoisation (3.9+); @lru_cache(maxsize=N) for boundeddef fib(n: int) -> int: return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(80)) # -> 23416728348467685print(fib.cache_info().hits > 0) # -> TrueYou’ve already met others or will soon: @property, @staticmethod, @classmethod, @dataclass (Part 4), @pytest.fixture (Part 6), and @torch.no_grad() (Part 10).
Gotcha — caching and mutability:
functools.cacheneeds hashable arguments, so you can’t pass a list or dict. And whatever it returns is shared between callers — if the result is mutable, a caller that mutates it corrupts the cache for everyone.
functools.partial and the operator Module
from functools import partial, reduceimport operator
def convert(amount, rate, fee=0.0): return amount * rate + fee
# partial pre-fills arguments — lighter than writing a wrapper function.usd_to_inr = partial(convert, rate=83.0, fee=10.0)print(usd_to_inr(5)) # -> 425.0
# operator provides functions for every operator — useful as keys and reducers.print(reduce(operator.mul, [1, 2, 3, 4])) # -> 24print(sorted([("b", 2), ("a", 1)], key=operator.itemgetter(1))) # -> [('a', 1), ('b', 2)]Tip —
reducelives infunctoolsrather than the built-ins because Python’s creator found it less readable than a loop. For sums, products, mins and maxes, usesum,math.prod,min,max.
Tips, Tricks & Gotchas
Tip — Implicit
None: a function withoutreturn(or with a barereturn) returnsNone. Forgetting areturnin one branch is a common source of'NoneType' object is not subscriptableerrors.
Tip — Docstrings are runtime data: the first string literal in a function becomes
__doc__, whichhelp(), IDEs and documentation generators read. Write them — triple-quoted, imperative mood.
Gotcha — calling vs referencing:
RULES = [high_amount()]calls the function (and probably fails).RULES = [high_amount]stores it. The parentheses are the call operator.
Tip — Callable objects: any object whose class defines
__call__can be used like a function. ML libraries use this everywhere: a PyTorch model is called asmodel(x), which invokes its__call__(Part 10).
class AmountThreshold: def __init__(self, limit): self.limit = limit self.hits = 0 # state — something a plain function can't hold neatly
def __call__(self, txn): hit = txn["amount"] > self.limit self.hits += hit # True counts as 1 return hit
rule = AmountThreshold(1_000)for amt in (500, 2_000, 3_000): rule({"amount": amt})print(rule.hits, callable(rule)) # -> 2 TrueKey Takeaways
| Concept | Remember |
|---|---|
| First-class functions | Assign, store, pass and return them — no interface required |
| Arguments | Defaults, keyword-only (*), positional-only (/), *args, **kwargs |
| Mutable defaults | Evaluated once at def time — use None as a sentinel |
| Lambdas | One expression; use def for anything bigger |
| Scope | LEGB; assignment makes a name local; nonlocal / global to rebind |
| Closures | Capture variables, not values — beware late binding in loops |
| Decorators | f = deco(f); always use functools.wraps; add a layer for arguments |
| Standard tools | functools.cache, partial, operator.itemgetter |
Story Closing
Arjun deleted his FraudRule hierarchy. The rule engine became a list of functions and a @register_rule decorator that added each one to the list at import time — eight lines instead of four files.
Then he got cocky. For the transaction model, he wrote a Transaction class with a private-looking __amount, a getAmount() method, a setAmount() with validation, hand-written equals-style comparisons, and a toString(). It worked. Priya read it and laughed for a full ten seconds.
“You’ve written Java with a Python accent,” she said. “Let me show you @dataclass.”
In Part 4, Arjun learns how Python does objects — properties, dunder methods, dataclasses and protocols.
This is Part 3 of a 10-part series: “Python for Java Developers: From Streams to Tensors.”