Course progress Course outline 24 of 24 lessons available
Python Foundations
Data and Collections
Building Reliable Programs
Modeling with Objects
Professional Python
Advanced Python
Give each helper one job
Chapter 8 made a study report. A long recipe works, but small helpers are easier to understand and test. A function should have one clear job and a small contract: what goes in, what comes back, and what it changes.
def session_label(topic, minutes=30, *, excited=False):
label = f"{topic}: {minutes} minutes"
if excited:
return label.upper() + "!"
return label
print(session_label("Python"))
print(session_label("Git", minutes=45, excited=True))
Output:
Python: 30 minutes
GIT: 45 MINUTES!
topic is required, minutes has a default, and everything after * must be named. excited=True is clearer than a mysterious second True.
The contract is simple: accept a topic and minutes, return text, and change nothing outside the function. Returning data keeps the calculation separate from the visible side effect made by print().
Functions are values too. Chapter 8 passed minutes_in to sorted() as a key. Pass the function without (); parentheses would call it immediately.
A mutable default is a shared backpack
Python evaluates a default when it runs the def statement, not each time the function is called. A list can therefore carry old things into the next call.
- One backpack the default list is made
- Add Python the backpack keeps it
- Add Git Python is still inside
This example is intentionally wrong:
def collect(topic, topics=[]):
topics.append(topic)
return topics
print(collect("Python"))
print(collect("Git"))
Output:
['Python']
['Python', 'Git']
Use None as a “please make a fresh list” sign:
def collect(topic, topics=None):
if topics is None:
topics = []
topics.append(topic)
return topics
Now two omitted calls start independently. Edge reminder: a list supplied by the caller is still changed. If the contract promises no mutation, copy it first with topics = list(topics).
Look for names from the inside out
When Python sees a name, it searches LEGB: Local, Enclosing, Global, Built-in.
- Local this function call
- Enclosing an outer function
- Global the top of this file
- Built-in names such as len
course = "Python" # Global
def make_reader():
prefix = "Study" # Enclosing
def label(minutes):
suffix = "minutes" # Local
return f"{prefix} {course}: {minutes} {suffix}"
return label
reader = make_reader()
print(reader(40)) # print is Built-in
Output:
Study Python: 40 minutes
Assignment inside a function normally makes a local name. Avoid global just to update a total; accept the old total and return the new one. Hidden shared changes make tests depend on which call ran first.
A closure can remember one small thing
The returned reader above still remembers prefix. A function bundled with remembered enclosing names is a closure. Use nonlocal only when the inner function must replace an enclosing value:
def make_counter(start=0):
total = start
def add(minutes):
nonlocal total
total += minutes
return total
return add
counter = make_counter(10)
print(counter(20))
print(counter(15))
Output:
30
45
Without nonlocal, total += minutes would try to use a new local total before it had a value. Closures are good for tiny private memory, not for hiding an entire program.
A pure transformation uses its inputs and returns a result without changing outside state. Printing, writing a file, and changing a caller’s list are side effects. Keep transformations in the middle and side effects at the edge whenever you can.
Build a tiny analysis pipeline
- Normalize make clean new records
- Analyze total and rank
- Format return report text
- Print one visible side effect
Save this complete project as study_pipeline.py:
def normalize_session(session):
return {
"topic": session["topic"].strip(),
"minutes": session["minutes"],
}
def minutes_by_topic(sessions):
totals = {}
for session in sessions:
topic = session["topic"]
totals[topic] = totals.get(topic, 0) + session["minutes"]
return totals
def minutes_in(item):
return item[1]
def analyze(sessions, target=60):
clean = [normalize_session(session) for session in sessions]
totals = minutes_by_topic(clean)
ranking = sorted(totals.items(), key=minutes_in, reverse=True)
total = 0
for session in clean:
total += session["minutes"]
return {"total": total, "met": total >= target, "ranking": ranking}
def format_report(analysis, *, heading="Study report"):
status = "met" if analysis["met"] else "not met"
lines = [heading, f"Total: {analysis['total']} minutes", f"Target: {status}"]
for number, (topic, minutes) in enumerate(analysis["ranking"], start=1):
lines.append(f"{number}. {topic}: {minutes} minutes")
return "\n".join(lines)
def run_pipeline(sessions, formatter=format_report, *, target=60):
return formatter(analyze(sessions, target=target))
sessions = [
{"topic": " Python ", "minutes": 45},
{"topic": "Git", "minutes": 40},
{"topic": "Python", "minutes": 30},
]
before = [session.copy() for session in sessions]
report = run_pipeline(sessions, target=100)
assert sessions == before
assert "Python: 75 minutes" in report
print("Checks passed.")
print(report)
Run python3 study_pipeline.py:
Checks passed.
Study report
Total: 115 minutes
Target: met
1. Python: 75 minutes
2. Git: 40 minutes
formatter receives a function value. All calculations return data; only the final two print() calls create visible side effects.
Three tiny missions
- Write
format_duration(minutes, *, compact=False)and return either45 minutesor45m. - Call the safe
collect()twice with no list and prove that the results are independent. - Start a closure at 100, add 20 and 5, and predict both answers before running it.
Ready for Chapter 10?
- I can state a function’s input, return value, and side effects.
- I know why a list should not usually be a default value.
- I can search for a name in LEGB order.
- I can explain what a closure remembers and what
nonlocalchanges. - I can keep calculation separate from printing.
- I ran the pipeline and its checks passed.
Next, you will protect these function contracts by rejecting bad input with precise exceptions and validation.