Chapter Five

Functions

Learning Objectives
  1. Define functions using def and explain why functions are central to writing maintainable code
  2. Distinguish between parameters and arguments, and apply default, keyword, and variadic arguments
  3. Explain Python's scope rules using the LEGB model and predict which variable a name refers to
  4. Use functions as first-class objects by assigning them to variables and passing them as arguments
  5. Write lambda expressions and describe when they are appropriate

Every program of any consequence eventually does the same thing more than once. You validate user input, format a date, compute a total, send a request — and then you need to do it again, somewhere else, with slightly different data. Without some way to package and reuse code, you would copy and paste the same logic over and over, and when you needed to fix a bug, you would fix it in one place and forget the other seven. Functions are Python's primary mechanism for avoiding this fate. They let you name a block of code, give it inputs, and call it whenever you need it — turning repetition into reuse and chaos into structure.

Defining a Function

You create a function with the def keyword, followed by a name, a pair of parentheses, and a colon. The indented block below is the function body.

def greet():
    print("Hello, world!")

greet()       # Output: Hello, world!
greet()       # Output: Hello, world!

The function does not run when you define it. It runs when you call it — by writing its name followed by parentheses. You can call a function as many times as you like, from anywhere in your program that can see the name.

Function names follow the same rules as variable names: lowercase, words separated by underscores. Good function names are verbs or verb phrases — calculate_tax, send_email, validate_input — because functions do things.

Parameters and Arguments

Most functions need input. You declare inputs by listing parameters inside the parentheses of the def statement. When you call the function, you pass arguments — the actual values that fill in those parameters.

def greet(name):
    print(f"Hello, {name}!")

greet("Alice")    # Output: Hello, Alice!
greet("Bob")      # Output: Hello, Bob!

The distinction is simple but worth being precise about: name is a parameter (part of the definition); "Alice" is an argument (part of the call). In practice, most people use the words interchangeably, and that is fine — but when documentation says "pass an argument," it means supply a value at call time.

Functions can have multiple parameters, separated by commas:

def add(a, b):
    return a + b

result = add(3, 7)
print(result)     # Output: 10

Return Values

A function communicates its result back to the caller with the return statement:

def square(n):
    return n * n

area = square(5)
print(area)       # Output: 25

return does two things: it sends a value back and it exits the function immediately. Any code after return in the same block will not execute.

If a function has no return statement — or reaches the end without hitting one — it returns None implicitly:

def say_hello():
    print("Hello!")

result = say_hello()    # Output: Hello!
print(result)           # Output: None

You can return multiple values by separating them with commas. Python packs them into a tuple:

def divide(a, b):
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide(17, 5)
print(q, r)         # Output: 3 2

This is one of Python's most pleasant features. Languages without it force you to return a single value or create a special object to hold multiple results. Python just lets you return them.

Default and Keyword Arguments

Parameters can have default values, making them optional at call time:

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Alice")                # Output: Hello, Alice!
greet("Alice", "Good morning") # Output: Good morning, Alice!

Parameters with defaults must come after parameters without them. This rule prevents ambiguity about which argument goes where.

When a function has several parameters, calling it with positional arguments can get confusing. Keyword arguments let you name the parameter you are filling:

def create_user(name, age, role="viewer"):
    print(f"{name}, age {age}, role: {role}")

create_user(name="Alice", age=30, role="admin")
create_user(age=25, name="Bob")    # order does not matter with keywords

You can mix positional and keyword arguments, but positional arguments must come first.

*args and **kwargs

Sometimes you do not know in advance how many arguments a function will receive. The *args syntax collects extra positional arguments into a tuple:

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

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

Similarly, **kwargs collects extra keyword arguments into a dictionary:

def print_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=30, city="London")
# Output:
# name: Alice
# age: 30
# city: London

The names args and kwargs are conventions, not requirements — you could write *values or **options. But sticking with the convention makes your code immediately recognisable to other Python developers.

Docstrings

A docstring is a string literal placed as the very first statement in a function body. It documents what the function does:

def factorial(n):
    """Return the factorial of a non-negative integer n."""
    if n == 0:
        return 1
    return n * factorial(n - 1)

Docstrings are not comments. Python stores them as an attribute of the function object, and you can access them with the built-in help() function:

help(factorial)

This prints the function name, its parameters, and the docstring. Every function you intend someone else to call — including your future self — should have a docstring. It takes ten seconds to write and saves minutes of confusion later.

Scope and the LEGB Rule

When you use a variable name inside a function, Python needs to figure out which object that name refers to. It does this by searching four namespaces in order, known as the LEGB rule:

  1. Local — names defined inside the current function.
  2. Enclosing — names in the local scope of any enclosing function (relevant for nested functions).
  3. Global — names defined at the top level of the module.
  4. Built-in — names pre-defined by Python itself (print, len, range).
x = "global"

def outer():
    x = "enclosing"
    
    def inner():
        x = "local"
        print(x)       # Output: local
    
    inner()
    print(x)           # Output: enclosing

outer()
print(x)               # Output: global

Each function creates its own local namespace. Assignments inside a function create local variables by default — they do not touch the global variable of the same name. If you need to modify a global variable from inside a function, you can declare it with the global keyword, but this is generally a sign that your design needs rethinking. Functions that rely on global state are harder to test, harder to reason about, and harder to reuse.

Functions as First-Class Objects

In Python, functions are first-class objects. This means they are values, just like integers and strings. You can assign a function to a variable, store it in a list, and pass it as an argument to another function.

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

yell = shout              # yell now refers to the same function
print(yell("hello"))      # Output: HELLO

Passing functions as arguments is a powerful technique:

def apply(func, value):
    return func(value)

print(apply(len, "hello"))        # Output: 5
print(apply(str.upper, "hello"))  # Output: HELLO

Python's built-in sorted(), map(), and filter() all accept functions as arguments. This style — passing behaviour as data — is the foundation of functional programming, and Python supports it comfortably.

Lambda Expressions

A lambda expression creates a small anonymous function in a single expression:

square = lambda x: x * x
print(square(5))          # Output: 25

Lambdas are limited to a single expression — no statements, no multiple lines, no assignments. They are most useful as quick throwaway functions passed to other functions:

names = ["Alice", "bob", "Charlie"]
sorted_names = sorted(names, key=lambda s: s.lower())
print(sorted_names)       # Output: ['Alice', 'bob', 'Charlie']

If your lambda needs more than one line of logic, write a regular function with def instead. Lambdas are meant to be small and obvious; the moment they become complicated, they defeat their own purpose.

Closures

When a nested function references a variable from its enclosing function, and the enclosing function has returned, the inner function retains access to that variable. This combination of a function and its captured environment is called a closure.

def make_multiplier(factor):
    def multiply(n):
        return n * factor
    return multiply

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

print(double(5))    # Output: 10
print(triple(5))    # Output: 15

make_multiplier returns a function that remembers the value of factor even after make_multiplier has finished executing. Each call creates a new closure with its own captured value. Closures are the mechanism behind decorators, factory functions, and many callback patterns — all topics we will revisit in later chapters.

Functions are where programming stops being about writing instructions and starts being about designing solutions. A well-chosen function with a clear name, a focused purpose, and a clean interface is worth more than a hundred lines of clever inline code. The discipline of asking "what should this function do, and what should it not do?" is, in many ways, the discipline of programming itself.