# Functions in Depth: Scope, Closures, and Passing Functions Around

In Post 4 you wrote simple functions that take an input and return an output. That's the easy part. This post goes into the parts that confuse people later, and the parts that show up everywhere in AI code.

This one is core, so I wouldn't skip it, even if you've written Python before. Functions that take other functions as input are all over the place in modern AI work. Callbacks, decorators, and the way a model asks your code to run a tool all depend on the ideas below.

## A quick recap, plus a trap

You can pass arguments by position or by name:

```python
def describe(name, age=30):
    return f"{name} is {age}"

print(describe("Asha"))              # Asha is 30
print(describe("Asha", 25))          # Asha is 25
print(describe(age=41, name="Ravi")) # Ravi is 41
```

Passing by name is clearer, and you'll do it a lot with AI libraries, where a call might have a dozen options.

Now the trap. Look at this function:

```python
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("a"))    # ['a']
print(add_item("b"))    # ['a', 'b']   <- wait, what?
```

The second call should have returned `['b']`. It didn't, because Python makes the default list _once_, when the function is defined, and then reuses that same list on every call. Changes pile up.

The fix is to use `None` as the default and make the list inside:

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

Rule of thumb: never use a list or dictionary as a default value.

## `*args` and `**kwargs`: any number of arguments

Sometimes you don't know how many arguments a function will get. A star in front of a parameter name collects extras.

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

print(total(1, 2, 3))    # 6
print(total())           # 0
```

Inside the function, `numbers` is a tuple holding everything that was passed in.

Two stars collect named arguments into a dictionary:

```python
def show(**options):
    for key, value in options.items():
        print(key, value)

show(model="x", temperature=0.2)
# model x
# temperature 0.2
```

The names are just convention: `*args` and `**kwargs` are what most people write. The stars are what matter.

The stars also work in reverse, when you _call_ a function. They spread out a collection into separate arguments:

```python
numbers = (1, 2, 3)
print(total(*numbers))            # same as total(1, 2, 3)

config = {"model": "y", "temperature": 0.5}
show(**config)                    # same as show(model="y", temperature=0.5)
```

Why does this matter for AI work? You'll often write a function that wraps another one and just passes everything along. A wrapper around an API call, for example, shouldn't have to list every option the API supports. It collects them with `**kwargs` and hands them over.

## Scope: where a variable lives

A variable made inside a function belongs to that function only.

```python
def local_only():
    secret = 42

local_only()
print(secret)    # NameError: name 'secret' is not defined
```

Python looks for names in a set order: first inside the current function (local), then in any function wrapped around it, then at the top of your file (global), and finally in Python's built-in names like `len` and `print`.

Now a puzzle that catches nearly everyone once:

```python
counter = 0

def increase():
    counter = counter + 1

increase()
```

```sql
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value
```

You'd expect that to work, since `counter` clearly exists above. The problem is that when you _assign_ to a name inside a function, Python decides that name is local to the function. So on the right-hand side it looks for a local `counter` that doesn't exist yet.

You can fix it with the `global` keyword, but I'd avoid that. Code that secretly changes outside variables gets very hard to follow. The cleaner way is to pass things in and return things out:

```python
def increase(counter):
    return counter + 1

counter = increase(counter)
```

## Functions are just values

This is the big idea of the post. In Python, a function is an object like any other. You can put it in a variable, a list, or a dictionary, and pass it to another function.

```python
def shout(text):
    return text.upper()

def whisper(text):
    return text.lower()

styles = {"shout": shout, "whisper": whisper}

print(styles["shout"]("hello"))      # HELLO
print(styles["whisper"]("HELLO"))    # hello
```

Notice there are no brackets after `shout` and `whisper` when they go into the dictionary. Writing `shout` means "the function itself." Writing `shout("hi")` means "run it now."

That dictionary trick comes back later. When a language model decides it wants to use a tool, it sends back the _name_ of the tool as text. Your code looks that name up in a dictionary like this one and runs the matching function. That's all tool calling is, at its core.

## Higher-order functions

A function that takes another function as input, or returns one, is called a higher-order function. It sounds fancy, but you've already used one.

```python
def apply_to_all(func, items):
    return [func(item) for item in items]

print(apply_to_all(shout, ["a", "b"]))    # ['A', 'B']
```

Python's built-in `sorted()` is one too. It takes an optional `key` that tells it _how_ to sort:

```python
words = ["banana", "fig", "cherry"]
print(sorted(words, key=len))    # ['fig', 'banana', 'cherry']
```

Here `len` is passed as a function. Python calls it on each word and sorts by the results: 6, 3, 6. That also explains the line from Post 5: `sorted(counts, key=counts.get)` sorts the words by looking up each one's count.

## Lambdas: tiny throwaway functions

Sometimes you need a small function just once, and giving it a name feels like overkill. A `lambda` is a function written in one line, with no name:

```python
square = lambda x: x * x
print(square(4))    # 16
```

The part before the colon is the input, and the part after is the result. Lambdas shine as quick arguments:

```python
words = ["banana", "fig", "cherry"]
print(sorted(words, key=lambda w: len(w)))    # ['fig', 'banana', 'cherry']
```

Two other built-ins that take functions are `filter` (keep the items that pass a test) and `map` (transform every item):

```python
numbers = [1, 2, 3, 4, 5, 6]

