Week 4: Functions
🎯 Learning objectives
By the end of this week you can:
- Define a function with
def, parameters, and areturnvalue. - 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
You already read 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 but where 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 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, with the base case , 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 neverreturns it gives backNone—result = add(2, 3)silently becomesNoneifaddforgot itsreturnstatement, and the bug often doesn't surface until much later when you try to useresult. - 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 ownitems— items from one call can leak into the next. The fix: default toNoneand create a fresh list inside the function if needed. - No base case in a recursive function. Forgetting the
if n == 0: return 1infactorialmeans every call recurses again, without end, until Python gives up with aRecursionError: maximum recursion depth exceeded. - Shadowing a built-in name. Naming your own function
sumorlistworks, but then hides Python's realsum()/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 , , .
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
totalinternally. Do they interfere with each other? Why or why not, given what you learned about scope? - Why does
returnimmediately exit a function, even if there's more code after it? Try writing a function with unreachable code after areturnand see what the playground does with it. double_then_add_onecomposesdoubleandadd_one. Could you write a generalcompose(f, g)function that returns a new function combining any two functions? (You don't needclasses for this — a function can return another function.)- Your recursive
factorialand an iterative version using aforloop 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
🎁 Bonus: handling errors with try/except
Available once you pass this week's quiz.