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

JavaPython
Function<T,R>, Predicate<T>, …Any callable — no interface needed
Strategy / Command patternPass a function
Method overloadingDefault and keyword arguments
Varargs String... args*args
Builder pattern for optional paramsKeyword arguments
Lambda x -> x * 2lambda x: x * 2 (single expression only)
Effectively-final captured variablesClosures (and nonlocal to rebind)
Spring AOP / annotations + proxiesDecorators
@Cacheablefunctools.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_score
print(scorer(2_500)) # -> 0.25
print(type(risk_score).__name__) # -> function
print(risk_score.__name__) # -> risk_score
print(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})) # -> True

Arguments: 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.5
print(score(500, "USD")) # -> 0.5
print(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 positionally
except TypeError as e:
print(e) # -> score() takes from 1 to 2 positional arguments but 3 were given

Tip — 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.0

You’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.0
print(clamp(-3, low=-1)) # -> -1

Deep 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 functions
print(min(txns, key=lambda t: t["amount"])["id"]) # -> T2
# map/filter exist, but comprehensions are usually clearer in Python
doubled = list(map(lambda t: t["amount"] * 2, txns))
also_doubled = [t["amount"] * 2 for t in txns] # preferred
print(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"] > 100

Scope: 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.0

A 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()) # -> 3

Gotcha — 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. Use nonlocal (enclosing) or global (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
@timed
def 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 functools
import 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
@timed
def 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.wraps and every decorated function reports its name as wrapper. 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 functools
import 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 bounded
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(80)) # -> 23416728348467685
print(fib.cache_info().hits > 0) # -> True

You’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.cache needs 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, reduce
import 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])) # -> 24
print(sorted([("b", 2), ("a", 1)], key=operator.itemgetter(1))) # -> [('a', 1), ('b', 2)]

Tip — reduce lives in functools rather than the built-ins because Python’s creator found it less readable than a loop. For sums, products, mins and maxes, use sum, math.prod, min, max.


Tips, Tricks & Gotchas

Tip — Implicit None: a function without return (or with a bare return) returns None. Forgetting a return in one branch is a common source of 'NoneType' object is not subscriptable errors.

Tip — Docstrings are runtime data: the first string literal in a function becomes __doc__, which help(), 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 as model(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 True

Key Takeaways

ConceptRemember
First-class functionsAssign, store, pass and return them — no interface required
ArgumentsDefaults, keyword-only (*), positional-only (/), *args, **kwargs
Mutable defaultsEvaluated once at def time — use None as a sentinel
LambdasOne expression; use def for anything bigger
ScopeLEGB; assignment makes a name local; nonlocal / global to rebind
ClosuresCapture variables, not values — beware late binding in loops
Decoratorsf = deco(f); always use functools.wraps; add a layer for arguments
Standard toolsfunctools.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.”