Skip to main content

Week 4: Functions

🧮 You already know f(x)f(x). This week you learn to write your own ff.

🎯 Learning objectives

By the end of this week you can:

  • Define a function with def, parameters, and a return value.
  • Explain the difference between a parameter's default value and an argument passed at call time.
  • Reason about variable scope: what a function can and can't see from outside itself.
  • Return more than one value from a function, and document what a function does with a docstring.
  • Recognize recursion, and know when it mirrors a mathematical definition naturally.

Lesson

Functions as f(x)f(x)

You already read f(x)=x2+1f(x) = x^2 + 1 as "a rule that takes a number and returns another number." A Python function is exactly that:

def f(x):
return x**2 + 1

f(3) # 10
f(0) # 1

def names the function and its parameters; the body computes a result; return sends that result back to the caller. A function with no return statement implicitly returns None.

It's good practice to document what a function does with a docstring — a string literal right after the def line, which tools (and other programmers, including future you) can read without opening the function's body:

def f(x):
"""Return x squared, plus one."""
return x**2 + 1

Multiple parameters, default values

Functions can take several inputs, some with defaults — the equivalent of f(x,y)=x+yf(x, y) = x + y but where yy has an assumed value if the caller doesn't supply one:

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

greet("Amina") # "Hello, Amina!"
greet("Youssef", "Hi") # "Hi, Youssef!"
greet(name="Sara", greeting="Hey") # keyword arguments — order doesn't matter

Parameters with a default must come after parameters without one — def greet(greeting="Hello", name): is a SyntaxError, since Python needs to know which arguments are required before it can figure out which are optional.

Returning more than one value

A function can only return one thing, but that "one thing" can be a tuple — and Python's tuple-unpacking syntax (from Week 3) makes this read almost like returning several values directly:

def min_and_max(numbers):
return min(numbers), max(numbers) # this builds a tuple: (min, max)

lowest, highest = min_and_max([4, 8, 1, 9, 3])
print(lowest, highest) # 1 9

This is the same pattern you'll use throughout the rest of the course whenever a computation naturally produces more than one related result.

Scope: what a function can see

A variable created inside a function only exists inside it — this is local scope:

def compute():
total = 42
return total

compute()
print(total) # NameError: total only existed inside compute()

A function can read variables defined outside it (global scope), but reassigning a global from inside a function without special syntax creates a new local variable instead — a common source of confusion:

counter = 0

def increment():
counter = counter + 1 # UnboundLocalError! Python sees the assignment
# and treats counter as local *throughout* the function,
# including on the read before the assignment.

The fix (global counter) exists but is rarely the right design — prefer passing values in and returning results out, which is also easier to test and reason about.

Functions calling functions

Because a function is just a value like any other, functions can call other functions, building up complexity from small pieces — the same way h(x)=g(f(x))h(x) = g(f(x)) composes two functions:

def double(x):
return x * 2

def add_one(x):
return x + 1

def double_then_add_one(x):
return add_one(double(x))

double_then_add_one(3) # double(3)=6, add_one(6)=7

Recursion: a function calling itself

Some functions are most naturally defined in terms of themselves — exactly like a recurrence relation. Factorial, n!=n(n1)!n! = n \cdot (n-1)! with the base case 0!=10! = 1, translates almost word-for-word:

def factorial(n):
if n == 0:
return 1 # base case — stops the recursion
return n * factorial(n - 1) # recursive case

factorial(5) # 5 * factorial(4) = 5 * 4 * factorial(3) = ... = 120

Every recursive function needs a base case that doesn't call itself (otherwise it recurses forever, eventually crashing with a RecursionError) and a recursive case that gets closer to that base case with each call — here, n - 1 shrinks toward 0 every time. Anything recursion can do, a loop can also do (and often more efficiently, since each recursive call has some overhead) — but for definitions that are already naturally recursive, like factorial or Week 5's CSV-processing pipeline, the recursive version can be the clearer one to read.

⚠️ Common pitfalls

  • Forgetting return. A function that computes a value but never returns it gives back Noneresult = add(2, 3) silently becomes None if add forgot its return statement, and the bug often doesn't surface until much later when you try to use result.
  • Using a mutable default argument. def add_item(item, items=[]): looks reasonable, but that default list is created once, when the function is defined, and reused across every call that doesn't supply its own items — items from one call can leak into the next. The fix: default to None and create a fresh list inside the function if needed.
  • No base case in a recursive function. Forgetting the if n == 0: return 1 in factorial means every call recurses again, without end, until Python gives up with a RecursionError: maximum recursion depth exceeded.
  • Shadowing a built-in name. Naming your own function sum or list works, but then hides Python's real sum()/list() for the rest of that file — a confusing bug to track down later.

🧩 Challenges

Write a function is_even(n) that returns True if n is even, False otherwise.

Write a function average(numbers) that takes a list of numbers and returns their mean. What happens if you call it with an empty list?

Turn last week's "is this number prime?" logic into a function is_prime(n), then use it inside a list comprehension to build a list of all primes below 50.

Write a function f(x, y=1) that returns x + y. Call it once with only x and once with both arguments, and explain why the two results differ.

Write a recursive function fibonacci(n) that returns the nth Fibonacci number, using the definition F(0)=0F(0) = 0, F(1)=1F(1) = 1, F(n)=F(n1)+F(n2)F(n) = F(n-1) + F(n-2).

Write a function stats(numbers) that returns three values at once — the minimum, maximum, and average — and call it using tuple unpacking to capture all three in separate variables.

🤔 Socratic Questions

  • Two functions both use a variable named total internally. Do they interfere with each other? Why or why not, given what you learned about scope?
  • Why does return immediately exit a function, even if there's more code after it? Try writing a function with unreachable code after a return and see what the playground does with it.
  • double_then_add_one composes double and add_one. Could you write a general compose(f, g) function that returns a new function combining any two functions? (You don't need classes for this — a function can return another function.)
  • Your recursive factorial and an iterative version using a for loop compute the same result. Try timing both on a large input (e.g. factorial(900)) — do you notice any difference? What do you think is happening at each recursive call that a loop iteration doesn't need to do?
  • The mutable-default-argument pitfall happens because a default value is created once, at function-definition time, not fresh on every call. Why might Python have been designed this way, rather than re-creating the default value every single call (which would avoid the gotcha but cost a little more work every time)?

✅ Weekly quiz

✅ Weekly quiz

1. What does a function return if it has no explicit return statement?
2. In def greet(name, greeting="Hello"), what is "Hello"?
3. A variable created inside a function is, by default:
4. What does greet(name="Sara") use to pass the argument?
5. Every recursive function must have a:

🎁 Bonus: handling errors with try/except

Available once you pass this week's quiz.