print(list(filter(lambda n: n % 2 == 0, numbers)))    # [2, 4, 6]
print(list(map(lambda n: n * n, numbers)))            # [1, 4, 9, 16, 25, 36]
```

A word of advice. Keep lambdas short. If you need more than one simple expression, or if you'd like to give it a good name, write a regular `def`. Also, in the next post you'll meet comprehensions, which are often a nicer way to do what `map` and `filter` do.

## Closures: functions that remember

Here's one that makes people pause the first time. You can define a function _inside_ another function, and the inner one can use the outer one's variables, even after the outer function has finished.

```python
def make_multiplier(factor):
    def multiply(number):
        return number * factor
    return multiply

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

print(double(5))    # 10
print(triple(5))    # 15
```

When `make_multiplier(2)` finishes, you'd think `factor` disappears. It doesn't. The inner function carries it along. That's called a **closure**, and `double` and `triple` each remember their own `factor`.

It's useful any time you want to build a configured function. A more AI-flavored example:

```python
def make_prompt_builder(role):
    def build(question):
        return f"You are a {role}. Answer this question: {question}"
    return build

teacher = make_prompt_builder("patient teacher")
print(teacher("What is a closure?"))
# You are a patient teacher. Answer this question: What is a closure?
```

You set the role once, and then reuse the builder as many times as you like.

A closure can also hold state that changes over time. For that you need the `nonlocal` keyword, which says "I mean the variable from the outer function, not a new local one":

```python
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = make_counter()
print(counter(), counter(), counter())    # 1 2 3
```

Closures are the idea behind decorators, which is the topic of the next-but-one post. So if this section felt abstract, read it again slowly before moving on.

## Habits for writing good functions

-   **One job per function.** If you need "and" to describe what it does, it's probably two functions.
    
-   **Pick a clear name.** `calculate_cost` beats `process`.
    
-   **Add a docstring.** A short description right under the `def` line:
    

```python
def calculate_cost(tokens, price_per_million):
    """Return the cost in dollars for a number of tokens."""
    return tokens / 1_000_000 * price_per_million
```

Your editor will show it when you hover over the function, and your future self will thank you.

## Mini project: a retry helper

API calls fail. The network drops, a server is busy, you hit a rate limit. Real AI apps need to try again, and you don't want to copy the same retry code around every place you call a model.

This is the perfect job for a higher-order function. Create `retry.py`:

```python
import random
import time

def retry(func, *args, attempts=3, delay=0.5, **kwargs):
    for attempt in range(1, attempts + 1):
        try:
            return func(*args, **kwargs)
        except Exception as error:
            print(f"Attempt {attempt} failed: {error}")
            if attempt == attempts:
                raise
            time.sleep(delay)

def flaky_greeting(name):
    if random.random() < 0.6:
        raise RuntimeError("Service busy")
    return f"Hello, {name}!"

print(retry(flaky_greeting, "Asha", attempts=5))
```

Run it a few times. Since the failures are random, the output changes. You might see something like this:

```yaml
Attempt 1 failed: Service busy
Attempt 2 failed: Service busy
Hello, Asha!
```

Occasionally all five attempts fail and you'll see the error crash the program. That's expected. When the last attempt fails, the function re-raises the error instead of hiding it.

Here's what's going on. `retry` takes any function, plus whatever arguments that function needs, collected with `*args` and `**kwargs`. It calls the function inside a `try` block. A `try`/`except` means "try this, and if it crashes, do this instead." (We'll cover it properly in Post 11.) Because `retry` doesn't care what the function does, the same helper works for any function that might fail.

One detail: `attempts` and `delay` come _after_ `*args`. That means they can only be given by name, like `attempts=5`. It stops you from mixing them up with the arguments for your own function.

### Make it yours

1.  Make the delay double after every failed attempt (this is called exponential backoff, and real APIs expect it).
    
2.  Only retry on certain errors, and let others crash right away.
    
3.  Print how long all the attempts took in total.
    

## Practice

About 20 minutes of real effort on each before you ask for help.

1.  Write a function that takes any number of numbers and returns their average.
    
2.  Write `make_greeter(greeting)` that returns a function. `make_greeter("Namaste")("Asha")` should give `"Namaste, Asha!"`.
    
3.  Sort a list of names by their last letter, using a lambda.
    
4.  Write your own `apply_to_all(func, items)` without using `map` or a list comprehension.
    
5.  Fix this so repeated calls don't share a list:
    

```python
def collect(value, bucket={}):
    bucket[value] = True
    return bucket
```

Then ask an AI tutor to review your answers, and don't let it rewrite them.

## What to take away

-   Arguments can be passed by position or by name, and `*args` and `**kwargs` collect the extras.
    
-   Never use a list or dictionary as a default value.
    
-   A variable made inside a function stays inside it.
    
-   Functions are values, so you can store them, pass them, and return them.
    
-   Lambdas are small one-line functions, best for short jobs.
    
-   A closure is a function that remembers variables from where it was created.
    

* * *

**Previous:** Post 5, _Built-in Data Structures_

**Next in the series:** Post 7, _Iterators, Generators & Comprehensions_, the tools behind data pipelines and streaming model output.

---

*Published via [ZyVOP](https://zyvop.com/functions-in-depth-scope-closures-and-passing-functions-around-lbukq?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
