All Posts

Functions in Python: Parameters, Return Values, and Scope

Why Python functions work nothing like Java methods — and how mastering them unlocks everything from decorators to async

Abstract AlgorithmsAbstract Algorithms
··23 min read

AI-assisted content.

TLDR: Python functions are first-class objects, not just reusable blocks. They support keyword arguments, safe defaults with None, variadic *args/**kwargs, closures, and LEGB scope resolution. These five ideas are not advanced features — they are the foundation of everything from decorators to FastAPI route definitions.


📖 The Argument Error That Broke Carlos's First Python Production Deploy

Carlos has three years of Java experience. He is comfortable. On day one with Python he writes a pipeline utility with six parameters and calls it like this:

process_data("sales.csv", True, 100, False, "USD", "2024-01")

Three weeks later a teammate mistakenly passes the currency symbol as the third argument instead of the fifth. Python accepts it silently. The column totals look reasonable. The bug ships. A financial report lands in the wrong currency. The post-mortem takes an afternoon.

In Java, the compiler and IDE would have surfaced a type mismatch. In Python, positional arguments carry no label — the caller has to know the order. But Python does not leave you helpless. It gives you something more expressive than static types: keyword arguments, enforced parameter ordering, and function signatures that carry their own documentation.

This post is about all of those things. By the end you will understand the six concepts every Python developer needs to know about functions: keyword vs positional calling, default argument values and the mutable default trap, *args and **kwargs, LEGB scope resolution, closures, and first-class functions. You will also see how Click and FastAPI build complete APIs directly from function signatures — an architecture that only works because Python functions are inspectable objects.


🔍 Defining Your First Python Function: Syntax, Return Values, and Calling Conventions

A Python function is defined with the def keyword, a name, parameters in parentheses, a colon, and an indented body:

def add(a, b):
    return a + b

result = add(3, 5)
print(result)  # 8

That is the entire syntax. No access modifier, no return-type annotation (optional but not required), no semicolons. The return statement hands a value back to the caller.

What happens when there is no return?

If execution reaches the end of the function body without a return, or if return is written with no value, Python returns None automatically. This catches every Java developer at least once:

def greet(name):
    print(f"Hello, {name}!")
    # No return statement — implicitly returns None

value = greet("Alice")  # Prints: Hello, Alice!
print(value)            # None

In Java, a void method simply does not produce a value. In Python, every function call is an expression. Assigning greet("Alice") to a variable always works — the variable just holds None if there was no explicit return.

Positional Arguments Versus Keyword Arguments

When you call a function, Python offers two ways to supply arguments. Positional arguments are matched by order:

def describe_user(username, age, city):
    print(f"{username}, age {age}, based in {city}")

describe_user("Alice", 30, "London")  # positional — order must match

Keyword arguments are supplied by name and can appear in any order:

describe_user(city="Berlin", username="Bob", age=25)  # keyword — order irrelevant

You can mix both styles. Positional arguments must come before keyword arguments in a call:

describe_user("Carol", city="Tokyo", age=28)  # OK: positional first, then keyword

Using keyword arguments transforms an opaque call like process_data("sales.csv", True, 100, False, "USD", "2024-01") into a self-documenting one: process_data("sales.csv", has_header=True, limit=100, dedupe=False, currency="USD", period="2024-01"). Every future reader knows exactly what each argument means without looking up the signature.


⚙️ Default Arguments, the Mutable Default Trap, and Variadic args / *kwargs

Giving Parameters Default Values

Parameters can have default values, making them optional at the call site:

def connect(host, port=5432, ssl=True):
    print(f"Connecting to {host}:{port}, SSL={ssl}")

connect("db.example.com")               # port=5432, ssl=True (defaults)
connect("db.example.com", port=3306)    # override port only
connect("db.example.com", 3306, False)  # override both positionally

Defaults are ideal for configuration options that rarely change. They reduce boilerplate at call sites while still exposing every knob to callers who need it.

The Mutable Default Argument Bug — and the Exact Reason It Happens

This is the single most common Python trap for developers coming from other languages. Read this function carefully:

def add_item(item, collection=[]):
    collection.append(item)
    return collection

print(add_item("alpha"))   # ['alpha']
print(add_item("beta"))    # ['alpha', 'beta']  ← expected ['beta']
print(add_item("gamma"))   # ['alpha', 'beta', 'gamma']  ← still wrong

The second call should return ['beta']. Why does the previous item appear?

Here is the precise reason. Default argument values are evaluated exactly once — at the moment the def statement is executed, not when the function is called. The list [] is created once and stored as an attribute on the function object itself (add_item.__defaults__). Every call that uses the default is sharing the same list object in memory. When you call .append() on it, you permanently mutate that shared object.

You can observe this directly in a Python REPL:

def add_item(item, collection=[]):
    collection.append(item)
    return collection

print(add_item.__defaults__)  # ([],)  — at definition time
add_item("alpha")
print(add_item.__defaults__)  # (['alpha'],)  — mutated!

The canonical fix is to use None as the default sentinel and create a fresh mutable object inside the function body on every call:

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

print(add_item("alpha"))   # ['alpha']
print(add_item("beta"))    # ['beta']  ← correct

This pattern applies to any mutable default: lists, dicts, sets, and custom class instances. Immutable defaults — None, strings, numbers, tuples — are always safe because you cannot mutate them in place.

Collecting an Arbitrary Number of Positional Arguments with *args

When a function needs to accept an unknown number of positional arguments, prefix one parameter with *. Python collects all extra positional values into a tuple named by that parameter (conventionally args):

def summarize(*values):
    total = sum(values)
    count = len(values)
    print(f"Sum={total}, Count={count}, Avg={total / count:.2f}")

summarize(10, 20, 30)       # Sum=60, Count=3, Avg=20.00
summarize(1, 2, 3, 4, 5)   # Sum=15, Count=5, Avg=3.00

Collecting an Arbitrary Number of Keyword Arguments with **kwargs

To accept an unknown number of keyword arguments, prefix a parameter with **. Python packs them into a dict:

def log_event(event_type, **metadata):
    print(f"[{event_type}]", metadata)

log_event("LOGIN", user_id=42, ip="10.0.0.1", device="mobile")
# [LOGIN] {'user_id': 42, 'ip': '10.0.0.1', 'device': 'mobile'}

You can combine regular parameters, *args, keyword-only parameters, and **kwargs in a single signature. The required order is: regular positional params → *args → keyword-only params → **kwargs:

def pipeline(source, *transforms, output="stdout", **options):
    print(f"Source: {source}")
    print(f"Transforms: {transforms}")
    print(f"Output: {output}")
    print(f"Options: {options}")

pipeline("db", "filter", "sort", "dedupe", output="file", delimiter=",")

🧠 How Python Actually Treats Functions Under the Hood

The Internals of Python Functions as First-Class Objects

In Python, a function is a first-class object. That sentence means exactly what it says: a function is a value of the same kind as an integer, a string, or a list. You can assign it to a variable, pass it as an argument to another function, return it from a function, and store it in a data structure.

def square(n):
    return n ** 2

# Assign to a variable
operation = square
print(operation(5))  # 25

# Store in a list alongside other callables
ops = [square, abs, lambda x: x + 1]
print([f(4) for f in ops])  # [16, 4, 5]

When Python executes a def statement, it allocates a function object in memory. That object carries several attributes you can inspect:

AttributeWhat it holds
__name__The function name as a string
__code__The compiled bytecode object
__defaults__Tuple of default argument values
__globals__Reference to the module-level global namespace
__closure__Tuple of cell objects for captured variables (closures)

A closure occurs when an inner function references a variable from an enclosing function scope. The inner function captures a reference to that variable, keeping it alive even after the outer function has returned and its stack frame is gone:

def make_multiplier(factor):
    def multiply(value):
        return value * factor   # 'factor' is captured from make_multiplier's scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(10))  # 20
print(triple(10))  # 30

# The captured variable lives inside __closure__
print(double.__closure__[0].cell_contents)  # 2

After make_multiplier(2) returns, the local variable factor would normally be garbage-collected. But multiply.__closure__ holds a cell reference to it, so Python keeps factor=2 alive as long as double exists. This mechanism powers decorators, factory functions, and the functools.partial utility throughout the Python ecosystem.

Performance Analysis: What Happens Every Time You Call a Function

Every Python function call involves measurable overhead. Python must:

  1. Allocate a new frame object on the call stack to hold local variables.
  2. Bind each argument to its parameter name.
  3. Execute the function body bytecode instruction by instruction.
  4. Pop the frame from the stack and let the garbage collector reclaim it.

For typical business logic this overhead is negligible — microseconds per call. But inside a hot loop processing millions of records, it accumulates. The most common avoidable cost is redefining a function object on every loop iteration:

# Slow — a new lambda object is created on every iteration
results = []
for i in range(1_000_000):
    transform = lambda x: x * i   # ← new function object each time
    results.append(transform(i))

# Fast — define once, reuse across the loop
def transform(x, factor):
    return x * factor

results = [transform(i, i) for i in range(1_000_000)]

Similarly, attribute lookup inside a tight loop carries a small but real cost. Caching a frequently-called method as a local variable before the loop eliminates repeated dictionary lookups:

# Slower — 'append' attribute is looked up on every iteration
data = []
for item in source:
    data.append(item)

# Faster in hot loops — cache the bound method once
append = data.append
for item in source:
    append(item)

The rule: measure before optimizing. Use timeit or cProfile to confirm a hot path actually exists before applying micro-optimizations. Premature optimization introduces complexity without measured benefit.


📊 How Python Resolves a Name: The LEGB Scope Lookup

When Python encounters a variable name in your code — reading a value, not assigning one — it does not search every namespace in the program. It follows a fixed four-step order known as LEGB:

  • L — Local: Variables assigned inside the currently executing function.
  • E — Enclosing: Variables in any enclosing function scopes (the make_multiplier scope when you are inside multiply, for example).
  • G — Global: Names defined at the top level of the current module.
  • B — Built-in: Python's built-in names such as len, print, range, None, and True.

The diagram below traces the exact path Python follows for every name lookup. Python starts at Local and works outward, stopping at the first scope where the name is found:

graph TD
    A[Name referenced in code] --> B{In Local scope?}
    B -->|Yes| C[Use local variable]
    B -->|No| D{In Enclosing scope?}
    D -->|Yes| E[Use enclosing variable]
    D -->|No| F{In Global scope?}
    F -->|Yes| G[Use global variable]
    F -->|No| H{In Built-in scope?}
    H -->|Yes| I[Use built-in name]
    H -->|No| J[NameError raised]

Read the diagram top-to-bottom: Python starts at the Local scope and asks "is this name defined here?" at each level. The moment it finds the name, it uses that binding and stops searching. If it exhausts all four levels with no match, it raises a NameError. This order also explains variable shadowing — a local variable with the same name as a global silently takes precedence because Local is checked first.

Here is a concrete example that exercises all four scopes in one program:

# Built-in scope: len lives here
SIZE = 10  # Global scope

def outer():
    multiplier = 3  # Enclosing scope for inner()

    def inner():
        count = 5       # Local scope
        print(count)        # L: found in local
        print(multiplier)   # E: found in enclosing outer()
        print(SIZE)         # G: found in global module scope
        print(len([]))      # B: found in built-in scope

    inner()

outer()
# Output:
# 5
# 3
# 10
# 0

To assign to a global variable from inside a function body, use the global keyword. To assign to an enclosing variable from inside a nested function, use nonlocal:

counter = 0

def increment():
    global counter      # allows assignment to the module-level counter
    counter += 1

def make_counter():
    count = 0
    def tick():
        nonlocal count  # allows assignment to make_counter's local 'count'
        count += 1
        return count
    return tick

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

Without nonlocal, writing count += 1 inside tick would create a new local variable called count in tick's scope — and because Python reads it before assigning, you would get an UnboundLocalError.


🌍 Real-World Patterns That Rely on Python's Function Flexibility

Factory Functions: Configurable Validators and Converters

A factory function returns a newly configured function as its output. This is a clean alternative to class-based configuration when the configuration state is small and the resulting callable is what you care about:

def make_validator(min_len, max_len):
    """Return a validator that checks string length against [min_len, max_len]."""
    def validate(value):
        if len(value) < min_len:
            return False, f"Too short (minimum {min_len} characters)"
        if len(value) > max_len:
            return False, f"Too long (maximum {max_len} characters)"
        return True, "OK"
    return validate

username_validator = make_validator(3, 20)
password_validator = make_validator(8, 64)

print(username_validator("ab"))          # (False, 'Too short (minimum 3 characters)')
print(password_validator("s3cr3t!23"))   # (True, 'OK')

Each call to make_validator produces a fresh closure. username_validator captures min_len=3, max_len=20; password_validator captures min_len=8, max_len=64. They are independent objects that share no state.

Higher-Order Functions: map, filter, sorted, and Custom Pipelines

A higher-order function takes a function as an argument, returns one, or both. Python's standard library is full of them:

numbers = [4, 1, 9, 2, 7]

# map: apply a function to every element, return an iterator
squared = list(map(lambda x: x ** 2, numbers))
print(squared)  # [16, 1, 81, 4, 49]

# filter: keep elements where the function returns True
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [4, 2]

# sorted with a key function: sort by a derived property
words = ["banana", "fig", "apple", "kiwi"]
print(sorted(words, key=len))   # ['fig', 'kiwi', 'apple', 'banana']
print(sorted(words, key=str.upper))  # alphabetical, case-insensitive

In production code, higher-order functions also appear as event callbacks passed to job queues, background task frameworks (Celery, APScheduler), and UI event loops. The pattern is always the same: one component accepts a callable and invokes it at the right moment, without knowing or caring what the callable does.


⚖️ Choosing Between *args, Explicit Parameters, and Dataclasses

No parameter style is universally correct. The trade-offs depend on the number of parameters, the likelihood the signature will change, and how clearly callers need to understand each argument's meaning.

StyleStrengthsWeaknessesBest for
Explicit named paramsSelf-documenting, IDE autocomplete, type hints workVerbose for 5+ paramsDomain functions with stable, well-understood args
Default valuesReduces call-site boilerplateMutable defaults are a trap; overuse hides required inputsOptional config options that rarely change
*argsAccepts any number of values; flexible aggregationCallers cannot tell what each value meansMath/aggregation functions: sum_all, max_of
**kwargsForwards arbitrary options; extensibleLoses IDE type-checking and autocompleteDecorators, wrappers, config forwarding
Dataclass / TypedDictGroups 5+ related fields; typed, inspectableOverkill for simple utilitiesComplex config objects, request bodies

Python 3.8 introduced two special parameter separators that give you fine-grained control over calling conventions:

# Parameters before / are positional-only — callers cannot use the keyword syntax
# Parameters after * are keyword-only — callers must use the keyword syntax
def process(source, /, *, output="stdout", verbose=False):
    print(source, output, verbose)

process("data.csv")                      # OK
process("data.csv", output="file")       # OK — keyword-only, fine
process(source="data.csv")              # TypeError — source is positional-only
process("data.csv", "file")             # TypeError — output is keyword-only

Positional-only (/) is useful in library APIs where the parameter name is an implementation detail that should not become part of the public contract. Renaming source to path later would break callers who used source=... as a keyword — positional-only prevents that coupling. Keyword-only (*) is valuable for boolean flags and configuration options where passing them positionally by accident would silently change behavior.


🧭 Which Parameter Style Should You Use? A Decision Guide

Use the table below when you are designing a new function signature. It maps common situations to the parameter style that minimizes caller confusion and future breakage:

SituationRecommended approach
1–3 simple params with obvious rolesPlain positional + keyword, no special decorators
Optional config that rarely changesNamed param with a safe immutable default
Boolean flags that look confusing positionallyKeyword-only: add * before the flags in the signature
Unknown number of values to aggregate*args
Forwarding options to a wrapped function**kwargs (captures and passes through all extras)
Parameter name is an internal implementation detailPositional-only: add / after it in the signature
5+ related parameters that travel togetherDataclass or TypedDict as a single argument
Public API that needs full type safety and docsExplicit named params + type hints + docstring

The goal of any signature is to make incorrect usage fail loudly and correct usage effortless. A well-designed function signature is its own documentation — and in frameworks like FastAPI and Click, it becomes the actual documentation served to users.


🧪 Four Worked Examples: Simple Defaults to Higher-Order Pipelines

These examples build from the most basic function feature to a composition of multiple ideas. Each one is fully runnable in any Python 3.8+ environment.

Example 1: Safe Defaults and Defensive Returns

This function computes a moving average. It demonstrates named defaults, None-guarded mutable state, and an early return when input is invalid:

def moving_average(values, window=3):
    """
    Compute moving average over 'values' with the given window size.
    Returns None if input is empty or the window is out of range.
    """
    if not values or window < 1 or window > len(values):
        return None
    return [
        sum(values[i : i + window]) / window
        for i in range(len(values) - window + 1)
    ]

data = [10, 20, 30, 40, 50]
print(moving_average(data))            # [20.0, 30.0, 40.0]
print(moving_average(data, window=2))  # [15.0, 25.0, 35.0, 45.0]
print(moving_average([], window=3))    # None

Example 2: Closure-Based Configurable Validators

This example shows the factory function and closure pattern. Each returned validator carries its own captured configuration:

def make_range_checker(low, high):
    """Return a function that validates whether a value is in [low, high]."""
    def check(value):
        if value < low or value > high:
            raise ValueError(
                f"{value} is outside the allowed range [{low}, {high}]"
            )
        return value
    return check

check_age   = make_range_checker(0, 120)
check_score = make_range_checker(0, 100)

print(check_age(25))      # 25
print(check_score(99))    # 99

try:
    check_age(200)
except ValueError as e:
    print(e)  # 200 is outside the allowed range [0, 120]

# Confirm closures hold independent captured state
print(check_age.__closure__[0].cell_contents)    # 0
print(check_age.__closure__[1].cell_contents)    # 120
print(check_score.__closure__[1].cell_contents)  # 100

Example 3: Wrapping a Function with args and *kwargs

A timing decorator built with *args and **kwargs demonstrates how to forward all arguments transparently — the core pattern behind every Python decorator:

import time

def timed_call(func, *args, label="", **kwargs):
    """Call func(*args, **kwargs), print elapsed time, and return the result."""
    start = time.perf_counter()
    result = func(*args, **kwargs)
    elapsed = time.perf_counter() - start
    tag = label or func.__name__
    print(f"[{tag}] completed in {elapsed:.6f}s")
    return result

def fetch_records(user_id, limit=10, include_deleted=False):
    # Simulate a short database query
    return [{"id": i, "user": user_id} for i in range(limit)]

records = timed_call(
    fetch_records,
    user_id=42,
    limit=5,
    label="fetch_records",
    include_deleted=False,
)
print(records[:2])
# [fetch_records] completed in 0.000012s
# [{'id': 0, 'user': 42}, {'id': 1, 'user': 42}]

Example 4: Composing a Data Transformation Pipeline

This final example shows first-class functions as pipeline stages. pipeline accepts any number of transformation functions and returns a composed callable that applies them in sequence:

def pipeline(*transforms):
    """Return a single function that applies each transform in order."""
    def run(value):
        for transform in transforms:
            value = transform(value)
        return value
    return run

def strip_whitespace(s):
    return s.strip()

def to_lowercase(s):
    return s.lower()

def remove_punctuation(s):
    return "".join(c for c in s if c.isalnum() or c.isspace())

normalize = pipeline(strip_whitespace, to_lowercase, remove_punctuation)

print(normalize("  Hello, World!  "))       # "hello world"
print(normalize("  Python is GREAT!!!  "))  # "python is great"
print(normalize("  foo-bar_baz (2024)  "))  # "foobar baz 2024"

The run closure captures transforms from pipeline's scope. Calling normalize(" Hello, World! ") applies strip_whitespace, then to_lowercase, then remove_punctuation, chaining the output of each into the input of the next.


Two of the most widely-used Python frameworks — Click for command-line interfaces and FastAPI for HTTP APIs — use function signatures as their primary API contract. Knowing Python functions deeply means you can use both frameworks fluently from day one.

Click: Generating CLI Commands from Function Parameters

Click is a Python library for building command-line interfaces declaratively. You decorate a plain function, and Click reads its parameter names, types, and defaults to auto-generate argument parsing:

import click

@click.command()
@click.argument("filename")
@click.option("--output", default="stdout", help="Output destination.")
@click.option("--limit", default=100, show_default=True, help="Max rows to process.")
@click.option("--verbose", is_flag=True, help="Enable verbose logging.")
def process(filename, output, limit, verbose):
    """Process FILENAME and write the result to OUTPUT."""
    if verbose:
        click.echo(f"Processing {filename}{output} (limit={limit})")
    # ... actual processing logic

if __name__ == "__main__":
    process()

Run it and Click automatically handles --help, type conversion, and error messages. The function's parameter names become the CLI option names. Default values become CLI defaults. The docstring becomes the --help description. Click's design relies entirely on the fact that Python function objects are inspectable at runtime.

FastAPI: Auto-Generating HTTP Endpoints and OpenAPI Docs

FastAPI takes this further. It reads parameter names, type annotations, and defaults from your handler function to auto-generate request parsing, validation, serialization, and interactive OpenAPI documentation — all without any configuration files:

from fastapi import FastAPI, Query
from typing import Optional

app = FastAPI()

@app.get("/items/")
def list_items(
    category: str,
    limit: int = Query(default=10, le=100),
    offset: int = 0,
    include_deleted: bool = False,
):
    """
    Return items filtered by category.
    - category: required query parameter
    - limit: max items to return (capped at 100)
    - offset: pagination offset
    - include_deleted: whether to include soft-deleted records
    """
    return {
        "category": category,
        "limit": limit,
        "offset": offset,
        "include_deleted": include_deleted,
    }

FastAPI parses category from the query string, validates that limit is an integer ≤ 100, rejects requests with limit=200 with a structured 422 error, and publishes all of this to /docs as interactive Swagger UI — generated entirely from the function signature. Adding a parameter to the function immediately updates the API contract and the documentation.

Both frameworks demonstrate that Python function signatures are not just a calling mechanism — they are a data structure that tools can read, validate, and expose to users. For a full deep-dive on FastAPI dependency injection and route composition, see the planned companion post in this series.


📚 Lessons Learned the Hard Way

Always use None for mutable defaults. The pattern def f(items=[]) is syntactically valid, technically works on the first call, and will silently corrupt subsequent calls. Make it a reflex: if a default value is a list, dict, set, or any other mutable object, replace it with None and create a fresh instance inside the body. The mutable default trap is not a subtle edge case — it is guaranteed to happen in any codebase that uses it.

Keyword arguments prevent silent positional swaps. When calling functions with more than two parameters — especially boolean flags or configuration switches — always pass by keyword. send(True, False, True) is a maintenance hazard waiting to become a production incident. send(compress=True, encrypt=False, async_mode=True) is readable, searchable, and refactor-safe.

Closures capture the variable reference, not the value at capture time. A classic loop-closure bug: funcs = [lambda: i for i in range(3)] creates three lambdas that all return 2 when called — the final value of i after the loop. Fix it by using a default argument to snapshot the current value: funcs = [lambda i=i: i for i in range(3)]. The default argument is evaluated at definition time, capturing a fresh copy of i for each function.

*args and `kwargstrade specificity for flexibility.** Use them at boundaries — wrappers, decorators, forwarding layers — not inside domain logic where explicit names communicate intent. A function that accepts**kwargs` and does different things depending on which keys are present is a design smell: it should be two functions, or one function with explicit parameters.

First-class functions replace many patterns that require classes in Java. A dispatch table {"add": add, "sub": sub} replaces a Strategy pattern with a class hierarchy. A factory function replaces an Abstract Factory. A closure replaces a single-method class. Recognizing these equivalences makes your Python more idiomatic and more concise.


📌 Key Takeaways

TLDR: Python functions are first-class objects that support keyword arguments, safe None defaults, variadic *args/**kwargs, closures, and LEGB scope resolution. These are the core mechanics behind decorators, factory functions, and the way FastAPI and Click read your function signature to build complete APIs. Understand the mutable default trap and LEGB lookup order — they appear in almost every non-trivial Python codebase.

Five things to remember from this post:

  • Default values are evaluated once at definition time. Any mutable default is shared across calls. Use None as the default and create the mutable object inside the body.
  • *args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. Both are useful at integration boundaries, not in domain logic.
  • Python resolves names using LEGB order: Local → Enclosing → Global → Built-in. A local variable with the same name as a global silently shadows it because Local is checked first.
  • Closures keep enclosing-scope variables alive by storing a cell reference in __closure__. This is what makes factory functions and decorators work.
  • Use * in a signature to force keyword-only arguments; use / to enforce positional-only. Both improve API safety in libraries and public-facing code.

📝 Practice Quiz

  1. What value does a Python function return if it reaches the end of its body without an explicit return statement?
Correct Answer: None. Every Python function is an expression. If execution reaches the end of the body without hitting a return statement — or hits a bare return with no value — the return value is None. Assigning the result of such a call to a variable always works; the variable simply holds None.
  1. You define def f(items=[]) and call f() three times, each time appending one item inside the function. How many items are in items after the third call?
Correct Answer: 3 items. The list [] is created exactly once when the def statement executes, not on each call. All three calls share the same list object stored in f.__defaults__. Each .append() mutates that shared object. The fix is def f(items=None) with if items is None: items = [] inside the body.
  1. What does the LEGB rule describe, and what happens if Python searches all four levels and still does not find a name?
Correct Answer: LEGB is the four-level name resolution order Python follows when it encounters a variable name: Local (current function) → Enclosing (any outer function scopes) → Global (module top level) → Built-in (Python's built-in namespace). If a name is not found at any level, Python raises a NameError.
  1. What is the difference between *args and **kwargs, and when would you reach for each?
Correct Answer: *args packs extra positional arguments into a tuple inside the function. Use it when the function accepts a variable number of values of the same kind — for example sum_all(1, 2, 3, 4). **kwargs packs extra keyword arguments into a dict inside the function. Use it when the function needs to accept or forward an unknown set of named options — for example in a wrapper or decorator: timed_call(func, *args, **kwargs).
  1. In the following snippet, which scope does Python find x in when inner() runs?

    x = "global"
    def outer():
        x = "enclosing"
        def inner():
            print(x)
        inner()
    outer()
    
Correct Answer: The Enclosing scope (E in LEGB). inner has no local x, so Python looks outward and finds x = "enclosing" in outer's scope before reaching the global x = "global". The output is "enclosing".
  1. Open-ended challenge: Design a function signature for a send_email(...) utility that must support: a required recipient email address, an optional subject line (default: no subject), optional CC and BCC recipient lists, and an arbitrary number of file attachments. Write out the signature, explain whether each parameter should be positional, keyword-only, use *args, or use a default, and justify why you chose that style over the alternatives.

Share
Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms