Skip to content
Hello Python
5/8

Functions

Topic 5 of 8, with 4 concept checks. Arguments, *args/**kwargs, and closures

Design the call contract before the function body

Call contracts

Start with inputs, defaults, return values, and ownership of mutable data so positional arguments, keyword arguments, closures, and variadic parameters remain predictable.

Core lesson 01

Positional: matched by position. Keyword: matched by name. Default: used only when the caller doesn't supply a value.

A function signature can mix all three. Positional arguments are bound to parameters strictly by their order in the call. Keyword arguments are bound by explicitly naming the parameter, so order doesn't matter for them. Default values (`def f(x=5)`) let a parameter be optional — Python only evaluates and binds the default if the caller doesn't provide that argument.

Python example
def greet(name, greeting="Hello", punctuation="!"):
    return f"{greeting}, {name}{punctuation}"

print(greet("Ada"))                          # Hello, Ada!
print(greet("Ada", "Hi"))                    # Hi, Ada!
print(greet("Ada", punctuation="?"))         # Hello, Ada?

What to remember

What's the difference between positional, keyword, and default arguments?

Common footguns

  • Positional arguments must come before keyword arguments in a call — `greet(greeting="Hi", "Ada")` is a SyntaxError.

Core lesson 02

The default list is built ONCE at def time and shared across every call that doesn't pass its own x — mutating it leaks state between calls.

Python evaluates default argument values exactly once, when the `def` statement runs, not each time the function is called. For immutable defaults (like `0` or `None`) this is invisible. For mutable defaults, every call that relies on the default is actually sharing the *same* object, so mutations accumulate silently across unrelated calls.

Python example
def append_bad(x, lst=[]):
    lst.append(x)
    return lst

print(append_bad(1))   # [1]
print(append_bad(2))   # [1, 2]  <- surprise, not [2]

def append_good(x, lst=None):
    if lst is None:
        lst = []
    lst.append(x)
    return lst

print(append_good(1))  # [1]
print(append_good(2))  # [2]  <- correct

What to remember

Why is def f(x=[]): considered dangerous?

Common footguns

  • This bites hardest in code that looks correct in isolated testing — the bug only shows up once a function is called more than once without passing that argument.
def f(x=[]):         call 1: f() -> x=[] -> append 1 -> [1]
    x.append(1)     call 2: f() -> SAME [] from before -> [1,1]
    return x         call 3: f() -> [1,1,1]   <- surprise!
                      (the [] literal is built ONCE, at def time)

Core lesson 03

*args packs extra positional args into a tuple. **kwargs packs extra keyword args into a dict — together they enable arbitrary/variadic signatures.

Prefixing a parameter with `*` tells Python 'collect any remaining positional arguments here as a tuple'. `**` does the same for keyword arguments, collecting them into a dict. This is essential for writing wrappers, decorators, or APIs that need to forward arbitrary arguments to another function without knowing its exact signature ahead of time.

Python example
def describe(*args, **kwargs):
    print("positional:", args)
    print("keyword:", kwargs)

describe(1, 2, a=3, b=4)
# positional: (1, 2)
# keyword: {'a': 3, 'b': 4}

def forward(*args, **kwargs):
    return describe(*args, **kwargs)   # unpack and forward

What to remember

What are *args and **kwargs used for?

Common footguns

  • Mixing up packing (`*args` in a def) with unpacking (`*mylist` in a call) — same symbol, opposite direction.
def f(*args, **kwargs): ...
f(1, 2, a=3, b=4)
args   -> (1, 2)
kwargs -> {'a': 3, 'b': 4}

Core lesson 04

A nested function that captures and retains variables from its enclosing scope, even after the outer function has finished running.

When a function is defined inside another function and references a variable from the outer function, Python keeps that variable alive as part of the inner function's closure — bundled with the function object itself. This lets you generate customized functions from a factory function, each with its own private captured state.

Python example
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter_a = make_counter()
print(counter_a())   # 1
print(counter_a())   # 2
counter_b = make_counter()
print(counter_b())   # 1  -- independent state

What to remember

What is a closure in Python?

Common footguns

  • Forgetting `nonlocal` when reassigning (not just reading) a captured variable — without it, Python treats the name as a new local variable and raises UnboundLocalError.
  • Creating closures in a loop that all capture the *same* loop variable by reference — classic late-binding bug.
make_counter()
+---------------------------+
| count = 0                 |
| def increment():          | <- returned function
|   nonlocal count          |    "remembers" count
|   count += 1               |    even after make_counter()
|   return count             |    has finished running
+---------------------------+

Python lab

Browser Python lab

Runtime · idle

Python loads on your first run. Your code stays in this browser.

Best practices

  • Never use a mutable object as a default argument — use None and initialize inside the function body.
  • Keep functions small and focused on a single responsibility.
  • Add type hints to signatures: def add(a: int, b: int) -> int:
  • Write a docstring for any non-trivial function describing params, return value, and behavior.

Apply the concept in Interview practice

Two SumeasyLeetCode #1 · O(n) time

Single pass with a value→index dict, checking for the complement before inserting the current number.

Open problem
Counting ValleyseasyHackerRank · O(n) time

Track altitude with a running counter; increment a valley count each time a step brings altitude from -1 back up to 0.

Open problem
Fibonacci NumbereasyLeetCode #509 · O(n) time, O(n) space memoized

Classic memoization demo: cache results in a dict (or use functools.lru_cache) keyed by n so each value is computed only once.

Open problem
Pow(x, n)mediumLeetCode #50 · O(log n) time

Use fast exponentiation: recursively compute pow(x, n//2) and square it, handling odd n and negative n separately.

Open problem
First Bad VersioneasyLeetCode #278 · O(log n) time

Binary search over version numbers, calling the given isBadVersion(mid) function to decide which half to search next.

Open problem

Concept checks

Q01

What's the difference between positional, keyword, and default arguments?

Q02

Why is def f(x=[]): considered dangerous?

Q03

What are *args and **kwargs used for?

Q04

What is a closure in Python?