Skip to content

04 - Functions

What this session is

About an hour. You'll learn how to define your own functions, default arguments, keyword arguments, return values, and a glimpse of type hints - Python's optional way to declare what types your functions expect and return. By the end you can break programs into named pieces.

The problem functions solve

So far every program has been a single block of statements. That works for tiny programs. Once you get past 30-40 lines, it stops working - you can't see structure, you can't reuse anything, and you can't test pieces in isolation.

A function is a named, reusable block of code that takes some input and (usually) returns some output.

The shape

def name(parameters):
    # body
    return something

Concrete:

def double(x):
    return x * 2

print(double(5))   # 10
print(double(7))   # 14

Type and run.

Walk through:

  • def double(x): - defines a function called double with one parameter x. The colon ends the signature; the indented body follows.
  • return x * 2 - compute x * 2 and send it back to whoever called the function.
  • double(5) - call the function. The result is 10.

def is short for "define." The function exists after the def runs, ready to be called.

Multiple parameters

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

print(add(3, 4))     # 7

Parameters are separated by commas.

Default values

A parameter can have a default - used when the caller doesn't supply one:

def greet(name, greeting="hello"):
    return f"{greeting}, {name}"

print(greet("Alice"))                  # hello, Alice
print(greet("Alice", "hi"))            # hi, Alice
print(greet("Alice", greeting="hey"))  # hey, Alice

The third call uses a keyword argument - naming the parameter explicitly. Useful when a function has many parameters; the call site is self-documenting.

Default values must come after any non-default parameters: def f(a, b=2): is fine; def f(a=1, b): is a syntax error.

The trap

Don't use mutable defaults like def f(items=[]):. The list is created once when the function is defined and shared between all calls - modifying it changes the default for all future callers. Universal advice: use def f(items=None): if items is None: items = [] instead. This is the classic Python beginner bug.

Functions that don't return anything

If you don't write a return, the function returns None (Python's "nothing" value):

def say_hi(name):
    print(f"Hi, {name}")

result = say_hi("Alice")    # prints, but returns nothing
print(result)               # None

Type hints (optional, increasingly standard)

Python lets you annotate the types of parameters and return values. These are hints - Python doesn't enforce them at runtime - but tools (mypy, pyright, IDE inspections) catch type bugs before you run.

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

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

Reading the syntax: - x: int - parameter x is an int. - -> int after the parameter list - the function returns an int.

You can use type hints from the start (recommended) or never (also fine - old code rarely has them). Modern Python projects use them. We'll use them lightly in this path; you'll get used to seeing them.

Returning multiple values

In some languages (Go) functions can return multiple values. In Python they "can't" - but they can return a tuple (an ordered group), which the caller can unpack:

def divide(a: int, b: int) -> tuple[int, int]:
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide(17, 5)
print(f"quotient: {q}, remainder: {r}")    # quotient: 3, remainder: 2

The return quotient, remainder actually creates a 2-tuple (quotient, remainder). The q, r = ... on the receiving end unpacks it back into two variables. You'll see this pattern often.

Functions calling functions

def square(x: int) -> int:
    return x * x

def sum_of_squares(a: int, b: int) -> int:
    return square(a) + square(b)

print(sum_of_squares(3, 4))   # 9 + 16 = 25

Functions calling functions is how programs get built up - small named pieces, composed.

Why functions matter

  1. Naming. double(7) reads better than re-typing 7 * 2, especially when the operation is more complex.
  2. Reuse. Write once, call many times.
  3. Testing. You can test double by itself, separately from the rest of the program (page 10).
  4. Structure. Reading a 500-line script is awful. Reading 20 small named functions tells you what the program does at a glance.

Variables inside vs outside

A variable created inside a function exists only inside that function. The technical word is scope.

def double(x):
    result = x * 2
    return result

print(result)   # ERROR - `result` doesn't exist out here

Each function has its own world. Get information in via parameters; get information out via return values.

(There's a way to share variables across functions called "global" state - generally avoided. Pass values explicitly.)

Exercise

In a new file iseven.py:

  1. Write a function is_even(n: int) -> bool that returns True if n is even, False otherwise. Use the % operator.

  2. Print is_even(4) and is_even(7). You should see True and False.

  3. Write a function count_evens(max: int) -> int that counts even numbers in 1, 2, ..., max. Use a for loop and call is_even.

  4. Print count_evens(10). Expected: 5.

  5. Print count_evens(100). Expected: 50.

  6. Now write a function greet(name, greeting="hello") with a default argument. Call it as greet("Alice") and greet("Alice", "hey").

What you might wonder

"What's *args and **kwargs?" A way to accept any number of positional or keyword arguments. def f(*args, **kwargs): - args becomes a tuple of all extra positional args; kwargs becomes a dict of extra named args. You'll see them constantly in framework code. For your own functions, prefer named parameters when you know what you're accepting.

"Are type hints required?" No. Old code rarely has them. New code increasingly does. They help tools catch bugs and make code self-documenting. Use them when you can.

"What if I don't write a return?" The function returns None. Calling f() and assigning the result gives you None.

"Can a function call itself?" Yes - that's recursion. A useful tool for certain problems (tree traversal, divide-and-conquer). We'll meet a use case later.

"Why is the mutable-default thing a trap?" Because of when the default value is created. Python evaluates default expressions once, when def runs. The list survives across calls; every call modifying it sees changes from previous calls. It's surprising. Use None as the default and create the list inside.

Done

You can now: - Define your own functions with def. - Use default and keyword arguments. - Optionally add type hints. - Return zero, one, or many values (via tuple unpacking). - Call functions from other functions. - Avoid the mutable-default trap.

You now have all the fundamentals: variables, types, control flow, functions. Every Python program is built from these. The next pages extend the toolkit: classes (your own types), collections (lists/dicts/sets), errors, iterators, files, tests, and packages.

Next: Classes →

Comments