On this page

Functions and Scope

14 min read TextCh. 2 — Functions and Data

Functions and Scope

Functions are the building blocks of any non-trivial program. They let you give a name to a piece of logic, reuse it without repeating code, and reason about your program in manageable chunks. Python functions are first-class objects — you can pass them as arguments, return them from other functions, and store them in variables. This flexibility makes Python extremely expressive and is the foundation for patterns like decorators (Lesson 15) and callbacks.

Defining Functions with `def`

The def keyword defines a function. The function body is indented:

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

# Calling the function
message = greet("Alice")
print(message)   # Hello, Alice!

# Functions are objects — you can assign them to variables
say_hi = greet
print(say_hi("Bob"))   # Hello, Bob!

The string on the first line of a function body (between triple quotes) is a docstring — Python's built-in documentation mechanism. Access it with help(greet) or greet.__doc__.

Parameters and Arguments

Python functions support several parameter types. Learning to use them well is one of the keys to writing clean, flexible APIs.

Positional Parameters

def power(base: float, exponent: float) -> float:
    """Return base raised to the exponent."""
    return base ** exponent

print(power(2, 10))    # 1024.0
print(power(3, 3))     # 27.0

Default Parameter Values

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

print(greet("Alice"))              # Hello, Alice!
print(greet("Bob", "Good morning"))  # Good morning, Bob!

tip type: warning title: "Never use mutable defaults"

Never use a mutable object (list, dict, set) as a default parameter value. Python creates the default object once at function definition time, so all calls share the same object. Use None as the default and create the mutable object inside the function body:

# BAD
def add_item(item, items=[]):
    items.append(item)
    return items

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

Keyword Arguments

When calling a function, you can pass arguments by name, which makes calls more readable and allows you to skip parameters with defaults:

def create_user(username: str, email: str, role: str = "user", active: bool = True) -> dict:
    return {"username": username, "email": email, "role": role, "active": active}

# Positional
u1 = create_user("alice", "[email protected]")

# Keyword — order does not matter
u2 = create_user(email="[email protected]", username="bob", role="admin")

# Mix of positional and keyword
u3 = create_user("carol", "[email protected]", active=False)

print(u1)
print(u2)
print(u3)

Positional-Only and Keyword-Only Parameters

Python 3.8+ allows you to enforce how arguments must be passed using / and * in the signature:

def strict_greet(name: str, /, *, greeting: str = "Hello") -> str:
    # name must be passed positionally (before /)
    # greeting must be passed as keyword (after *)
    return f"{greeting}, {name}!"

print(strict_greet("Alice"))                   # Hello, Alice!
print(strict_greet("Bob", greeting="Hi"))      # Hi, Bob!
# strict_greet(name="Carol")  # TypeError: name is positional-only

`*args` — Variable Positional Arguments

When you prefix a parameter with *, it collects all extra positional arguments into a tuple:

def total(*numbers: float) -> float:
    """Sum any number of arguments."""
    return sum(numbers)

print(total(1, 2, 3))          # 6
print(total(10, 20, 30, 40))   # 100
print(total())                  # 0

def log(level: str, *messages: str) -> None:
    prefix = f"[{level.upper()}]"
    for msg in messages:
        print(f"{prefix} {msg}")

log("info", "Server started", "Listening on port 8000")
# [INFO] Server started
# [INFO] Listening on port 8000

You can also use * to unpack an iterable when calling a function:

values = [3, 1, 4, 1, 5, 9, 2, 6]
print(max(*values))    # 9
print(min(*values))    # 1
print(total(*values))  # 31

`**kwargs` — Variable Keyword Arguments

When you prefix a parameter with **, it collects all extra keyword arguments into a dictionary:

def configure(**settings: str | int | bool) -> None:
    for key, value in settings.items():
        print(f"  {key} = {value!r}")

print("App settings:")
configure(debug=True, host="localhost", port=8000, workers=4)
# App settings:
#   debug = True
#   host = 'localhost'
#   port = 8000
#   workers = 4

Combining all parameter types:

def mixed(pos1: int, pos2: int, *args: int, keyword_only: str = "default", **kwargs: int) -> None:
    print(f"pos1={pos1}, pos2={pos2}")
    print(f"args={args}")
    print(f"keyword_only={keyword_only!r}")
    print(f"kwargs={kwargs}")

mixed(1, 2, 3, 4, 5, keyword_only="custom", extra=99)
# pos1=1, pos2=2
# args=(3, 4, 5)
# keyword_only='custom'
# kwargs={'extra': 99}

Return Values

Functions return values with the return statement. A function can return multiple values by returning a tuple:

