On this page
Functions and Scope
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.0Default 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
Noneas 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 8000You 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 = 4Combining 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 2Lambda 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()orfilter()with a lambda.[x**2 for x in numbers]is clearer thanlist(map(lambda x: x**2, numbers)). Reservemap()/filter()for cases where a named function already exists, likelist(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:
- Local — inside the current function
- Enclosing — inside any enclosing function (for nested functions)
- Global — at the module level
- 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) # globalThe `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) # 3tip type: warning title: "Avoid global variables"
Using
globalis 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)) # 17This 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)) # 24Higher-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
Sign in to track your progress