Functions: Naming a Process 🧩
Everything so far has been one long strip of instructions. Functions let you cut that strip into named pieces, and that is what makes big programs possible at all.
Defining and calling
def greet():
print("Welcome to the Scumm Bar.")
print("Mind the grog.")
greet()
greet()
Welcome to the Scumm Bar.
Mind the grog.
Welcome to the Scumm Bar.
Mind the grog.
Three parts:
defmeans "define". You are describing a process, not doing it yet.- The name, the brackets and a colon.
- An indented block: the body.
Defining a function runs nothing. The body only executes when you call it, by
writing its name with brackets. Forgetting the brackets is a classic: greet
is the function itself, greet() is the act of running it.
Arguments: information going in
def greet(name):
print(f"Welcome, {name}.")
greet("Guybrush")
greet("Elaine")
Welcome, Guybrush.
Welcome, Elaine.
def describe(name, role, insults):
print(f"{name} the {role} knows {insults} insults.")
describe("Guybrush", "pirate", 8)
describe(role="governor", name="Elaine", insults=3)
Guybrush the pirate knows 8 insults.
Elaine the governor knows 3 insults.
Passing by position is the default. Passing by name (keyword arguments) works in any
order and makes the call site far easier to read, especially when there are booleans
involved: send(urgent=True) beats send(True) every time.
return: information coming out
def double(n):
return n * 2
result = double(21)
print(result)
print(double(double(5)))
42
20
This is the most common confusion of Level 2. print shows something to a human and produces nothing. return hands a value back to the code that called the function. A function that prints cannot be used in a calculation; a function that returns can be used anywhere.
def add_printing(a, b):
print(a + b)
def add_returning(a, b):
return a + b
x = add_printing(2, 3)
y = add_returning(2, 3)
print(f"x is {x}")
print(f"y is {y}")
print(add_returning(1, 1) + add_returning(2, 2))
5
x is None
y is 5
6
add_printing returned None, because a function with no
return returns None. That None then poisons
whatever you do with it. Rule of thumb: functions should return values and let
the caller decide about printing. It makes them testable, reusable, and
combinable.
return exits immediately
def check_age(age):
if age < 0:
return "That is not an age."
if age < 18:
return "Too young."
return "Welcome."
print(check_age(-5))
print(check_age(12))
print(check_age(30))
That is not an age.
Too young.
Welcome.
The moment a return runs, the function is over: nothing after it executes.
This "early return" style flattens what would otherwise be nested if/else
pyramids, and it is generally considered better than one exit point at the bottom.
Docstrings: telling the next person
def split_bill(total, people, tip_rate=0.15):
"""Work out what each person owes, including tip.
Args:
total: the bill before tip
people: how many are splitting it
tip_rate: as a fraction, so 0.15 means 15 percent
Returns:
The amount each person owes, as a float.
"""
return (total * (1 + tip_rate)) / people
print(f"{split_bill(87.50, 4):.2f}")
print(split_bill.__doc__.splitlines()[0])
help(split_bill)
25.16
Work out what each person owes, including tip.
Help on function split_bill in module __main__:
split_bill(total, people, tip_rate=0.15)
Work out what each person owes, including tip.
Args:
total: the bill before tip
people: how many are splitting it
tip_rate: as a fraction, so 0.15 means 15 percent
Returns:
The amount each person owes, as a float.
A string as the first line of a function body becomes its documentation. It is not a
comment: Python keeps it, help() prints it, your editor shows it on hover,
and documentation tools generate whole websites from it. Write one for anything that is
not instantly obvious.
Why functions actually matter
def celsius_to_fahrenheit(c):
return c * 9 / 5 + 32
for city, temp in [("Melee", 28), ("Booty", 31), ("Blood", 19)]:
print(f"{city:8} {temp}C = {celsius_to_fahrenheit(temp):.1f}F")
Melee 28C = 82.4F
Booty 31C = 87.8F
Blood 19C = 66.2F
Four reasons, in order of importance:
- Naming.
celsius_to_fahrenheit(t)says what it is.t * 9 / 5 + 32makes the reader do the work every time. - One place to fix. A bug in the formula gets fixed once, not in nine scattered copies, one of which you will miss.
- Testability. You can check a function in isolation (Lesson 29). You cannot easily test line 47 of a long script.
- Thinking. A named function is a concept you can hold in your head as one thing, which is how you fit a big program into a small skull.
This is the whole trick of software, and it never stops working. You cannot hold ten thousand lines in your mind. You can hold twenty names. Each name hides a hundred lines you have already stopped worrying about.
Every abstraction you will ever meet, functions, classes, modules, packages, services, is this same move performed at a larger scale.
A worked example: refactoring
# Before: one long strip, hard to follow, impossible to test
crew = [{"name": "Guybrush", "pay": 100}, {"name": "Otis", "pay": 40}]
total = 0
for m in crew:
total += m["pay"]
avg = total / len(crew)
print(f"Total {total}, average {avg:.1f}")
for m in crew:
if m["pay"] < avg:
print(f"{m['name']} is paid below average")
Total 140, average 70.0
Otis is paid below average
def total_pay(crew):
"""Sum everyone's pay."""
return sum(member["pay"] for member in crew)
def average_pay(crew):
"""Mean pay across the crew."""
return total_pay(crew) / len(crew)
def below_average(crew):
"""Names of everyone paid less than the mean."""
threshold = average_pay(crew)
return [m["name"] for m in crew if m["pay"] < threshold]
crew = [{"name": "Guybrush", "pay": 100}, {"name": "Otis", "pay": 40}]
print(f"Total {total_pay(crew)}, average {average_pay(crew):.1f}")
for name in below_average(crew):
print(f"{name} is paid below average")
Total 140, average 70.0
Otis is paid below average
Longer, and much better. Each piece has a name, does one thing, and can be tested on its own. The last four lines now read like a description of the task rather than an implementation of it.
Temperature converter
Write two functions that convert both ways, and prove they round-trip.
Reveal solution
def c_to_f(celsius):
"""Convert Celsius to Fahrenheit."""
return celsius * 9 / 5 + 32
def f_to_c(fahrenheit):
"""Convert Fahrenheit to Celsius."""
return (fahrenheit - 32) * 5 / 9
print(c_to_f(100))
print(f_to_c(212))
print(f_to_c(c_to_f(37)))
212.0
100.0
37.0Is it a palindrome?
Write is_palindrome(text) that returns True or False, ignoring case, spaces and punctuation. Test it on several phrases.
Reveal solution
def is_palindrome(text):
"""True if text reads the same backwards, ignoring case and punctuation."""
cleaned = "".join(c.lower() for c in text if c.isalnum())
return cleaned == cleaned[::-1]
for phrase in ["Never odd or even", "A man, a plan, a canal: Panama", "Monkey Island"]:
print(f"{is_palindrome(phrase)!s:6} {phrase}")
True Never odd or even
True A man, a plan, a canal: Panama
False Monkey Island.isalnum() is True for letters and digits only, which strips punctuation and spaces in one go. {{value!s:6}} converts to a string first so the width padding applies.
Grade calculator
Write grade(score) returning a letter, and a second function that takes a dictionary of names to scores and prints a report sorted by score. Neither function should print anything except the report one.
Reveal solution
def grade(score):
"""Convert a percentage to a letter grade."""
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"
def report(scores):
"""Print every student, best first, with their letter grade."""
for name, score in sorted(scores.items(), key=lambda pair: pair[1], reverse=True):
print(f"{name:10} {score:3} {grade(score)}")
report({"Guybrush": 95, "Otis": 58, "Elaine": 82, "Meathook": 71})
Guybrush 95 A
Elaine 82 B
Meathook 71 C
Otis 58 Fgrade is a pure function: same input, same output, no side effects. Those are the easiest things in the world to test and to trust.