# Functions and *args / **kwargs

## 1. Function Basics

```python
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

greet("Ana")                    # "Hello, Ana!"
greet("Ana", "Hi")              # "Hi, Ana!"
greet(name="Ana", greeting="Hi")  # same, by keyword
greet(greeting="Hi", name="Ana")  # keyword args can be reordered
```

Functions are objects, defined with `def` (or `lambda` for single-expression anonymous functions), and can be passed around, stored in data structures, and returned from other functions like any other value.

## 2. Positional, Keyword, and the `*` / `/` Separators

```python
def f(a, b, /, c, d, *, e, f):
    #   ^-------^  ^  ^-------^
    #   positional  |  keyword-only
    #      only     positional-or-keyword
    pass

f(1, 2, 3, d=4, e=5, f=6)   # valid
f(a=1, b=2, c=3, d=4, e=5, f=6)  # TypeError — a, b are positional-only
f(1, 2, 3, 4, 5, 6)          # TypeError — e, f are keyword-only
```

- Parameters before `/` **must** be passed positionally (Python 3.8+).
- Parameters after `*` **must** be passed by keyword.
- Everything in between can be passed either way.

This matters for API design: positional-only parameters let you rename them later without breaking callers; keyword-only parameters force call sites to be self-documenting for options that are easy to mix up (e.g. `sort(data, *, reverse=True)` instead of a bare trailing `True`).

## 3. `*args` — Variadic Positional Arguments

```python
def total(*args):
    return sum(args)

total(1, 2, 3)      # 6 — args is the tuple (1, 2, 3)
total()              # 0 — args is ()

def show(first, *rest):
    print(first, rest)

show(1, 2, 3)  # 1 (2, 3)
```

`*args` collects any extra positional arguments into a `tuple`. It can also be used at a call site to unpack an iterable into positional arguments: `total(*[1, 2, 3])`.

## 4. `**kwargs` — Variadic Keyword Arguments

```python
def describe(**kwargs):
    return kwargs

describe(name="Ana", age=30)  # {"name": "Ana", "age": 30} — a dict

def connect(host, **options):
    print(host, options)

connect("localhost", timeout=5, retries=3)
```

`**kwargs` collects extra keyword arguments into a `dict`, in the order they were passed (dicts preserve insertion order). At a call site, `**some_dict` unpacks a dict into keyword arguments: `connect("localhost", **{"timeout": 5})`.

## 5. Combining Everything — and the Required Order

```python
def full(a, b=2, *args, c, d=4, **kwargs):
    return a, b, args, c, d, kwargs

full(1, c=3)
# (1, 2, (), 3, 4, {})
full(1, 2, 3, 4, c=5, e=6)
# (1, 2, (3, 4), 5, 4, {'e': 6})
```

The required order in a `def` is: positional-only, positional-or-keyword (with defaults after non-defaults), `*args`, keyword-only, `**kwargs`. Anything after a bare `*args` (or a bare `*`) is automatically keyword-only.

## 6. The Mutable Default Argument Trap

```python
def append_item(item, bucket=[]):   # DANGER
    bucket.append(item)
    return bucket

append_item(1)   # [1]
append_item(2)   # [1, 2] — NOT [2]! Same list reused across calls.
```

Default argument values are evaluated **exactly once**, at `def` time — not on every call. A mutable default is therefore shared and accumulates state across unrelated calls. The idiomatic fix:

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

## 7. Lambdas

```python
square = lambda x: x ** 2
sorted(["ccc", "a", "bb"], key=lambda s: len(s))  # ["a", "bb", "ccc"]
```

`lambda` creates a function limited to a single expression (no statements, no annotations before 3.something in some forms) — used almost exclusively for short throwaway callbacks like sort keys, not for anything that deserves a name.

## 8. Under the Hood: Argument Binding and the Function Object

### `def` creates a function object once; every call reuses it

```python
def f(a, b=1):
    return a + b

print(f.__defaults__)   # (1,) — the tuple of default values, built once at def time
print(f.__code__.co_argcount)  # 2
print(f.__code__.co_varnames)  # ('a', 'b')
```

![Call arguments and the __defaults__ tuple are bound into the callee frame's fast-locals array](diagrams/01-argument-binding.svg)

A `def` statement executes once, creating a single function object holding a reference to a compiled `code` object plus the tuple of default values captured *at that moment*. This is exactly why mutable defaults leak state between calls — `bucket=[]` allocates one list object when the `def` line runs, and `__defaults__` holds a reference to that same list forever, handed out fresh to every call that doesn't override it.

### Binding arguments to parameters is a real algorithm, not magic

When you call `f(1, b=2)`, CPython runs an explicit argument-binding procedure roughly equivalent to: fill positional-or-keyword slots left to right from positional args, then fill remaining slots (and `**kwargs`) from keyword args, checking for duplicates and missing required parameters, then apply defaults for anything still unfilled. Modern CPython (3.8+, the "vectorcall" protocol) does this without first packing arguments into an intermediate tuple/dict for ordinary calls — it's optimized to bind straight from the C-level argument array into the callee's frame's fast-locals array for the common case, falling back to the slower generic path when `*args`/`**kwargs` unpacking is genuinely needed at the call site.

### Bound methods are functions wrapped at attribute-access time

```python
class Counter:
    def increment(self):
        return "incremented"

c = Counter()
m = c.increment       # a NEW bound-method object created right here
print(m.__self__ is c)  # True
print(m.__func__ is Counter.increment)  # True
```

`Counter.increment` is a plain function stored on the class. Accessing `c.increment` doesn't call it — it goes through the function object's descriptor protocol (`__get__`), which produces a fresh **bound method** object pairing that function with `c` as `self`. This is why `c.increment()` and `Counter.increment(c)` do exactly the same thing: the bound method's job is only to pre-fill the first positional argument.

## 9. Common Pitfalls

1. **Mutable default arguments** (`def f(x=[])`) — evaluated once, shared across all calls that don't override it. Use `None` as the sentinel default and create the mutable object inside the function body instead.
2. **Forgetting `*args`/`**kwargs` unpacking order at call sites** — `f(*args, **kwargs)` unpacks positionally then by keyword; passing them in the wrong order relative to explicit arguments raises `SyntaxError` at the call site (`f(**kwargs, *args)` is invalid).
3. **Shadowing builtins as parameter names** — `def process(list, dict): ...` works but shadows the builtins `list`/`dict` for the whole function body, which can cause confusing bugs later in that function.
4. **Assuming `**kwargs` preserves any particular required order** — its iteration order is insertion order (dict semantics), but you should not design an API that depends on the caller passing keyword arguments in a specific order; use explicit named parameters if order-independent meaning matters.

## 10. Quick Reference

```python
def f(a, b, /, c, *, d): ...     # a,b positional-only; d keyword-only
def f(*args): ...                 # args: tuple of extra positional args
def f(**kwargs): ...              # kwargs: dict of extra keyword args
f(*some_list)                      # unpack list/tuple into positional args
f(**some_dict)                     # unpack dict into keyword args
def f(x=None):                     # correct mutable-default pattern
    if x is None:
        x = []
f.__defaults__                     # tuple of default values, set once at def time
```
