Skip to content

Functions and Code Reuse#

Functions are the unit of decomposition in interview code. Clean signatures, correct parameter passing, predictable scope, and knowing when to extract a helper separate readable solutions from brittle ones.

How to use this page

Focus on parameters, scope, mutable defaults, and recursion. Advanced topics: Closures, Decorators.

At a glance
Track Python Basics
Sections 10 major topics
Outline Use the right-hand TOC to jump

Topics: Defining and calling functions · Parameters and arguments — complete model · Return values — patterns and conventions · Scope: LEGB rule in depth · Mutable default argument — must know cold · Functions as first-class objects · Docstrings and documentation · Organizing interview solutions · … (+2 more)

  1. Defining and calling functions
  2. Parameters and arguments — complete model
  3. Return values — patterns and conventions
  4. Scope: LEGB rule in depth
  5. Mutable default argument — must know cold
  6. Functions as first-class objects
  7. Docstrings and documentation
  8. Organizing interview solutions
  9. Recursion — theory and practice
  10. Partial application and functools

Defining and calling functions#

def greet(name: str, greeting: str = "Hello") -> str:
    """Return a greeting string."""
    return f"{greeting}, {name}!"

greet("Alice")                      # positional
greet("Bob", greeting="Hi")         # positional + keyword
greet(greeting="Hey", name="Carol")  # all keyword — order free

What happens at def time:

  1. Function object is created.
  2. Default argument values are evaluated once and stored on the function object.
  3. Name greet is bound to that function object.
def show_defaults(a, b=[]):
    print(id(b))

show_defaults.__defaults__  # inspect stored defaults

Parameters and arguments — complete model#

Python binds arguments in this order:

  1. Positional parameters
  2. Keyword parameters matching remaining names
  3. *args collects extra positional
  4. Keyword-only parameters (after * or *args)
  5. **kwargs collects extra keyword
def full_example(a, b, c=0, /, d, e=0, *args, f, g=0, **kwargs):
    """
    a, b     — positional-only (before /)
    c        — positional or keyword
    d        — positional or keyword (after /)
    *args    — extra positional
    f, g     — keyword-only (after *args)
    **kwargs — extra keyword
    """
    pass
Kind Syntax Call example
Positional def f(a, b) f(1, 2)
Default def f(a, b=0) f(1)
Positional-only def f(a, /, b) f(1, 2) not f(a=1, b=2) for a
Keyword-only def f(*, kw) f(kw=1) required
*args def f(*args) extra positional → tuple
**kwargs def f(**kwargs) extra keyword → dict

Unpacking at call site#

def connect(host, port, timeout=30):
    ...

args = ("localhost", 8080)
connect(*args)
connect(*args, timeout=60)

config = {"host": "localhost", "port": 8080, "timeout": 5}
connect(**config)

# Mixed
connect("localhost", **{"port": 8080})

Argument passing semantics — critical for interviews#

Python is call by object reference (often called "pass by assignment"):

Argument type Reassigning parameter Mutating parameter
Immutable (int, str, tuple) Caller unaffected N/A — can't mutate
Mutable (list, dict, set) Caller unaffected Caller sees change
def rebind(lst):
    lst = [99]        # local rebinding only

def mutate(lst):
    lst.append(99)    # mutates caller's object

nums = [1, 2, 3]
rebind(nums)   # nums still [1, 2, 3]
mutate(nums)   # nums is [1, 2, 3, 99]

Three strategies when you must not mutate caller data:

def safe_process(nums: list[int]) -> list[int]:
    # 1. Copy inside function
    nums = nums.copy()
    nums.sort()
    return nums

def safe_process2(nums: list[int]) -> list[int]:
    # 2. Build new list
    return sorted(nums)

def safe_process3(nums: list[int]) -> None:
    # 3. Document mutation intent clearly
    nums.sort()   # in-place by design

Deep dive: Parameter Unpacking.


Return values — patterns and conventions#

def min_max(nums: list[int]) -> tuple[int, int]:
    return min(nums), max(nums)

lo, hi = min_max([3, 1, 4])   # tuple unpacking
Pattern When to use Example
Single value One result return count
Tuple Multiple related values return (i, j)
List Variable-length collection return result
Early return Invalid input / found answer return -1
None Void / not found sentinel return None
Raise exception Invalid caller contract raise ValueError

Return nothing vs return None#

def log(msg):
    print(msg)
    # implicit return None

def find(nums, target):
    for i, x in enumerate(nums):
        if x == target:
            return i
    return None   # explicit "not found"

Functions without return execute return None implicitly.

Multiple return paths — keep consistent types#

# Good — always returns list
def two_sum(nums, target):
    ...
    return [i, j]
    ...
    return []

# Risky — sometimes list, sometimes None
def two_sum_bad(nums, target):
    ...
    return [i, j]
    ...
    return None   # caller must handle both

Scope: LEGB rule in depth#

Python resolves names in this order:

  1. Local — inside current function
  2. Enclosing — outer nested functions
  3. Global — module level
  4. Built-in — len, print, Exception, …
x = "global"

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

outer()
print(x)              # global

The UnboundLocalError trap#

Any assignment to a name in a function makes that name local for the entire function:

count = 0

def broken():
    print(count)   # UnboundLocalError — count is local due to +=
    count += 1

def fixed_global():
    global count
    count += 1

