Module 4 · Functions & Modularity ⏱ 18 min

Functions & Parameters

By the end of this lesson you will be able to:
  • Define functions with positional, keyword, and default arguments
  • Explain how local scope isolates a function's variables from the rest of your program
  • Recognise and fix the mutable default argument trap

Up to now your programs have run top to bottom, once. That works until you need the same logic in three places — so you copy it, and later you fix a bug in one copy and forget the other two.

A function is a named, reusable block of code. You define it once with def, then call it as often as you like. Every serious program is mostly functions calling other functions.

Functions buy you three things:

  • Reuse — write the logic once, call it everywhere.
  • Namingcalculate_tax(salary) says what it does; twelve lines of arithmetic do not.
  • Isolation — a function's variables belong to it alone, so it can't quietly corrupt the rest of your program.

That third one is the subtle one, and it's where most beginner confusion lives. We'll come back to it.

flowchart LR
  A["Caller<br/>result = double(5)"] -->|"argument 5"| B["def double(n)"]
  B --> C["body runs<br/>n * 2"]
  C -->|"return 10"| D["result = 10"]
  style B fill:#3776ab,color:#fff
  style C fill:#1e293b,color:#fff
A call hands arguments in, the body runs, and return hands one value back out.

The anatomy of a definition

Every definition has the same shape: the keyword def, the name you'll call it by, a parameter list in parentheses, and a colon.

def double(n):
    return n * 2

The indented lines below the header are the body. The body only executes when the function is called, never when it is defined. Writing a def is like writing down a recipe; calling it is cooking.

Two words get used loosely but mean different things. n is the parameter — the placeholder name in the definition. The 5 in double(5) is the argument — the actual value you handed over. Keeping those straight makes error messages far easier to read later on.

Define once, call many times. Press Run.
def double(n):
    return n * 2

# Defining it printed nothing. Calling it does the work.
print(double(5))
print(double(21))
print(double(double(3)))

Arguments: positional, keyword, and default

Python gives you three ways to pass values in, and real code uses all three.

Positional arguments are matched by order — power(2, 3) sets base=2 and exponent=3. Order is everything.

Keyword arguments are matched by name — power(exponent=3, base=2) does the same job, and order stops mattering. Reach for these when a call would otherwise be a row of mystery numbers.

Default values make a parameter optional. def power(base, exponent=2) lets callers write power(5) and get squaring for free, or override it with power(5, 3).

One rule trips everyone up once: parameters with defaults must come after parameters without them. def f(a=1, b) is a syntax error, because Python would have no way to know what a lone argument meant.

One function, called four different ways.
def power(base, exponent=2):
    return base ** exponent

print(power(5))                      # default exponent
print(power(5, 3))                   # positional
print(power(base=5, exponent=3))     # keyword
print(power(exponent=3, base=5))     # keyword, any order
flowchart TD
  subgraph G["Global scope"]
    X["total = 100"]
    subgraph L["Local scope of change()"]
      Y["total = 120<br/>a different variable"]
    end
  end
  L -.->|"can READ outer names"| X
  style Y fill:#b45309,color:#fff
  style X fill:#3776ab,color:#fff
Assigning inside a function creates a local variable; the global of the same name is untouched.

Scope: a function's variables are its own

When a function runs, Python hands it a private workspace called the local scope. Names you assign in the body live there and are thrown away when the call ends.

This is what stops a function from clobbering your program's state. Suppose a global total exists and your function assigns total = 120. Python does not overwrite the global — it builds a brand-new local that shadows it for the duration of the call.

Reading is more permissive than writing: a function may read an outer name it never assigned. That asymmetry surprises people, so lean on a simple habit — take what you need as parameters, hand results back with return. A function that touches only its parameters and its return value is one you can test, move, and reason about on its own.

return versus print

These look interchangeable in a tutorial and are nothing alike.

print writes text to the screen for a human. return hands a value back to the calling code, which can then store it, add to it, or pass it along.

A function with no return still gives something back: None. That's the source of a classic bug — the function displayed the right answer, so it looked correct, but the value it handed over was None, and None + 1 raises a TypeError.

Rule of thumb: anything that computes should return. Save print for the outermost layer that actually talks to the user.

Two functions that look alike and behave completely differently.
def show_double(n):
    print(n * 2)      # displays, hands back None

def give_double(n):
    return n * 2      # hands the value back

a = show_double(5)    # displays 10 as a side effect
b = give_double(5)    # displays nothing

print("show_double gave back:", a)
print("give_double gave back:", b)
print("and we can keep computing:", b + 1)
Exercise

Write greet(name, greeting="Hello") that returns the string "<greeting>, <name>!" — so greet("Ada") gives "Hello, Ada!". It must return, not print.

def greet(name, greeting="Hello"):
    # your code here
    pass
Exercise

Scope check. Read it carefully and predict both lines before you check.

x = 10

def change():
    x = 20
    print(x)

change()
print(x)
Exercise

This is meant to start an empty basket whenever it's called with one argument. It doesn't — the second call comes back with two items. Repair it with the None idiom from the callout.

def add_item(item, basket=[]):
    basket.append(item)
    return basket
Exercise

Functions are values too — you can hand one to another function. Write apply_twice(fn, value) that calls fn on value, then calls fn again on that result, and returns it. So apply_twice(double, 3) means double(double(3)).

def apply_twice(fn, value):
    # your code here
    pass

Recap

  • def name(params): defines a function; the body only runs when you call it.
  • Arguments travel positionally or by keyword; defaults make a parameter optional and must come last.
  • Assigning a name in the body creates a local variable — outer state is safe from it.
  • return hands a value to the caller; without one you get None.
  • Defaults are built once at definition time — never use a mutable one like [] or {}.

Next you'll combine functions into something real, and the isolation you just met is exactly what makes those pieces safe to snap together.

Checkpoint quiz

What does a function return if it runs to the end without a return statement?

Why is def add(item, basket=[]) dangerous?

Go deeper — technical resources