3. Functions

Define behaviors cleanly: parameters, defaults, typing, closures, and small utilities like decorators.

Question: What are *args and **kwargs?

Answer: They allow a function to accept a variable number of arguments. *args collects extra positional arguments into a tuple, while **kwargs collects extra keyword arguments into a dictionary.

def func(a, b, *args, **kwargs):
    print(f"args={args}, kwargs={kwargs}")

func(1, 2, 3, 4, x=5, y=6)
# args=(3, 4), kwargs={'x': 5, 'y': 6}

Question: Why is it bad to use mutable objects (like a list) as default arguments?

Answer: Default arguments are created only once when the function is defined. A mutable default will be shared across all calls, leading to unexpected side effects. The standard practice is to use None as the default and create a new object inside the function.

def append_safe(item, bag=None):
    if bag is None:
        bag = []
    bag.append(item)
    return bag

Question: What's the difference between a shallow copy and a deep copy?

Answer: A shallow copy duplicates the container but references the same nested objects; a deep copy recursively duplicates all nested objects.

import copy

original = [[1], [2]]
shallow = original.copy()           # or list(original)
deep = copy.deepcopy(original)

original[0].append(99)
# shallow[0] now also has 99; deep[0] is unchanged

Question: Explain Python's scope rules (LEGB).

Answer: Python searches for variables in this order:

  1. Local: Inside the current function.

  2. Enclosing: In the scope of any enclosing functions.

  3. Global: At the top level of the module.

  4. Built-in: In Python's built-in functions.

Question: How do you annotate functions with types?

Answer: Use type hints on parameters and return values.

def greet(name: str) -> str:
    return f"Hi {name}"

numbers: list[int] = [1, 2, 3]
maybe_name: str | None = None

Explanation: Type hints improve readability and tooling. They are not enforced at runtime; use tools like mypy or ruff for checking.

Question: What are parameter kinds (positional-only, keyword-only)?

Answer: Use / to mark positional-only parameters and * to start keyword-only parameters.

def f(a, /, b, *, c):
    return a, b, c

f(1, 2, c=3)   # OK
f(a=1, b=2, c=3)  # TypeError: a is positional-only

Question: How do you unpack arguments when calling a function?

Answer: Use *iterable for positional args and **mapping for keyword args.

args = (1, 2)
kwargs = {"c": 3}
f(*args, **kwargs)

Question: What are lambdas and when should you use them?

Answer: Anonymous, single-expression functions handy for short callbacks or keys to sorted, but avoid complex logic for readability.

square = lambda x: x * x
sorted_users = sorted(users, key=lambda u: (u["age"], u["name"]))

Question: What are closures?

Answer: A closure is a function that captures variables from its enclosing scope, allowing state to persist without using global variables.

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

c = make_counter()
c(), c()  # 1, 2

Question: When to use global and nonlocal?

Answer: global assigns to a module-level variable; nonlocal assigns to a variable in an enclosing function scope.

total = 0
def add(n):
    global total
    total += n

def outer():
    x = 0
    def inner():
        nonlocal x
        x += 1
        return x
    return inner

Question: What is a decorator?

Answer: A decorator takes a function and returns a new function adding behavior (e.g., logging, caching). Use functools.wraps to preserve metadata.

from functools import wraps

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_calls
def greet(name: str) -> str:
    return f"Hi {name}"