def fixed_nonlocal():
    count = 0
    def inner():
        nonlocal count
        count += 1
    inner()
Declaration Scope affected
global x Module-level name
nonlocal x Enclosing function's name (not global)

Interview preference: pass state as parameters and return updates — avoid global/nonlocal unless implementing closures intentionally.

See Variable Scope and nonlocal Keywords.


Mutable default argument — must know cold#

Interview trap

1
2
3
4
5
6
7
8
def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  ← shared list!

print(add_item.__defaults__)  # ([],) — same list object id

Fix — sentinel pattern:

def add_item(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

Alternative — use immutable sentinel:

_MISSING = object()
def add_item(item, bucket=_MISSING):
    if bucket is _MISSING:
        bucket = []
    ...

Use None when None isn't a valid argument value.

Same trap applies to mutable class attributes — see Introdicion to OOPs.


Functions as first-class objects#

Functions are objects with attributes:

def greet(name):
    return f"Hello, {name}"

greet("Alice")
greet.__name__     # 'greet'
greet.__doc__
callable(greet)    # True

say_hi = greet     # alias
operations = {"greet": greet, "len": len}
operations["greet"]("Bob")

Higher-order functions#

def apply(fn, value):
    return fn(value)

apply(abs, -5)
apply(lambda x: x * 2, 10)

# sorted key
sorted(words, key=str.lower)
sorted(points, key=lambda p: p[0])

Details: Higher Order Functions, Lambda Functions.


Docstrings and documentation#

def merge_intervals(intervals: list[list[int]]) -> list[list[int]]:
    """Merge overlapping intervals.

    Args:
        intervals: List of [start, end] pairs, possibly unsorted.

    Returns:
        Merged non-overlapping intervals sorted by start.

    Raises:
        ValueError: If any interval has start > end.
    """
    ...
Style Use
One-liner Simple helpers
Google/NumPy style Multi-arg functions
Type hints Contract in signature

help(fn) and fn.__doc__ read the docstring. In timed interviews, one-line docstrings on non-trivial functions suffice.


Organizing interview solutions#

LeetCode-style class template#

class Solution:
    def twoSum(self, nums: list[int], target: int) -> list[int]:
        seen: dict[int, int] = {}
        for i, n in enumerate(nums):
            need = target - n
            if need in seen:
                return [seen[need], i]
            seen[n] = i
        return []

if __name__ == "__main__":
    s = Solution()
    assert s.twoSum([2, 7, 11, 15], 9) == [0, 1]

Functional style (no class)#

def two_sum(nums: list[int], target: int) -> list[int]:
    ...

if __name__ == "__main__":
    assert two_sum([2, 7, 11, 15], 9) == [0, 1]

Use whichever the platform provides. Extract helpers when:

  • Logic repeats 2+ times
  • A block exceeds ~15 lines
  • A nested loop needs a descriptive name (is_valid, dfs, backtrack)

Recursion — theory and practice#

Structure: base case + recursive case that progresses toward base.

def factorial(n: int) -> int:
    if n <= 1:
        return 1
    return n * factorial(n - 1)
Concern Detail
Base case Missing → RecursionError: maximum recursion depth exceeded
Stack depth CPython default ~1000 frames
Tail recursion Not optimized in CPython — don't rely on it
Memoization @cache, @lru_cache, or explicit dict

Fibonacci — three approaches#

# 1. Naive recursion O(2^n)
def fib_naive(n):
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

# 2. Memoized recursion O(n)
from functools import cache

@cache
def fib_memo(n):
    if n < 2:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)

# 3. Iterative O(n) space O(1)
def fib_iter(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

Prefer iterative or memoized in interviews — state complexity aloud.

Tree/graph DFS template#

def dfs(node: TreeNode | None) -> int:
    if node is None:
        return 0
    left = dfs(node.left)
    right = dfs(node.right)
    return 1 + max(left, right)   # example: max depth

Backtracking template#

def backtrack(index: int, path: list[int], ...):
    if base_condition:
        result.append(path[:])   # copy!
        return
    for choice in choices:
        path.append(choice)
        backtrack(index + 1, path, ...)
        path.pop()              # undo

Full treatment: Recursion, Backtracking.

Increasing recursion limit (avoid in interviews)#

import sys
sys.setrecursionlimit(10000)   # deep recursion — prefer iterative rewrite

Partial application and functools#

from functools import partial

def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
square(5)   # 25

See Partial Functions.


Interview traps (quick reference)#

Trap What goes wrong Safe approach
Mutable default Shared list/dict across calls None sentinel
Mutating caller's list Side effects Copy or document intent
global mutation Hidden coupling Pass/return state
Missing return Implicit None Explicit return on all paths
Shadowing builtins sum = 0 breaks sum() Never reuse builtin names
UnboundLocalError Assign makes name local Declare nonlocal/global or rename
Recursion depth Stack overflow Memoize or iterate
Forgetting path[:] in backtrack Shared mutable result Append copy to results

Mental model checklist#

  1. Are default arguments evaluated once or per call?
  2. What happens if you mutate a list passed to a function?
  3. What is LEGB order?
  4. Why does count += 1 inside a function fail without global?
  5. When should you use recursion vs iteration?
  6. Why copy path before appending to results in backtracking?

What's next#

Topic Page
Exceptions in functions Basic Error Handling
Closures and decorators Closures
Type hints in depth Function Annotations and Type Hints
Classes for state Introdicion to OOPs