Chapter 8: Functions and Functional Features
Functions are the building blocks of structure and reuse in Python. They let you group statements into named units, turning repeated logic into single, clear definitions. A good function captures one idea, performs one task, and hides unnecessary detail behind a clean interface. This modularity is what turns a script into a program and a collection of programs into a coherent system.
In Python, functions are not only organisational tools but also objects in their own right. They can be passed as arguments, returned from other functions, stored in data structures, and created dynamically. This flexibility comes from Python’s functional heritage, where functions are treated as first-class citizens—a concept that opens up powerful and expressive programming patterns.
This chapter explores how functions work in Python and how they connect to the broader functional features of the language. We’ll cover definition and calling syntax, parameters and return values, scope and closures, anonymous functions with lambda, higher-order functions, and the role of decorators in extending behaviour. Along the way, you’ll see how Python’s treatment of functions as objects allows for patterns that are both elegant and deeply practical.
Defining and calling functions
Every function in Python begins with the def keyword, followed by the function’s name, a list of parameters in parentheses, and a colon. The body of the function is indented and contains the statements that define what the function does. When the function is called, Python executes those statements and optionally returns a value with the return statement.
def greet(name):
print("Hello,", name)
Here, greet is a function that takes a single argument, name. When called, it prints a greeting. The function itself does nothing until invoked by name:
greet("Ada")
greet("Grace")
Function names follow the same rules as variables: they must begin with a letter or underscore, contain only letters, digits, and underscores, and are case-sensitive. By convention, Python functions use lowercase words separated by underscores (snake_case).
Returning values
Functions often compute and return a result. The return statement ends the function and passes a value back to the caller. If no return is given, Python automatically returns None.
def add(a, b):
return a + b
result = add(5, 3)
print(result) # 8
A function can return any Python object, including lists, tuples, dictionaries, or even other functions. Returning multiple values is simply done by separating them with commas, which creates a tuple implicitly.
def divide(x, y):
quotient = x // y
remainder = x % y
return quotient, remainder
q, r = divide(10, 3)
print(q, r) # 3 1
Default and optional arguments
Parameters can have default values, making them optional when calling the function. Defaults are evaluated once when the function is defined, not each time it is called.
def greet(name, greeting="Hello"):
print(greeting, name)
greet("Ada")
greet("Grace", "Hi")
Defaults can be of any type, but mutable defaults (like lists or dictionaries) should be avoided because they persist across calls. Use None as a safe default if you need to create a new object each time.
def append_item(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
Keyword and positional arguments
Arguments in Python can be passed by position or by keyword. Positional arguments match parameters in order, while keyword arguments specify them explicitly. You can mix both styles as long as positional arguments come first.
def describe(person, age, city):
print(f"{person} is {age} and lives in {city}.")
describe("Ada", 36, "London")
describe(person="Grace", age=35, city="New York")
describe("Linus", city="Helsinki", age=33)
This flexibility makes code easier to read and maintain. It also helps prevent errors when a function takes several parameters of similar types.
Docstrings and function help
A docstring (short for documentation string) is a string literal placed immediately after a function definition. It describes what the function does, its parameters, and its return value. Python stores this text as the function’s .__doc__ attribute and displays it when using the help() function.
def square(n):
"""Return the square of n."""
return n * n
help(square)
Clear docstrings turn your functions into self-documenting components, making code easier to use, share, and maintain. Well-written docstrings follow the same conventions as those in the standard library: a short summary on the first line, optionally followed by more detail or examples.
return values are the output, and the docstring is the agreement that explains what happens in between.
By defining and calling functions clearly, you give structure to your code, make behaviour reusable, and create natural building blocks for larger systems. Every good Python program grows from small, well-defined functions that each do one thing well.
Positional, keyword, and default parameters
Python gives you several ways to pass data into functions, allowing for a mix of flexibility and clarity. Function parameters can be positional, keyword-based, or have default values, and all three styles can coexist in a single definition. Understanding how these work together is key to writing functions that are both powerful and easy to call.
Positional parameters
By default, arguments in a function call are matched to parameters by their order. These are known as positional parameters. The first argument corresponds to the first parameter, the second to the second, and so on.
def move(x, y):
print(f"Moving to coordinates ({x}, {y})")
move(10, 20)
Positional arguments are compact and familiar, but can become confusing when there are many parameters or when the order is easy to forget. This is where keyword arguments improve readability.
Keyword arguments
A keyword argument specifies which parameter a value is meant for, regardless of order. You write the parameter name followed by an equals sign and the value. Keyword arguments make function calls self-documenting, which is particularly useful when functions have many optional or similarly typed parameters.
def connect(host, port, secure):
print(f"Connecting to {host}:{port} (secure={secure})")
connect("example.com", 443, True) # positional
connect(port=443, host="example.com", secure=True) # keyword
When combining positional and keyword arguments, positional ones must always come first. This rule avoids ambiguity and keeps function calls consistent.
connect("example.com", port=443, secure=True) # valid
connect(port=443, "example.com", secure=True) # invalid
Default parameters
Parameters can be given default values, which makes them optional when calling the function. If the caller omits a value, Python uses the default instead.
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet("Ada")
greet("Grace", "Hi")
Defaults are evaluated once at the time the function is defined, not every time it runs. This means that mutable default values like lists or dictionaries persist across calls, which can lead to subtle bugs.
def add_item(item, collection=[]):
collection.append(item)
return collection
print(add_item("apple"))
print(add_item("banana")) # reuses the same list
The second call here adds to the same list as the first, since the default list is shared. To avoid this, use None as a placeholder and create a new list inside the function.
def add_item(item, collection=None):
if collection is None:
collection = []
collection.append(item)
return collection
Combining styles
Functions often use a mix of positional, keyword, and default parameters to balance flexibility with clarity. For instance, positional parameters might define essential inputs, while keyword and default parameters handle optional behaviour.
def send_message(to, subject, body="", urgent=False):
print(f"To: {to}")
print(f"Subject: {subject}")
if urgent:
print("⚠️ URGENT")
print(body)
send_message("team@example.com", "Meeting", urgent=True)
This design lets the caller specify what matters most, while relying on defaults for the rest. It’s a clear, Pythonic way to make functions flexible without making them hard to read.
Variable-length arguments
Sometimes you don’t know in advance how many arguments a function should accept. Python solves this with variable-length arguments, which allow a function to receive an arbitrary number of positional or keyword parameters. This makes functions highly flexible and adaptable to different calling patterns.
*args: variable positional arguments
When you prefix a parameter with an asterisk (*), Python collects any extra positional arguments into a tuple. This allows the function to handle any number of values without needing to define them individually.
def total(*numbers):
print(numbers)
return sum(numbers)
print(total(2, 4, 6))
print(total(1, 3, 5, 7, 9))
Here, all supplied arguments are packed into the tuple numbers. The function can then iterate over them as needed. This technique is especially useful when building wrapper functions or utilities that forward arguments to other functions.
**kwargs: variable keyword arguments
Similarly, prefixing a parameter with two asterisks (**) collects any additional keyword arguments into a dictionary. This lets you capture named arguments that were not explicitly defined in the function’s signature.
def describe(**info):
for key, value in info.items():
print(f"{key}: {value}")
describe(name="Ada", job="Engineer", city="London")
Inside the function, info is a dictionary containing all the keyword arguments passed in. This pattern is common when functions need to accept flexible configuration data or pass arbitrary options along to another function.
Combining fixed and variable parameters
You can mix normal parameters with *args and **kwargs in one definition. The general order is:
- Positional or keyword parameters
*args- Keyword-only parameters (optional)
**kwargs
def report(title, *items, **details):
print("Report:", title)
for item in items:
print("-", item)
for key, value in details.items():
print(f"{key}: {value}")
report("Inventory", "Apples", "Oranges", total=42, checked=True)
This prints a short list of items, followed by key–value details. The function can accept any number of extra items and additional details, making it extremely flexible while keeping a clear base structure.
Argument unpacking
The * and ** symbols can also be used in the opposite direction to unpack sequences or dictionaries when calling a function. This spreads their contents into separate arguments automatically.
def greet(first, last):
print(f"Hello, {first} {last}!")
args = ("Ada", "Lovelace")
kwargs = {"first": "Grace", "last": "Hopper"}
greet(*args)
greet(**kwargs)
Unpacking is an elegant way to forward arguments dynamically or combine values from different sources. It’s also a key part of many Python APIs, especially in frameworks that build functions programmatically.
*args and **kwargs, remember that they collect all unmatched arguments. Misordering parameters or misspelling a keyword can silently redirect values into the wrong place, so always check your function signatures carefully.
Scope, closures, and global/nonlocal
Every name in Python lives inside a scope, which defines where that name can be accessed. Scopes create layers of visibility, ensuring that variables defined inside one part of a program do not unintentionally affect others. Understanding how Python manages scope is essential for writing reliable and maintainable code.
The LEGB rule
When Python looks up a name, it follows a specific order known as the LEGB rule — Local, Enclosing, Global, and Built-in. Python searches through these scopes in sequence until it finds the name being referenced.
- Local: names created inside the current function
- Enclosing: names in any outer (but not global) functions that surround the current one
- Global: names defined at the top level of the current module or declared global
- Built-in: names provided by Python itself, such as
lenorrange
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x)
inner()
outer()
When inner() prints x, it finds the local version first. If it were missing, Python would look outward through the enclosing, global, and finally built-in scopes until a match was found.
Global variables
Names defined at the top level of a module live in the global scope. They can be read from anywhere, but to modify them inside a function you must declare them as global. Without this declaration, Python assumes any assignment creates a new local variable instead of changing the global one.
counter = 0
def increment():
global counter
counter += 1
increment()
print(counter) # 1
Using global variables can make code harder to reason about because changes in one part of a program can unexpectedly affect another. They should be used sparingly, typically for constants or shared configuration.
Closures and enclosing scopes
A closure occurs when a nested function captures and remembers variables from its enclosing scope, even after that outer function has finished executing. Closures are a powerful feature that enable encapsulation and function factories.
def make_multiplier(factor):
def multiply(n):
return n * factor
return multiply
times3 = make_multiplier(3)
print(times3(5)) # 15
Here, multiply() retains access to factor even though make_multiplier() has already returned. The variable is stored in the closure, not recreated each time the inner function runs. You can inspect a function’s closure via its __closure__ attribute.
The nonlocal keyword
When working with nested functions, you may want to modify a variable from an enclosing (but not global) scope. The nonlocal keyword tells Python that a name refers to one in the nearest enclosing scope, not a new local one.
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c = counter()
print(c()) # 1
print(c()) # 2
Without the nonlocal declaration, the count += 1 line would create a new local variable inside increment() instead of updating the one in the enclosing scope. The nonlocal keyword ensures that nested functions can modify variables from their outer lexical context safely and predictably.
global and nonlocal unless necessary. Excessive use can make code difficult to follow by introducing hidden state changes across scopes. When possible, return updated values explicitly or use classes to manage state more transparently.
Lambda expressions
Python’s lambda expressions provide a compact way to define small, anonymous functions. They are useful when a simple function is needed for a short period, especially as an argument to another function such as map(), filter(), or sorted(). A lambda is syntactically limited to a single expression, but it can capture variables from its surrounding scope just like a regular function.
square = lambda x: x * x
print(square(5)) # 25
This defines a function that takes one argument (x) and returns its square. The lambda keyword is followed by parameters, a colon, and the expression whose result will be returned. There is no need for return because the value of the expression is returned automatically.
Using lambda with built-in functions
Lambda functions are most commonly used where a short, throwaway function is required. For example, you can provide a sorting key directly within a call to sorted():
words = ["banana", "apple", "cherry"]
sorted_words = sorted(words, key=lambda w: len(w))
print(sorted_words) # ['apple', 'banana', 'cherry']
Here, the lambda function takes each element w and returns its length, which sorted() uses to order the list. The same approach works with other higher-order functions that expect a callable argument.
numbers = [1, 2, 3, 4, 5, 6]
even = list(filter(lambda n: n % 2 == 0, numbers))
print(even) # [2, 4, 6]
Lambda vs. def
Every lambda can be written as a normal function using def. The difference is only in form, not in behaviour. The lambda style is concise but best reserved for very simple operations. For anything longer or more descriptive, a named function improves clarity.
# Using def
def cube(x):
return x ** 3
# Equivalent lambda
cube = lambda x: x ** 3
Lambdas are anonymous functions. They do not have a name unless assigned to one, and they do not support statements, annotations, or multiple expressions. This makes them ideal for quick, inline operations, not for complex logic.
lambda can make code difficult to read. If the expression grows beyond a single simple idea, use a regular def function instead. Readability should always take priority over brevity.
Closures in lambda
Like any other function, a lambda can form a closure by capturing values from the surrounding scope. This makes it a concise way to create small, customised functions dynamically.
def make_incrementer(step):
return lambda n: n + step
inc5 = make_incrementer(5)
print(inc5(10)) # 15
Here, the lambda expression remembers the value of step from the enclosing scope, just as an inner function would. This combination of conciseness and lexical scoping makes lambda expressions powerful tools for functional programming patterns.
In practice, lambda expressions should be used to express simple, self-contained operations. They work best when their intent is immediately clear, such as transforming data, defining quick sorting keys, or building small closures inline.
Type hints and annotations
Type hints let you describe the expected types of function parameters and return values without changing how the program runs. They were introduced in Python 3.5 as part of the typing system and are now a standard feature of modern Python development. Type hints are optional but strongly encouraged in professional codebases because they improve readability, make intent explicit, and enable static analysis tools to catch errors before runtime.
def add(x: int, y: int) -> int:
return x + y
In this example, x and y are expected to be integers, and the function should return an integer. The hints do not affect execution (Python does not enforce them) but tools such as mypy, pyright, and modern IDEs use them to provide intelligent feedback and error detection.
Annotating parameters and return values
Type hints are written after each parameter name, separated by a colon, and the return type is marked after an arrow (->) following the parameter list. Any valid Python expression can appear in an annotation, though most commonly these come from the typing module.
def greet(name: str, times: int = 1) -> None:
for _ in range(times):
print(f"Hello, {name}!")
Here, name is expected to be a string, times an integer with a default value, and the function returns nothing (None). The annotations are available at runtime through the function’s __annotations__ attribute:
print(greet.__annotations__)
# {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'NoneType'>}
Common types and collections
The typing module provides standard type objects for collections and generics, letting you describe the expected contents of lists, dictionaries, and other containers.
from typing import List, Dict, Tuple
def process(scores: List[int]) -> Dict[str, float]:
return {"average": sum(scores) / len(scores)}
def coordinate() -> Tuple[int, int]:
return (10, 20)
For newer versions of Python (3.9+), you can use the built-in generic syntax instead of importing from typing:
def process(scores: list[int]) -> dict[str, float]:
return {"average": sum(scores) / len(scores)}
Optional, Union, and Any
Sometimes a parameter may accept more than one type, or may be optional. The Union and Optional helpers express these cases clearly.
from typing import Union, Optional
def parse(value: Union[int, str]) -> str:
return str(value)
def read_name(name: Optional[str] = None) -> str:
return name or "Anonymous"
Union[int, str] means the argument can be either type. Optional[str] is shorthand for Union[str, None], showing that the value may be missing or set to None.
Callable and TypeVar
You can also specify functions as arguments using Callable, or define generic types using TypeVar. These are common when writing decorators or higher-order functions.
from typing import Callable, TypeVar
T = TypeVar("T")
def apply_twice(func: Callable[[T], T], value: T) -> T:
return func(func(value))
print(apply_twice(lambda n: n * 2, 3)) # 12
Here, apply_twice() works with any function that takes and returns the same type, demonstrating how type variables express general relationships between inputs and outputs.
Type checking and benefits
Although type hints are not enforced at runtime, they greatly enhance static checking, documentation, and tooling. Editors can autocomplete more accurately, catch mismatches early, and generate cleaner API documentation. They also make code easier for humans to read by showing what kind of data each function expects and produces.