def divmod_custom(a: int, b: int) -> tuple[int, int]:
    """Return the quotient and remainder of a divided by b."""
    return a // b, a % b   # returns a tuple (implicitly)

quotient, remainder = divmod_custom(17, 5)
print(f"17 ÷ 5 = {quotient} remainder {remainder}")   # 17 ÷ 5 = 3 remainder 2

# Python has a built-in divmod() too!
q, r = divmod(17, 5)
print(q, r)   # 3 2

Lambda Functions

A lambda is an anonymous function defined in a single expression. It is useful when you need a small function for a short time — typically as an argument to sorted(), map(), or filter():

# A regular function...
def square(x: float) -> float:
    return x ** 2

# ...can be written as a lambda
square_lambda = lambda x: x ** 2
print(square_lambda(5))   # 25

# Sorting with a key function
students = [
    {"name": "Alice", "grade": 92},
    {"name": "Bob", "grade": 78},
    {"name": "Carol", "grade": 85},
]

# Sort by grade (ascending)
by_grade = sorted(students, key=lambda s: s["grade"])
for s in by_grade:
    print(f"{s['name']}: {s['grade']}")
# Bob: 78
# Carol: 85
# Alice: 92

# Sort by name (alphabetically)
by_name = sorted(students, key=lambda s: s["name"])

# map() applies a function to every element
numbers = [1, 2, 3, 4, 5]
squares = list(map(lambda x: x ** 2, numbers))
print(squares)   # [1, 4, 9, 16, 25]

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

tip type: tip title: "Prefer list comprehensions over map/filter"

In most cases, a list comprehension is more readable than map() or filter() with a lambda. [x**2 for x in numbers] is clearer than list(map(lambda x: x**2, numbers)). Reserve map()/filter() for cases where a named function already exists, like list(map(str, numbers)).

LEGB Scope Rules

Scope determines where in your code a variable is accessible. Python uses the LEGB rule to resolve names: it searches scopes in this order:

  1. Local — inside the current function
  2. Enclosing — inside any enclosing function (for nested functions)
  3. Global — at the module level
  4. Built-in — Python's built-in namespace (print, len, range, etc.)
x = "global"   # Global scope

def outer() -> None:
    x = "enclosing"   # Enclosing scope (relative to inner)

    def inner() -> None:
        x = "local"       # Local scope — shadows enclosing and global
        print(x)          # local

    inner()
    print(x)              # enclosing

outer()
print(x)                  # global

The `global` Keyword

To modify a global variable from inside a function, declare it with global:

counter = 0

def increment() -> None:
    global counter
    counter += 1

increment()
increment()
increment()
print(counter)   # 3

tip type: warning title: "Avoid global variables"

Using global is generally a code smell. It makes functions impure, harder to test, and creates hidden dependencies. Prefer returning values from functions or encapsulating state in a class or a closure instead.

The `nonlocal` Keyword

To modify a variable in an enclosing (but not global) scope, use nonlocal:

def make_counter(start: int = 0):
    count = start

    def increment(step: int = 1) -> int:
        nonlocal count
        count += step
        return count

    return increment

counter = make_counter(10)
print(counter())    # 11
print(counter())    # 12
print(counter(5))   # 17

This pattern — where an inner function "remembers" a variable from its enclosing scope — is called a closure. It is a fundamental building block for decorators and functional programming in Python.

Functions as First-Class Objects

Because functions are objects, you can store them in data structures and pass them around just like any other value:

def add(a: float, b: float) -> float:
    return a + b

def subtract(a: float, b: float) -> float:
    return a - b

def multiply(a: float, b: float) -> float:
    return a * b

operations: dict[str, object] = {
    "+": add,
    "-": subtract,
    "*": multiply,
}

def calculate(a: float, op: str, b: float) -> float:
    operation = operations.get(op)
    if operation is None:
        raise ValueError(f"Unknown operator: {op!r}")
    return operation(a, b)   # type: ignore[operator]

print(calculate(10, "+", 5))   # 15
print(calculate(10, "-", 3))   # 7
print(calculate(4, "*", 6))    # 24

Higher-Order Functions

A higher-order function either takes a function as an argument or returns a function:

from typing import Callable

def apply_twice(func: Callable[[float], float], value: float) -> float:
    """Apply func to value, then apply it again to the result."""
    return func(func(value))

def double(x: float) -> float:
    return x * 2

def add_ten(x: float) -> float:
    return x + 10

print(apply_twice(double, 3))     # 12 (3*2=6, 6*2=12)
print(apply_twice(add_ten, 5))    # 25 (5+10=15, 15+10=25)

nextSteps

  • lists-and-tuples