Classes and Functions in Python (Pure Functions vs. Modifiers)
A pure function returns a new object without touching its inputs, while a modifier changes the object it receives directly. Planned development — reframing a Time object as a base-60 number — replaces messy overflow-checking code with a few clean lines using divmod().
In Classes and Objects, you built objects — you created a Point, gave it an x and a y, and passed it around. That was the first step: knowing how to hold data inside a custom type.
This article asks a bigger question: what do you do with those objects? How do you write functions that work with them — and write those functions well? To explore this, we introduce a new class called Time, which stores a time of day, and use it to teach two big ideas: pure functions vs. modifiers, and prototype and patch vs. planned development. Along the way you'll meet invariants — the idea of keeping your objects honest.
What you will learn
- How to build functions that read and combine custom objects
- The difference between a pure function and a modifier
- Why "prototype and patch" tends to produce fragile, special-case code
- How reframing a problem (planned development) can make it dramatically simpler
- How to protect an object's invariants with
assert
The Time class
Imagine a cinema app that needs to track when a movie starts, how long it runs, and when it ends. For that you need a way to represent a time of day:
class Time:
"""Represents the time of day.
attributes: hour, minute, second
"""
time = Time()
time.hour = 11
time.minute = 59
time.second = 30Nothing new here — same pattern as Point. This Time object represents 11:59:30, thirty seconds before noon. Here's a function to print it nicely, using the format specifier %02d to pad single digits with a leading zero:
def print_time(t):
print(f'{t.hour:02d}:{t.minute:02d}:{t.second:02d}')
print_time(time) # 11:59:30And a function to compare two times without writing a single if — Python compares tuples element by element, hour first, then minute, then second:
def is_after(t1, t2):
return (t1.hour, t1.minute, t1.second) > (t2.hour, t2.minute, t2.second)Pure functions
Suppose you want to add two Time objects together — start time plus duration equals end time:
def add_time(t1, t2):
total = Time()
total.hour = t1.hour + t2.hour
total.minute = t1.minute + t2.minute
total.second = t1.second + t2.second
return totalThis function is polite. It does not touch t1 or t2 at all — it just reads their values and builds a brand new Time object, which it returns. That is the definition of a pure function: it takes inputs in, sends a result out, and never modifies anything outside itself.
But test it on a 9:45 start and a 1:35 duration:
start = Time()
start.hour = 9; start.minute = 45; start.second = 0
duration = Time()
duration.hour = 1; duration.minute = 35; duration.second = 0
done = add_time(start, duration)
print_time(done)
# → 10:80:0010:80:00 — there is no such time. The function added 45 and 35 minutes and got 80, without checking whether minutes exceeded 59. We need to carry, just like adding 45 + 35 on paper:
def add_time(t1, t2):
total = Time()
total.hour = t1.hour + t2.hour
total.minute = t1.minute + t2.minute
total.second = t1.second + t2.second
if total.second >= 60:
total.second -= 60
total.minute += 1
if total.minute >= 60:
total.minute -= 60
total.hour += 1
return totalThis works — but it's getting bulky, and it still only handles one overflow at a time. Add a 90-second duration and it would need to carry twice, growing again. This feeling of patching one bug and immediately worrying about the next is exactly what's called prototype and patch — a warning sign that a better approach exists.
Modifiers
A modifier is the other kind of function — one that changes the object you give it. Instead of politely building a new one, it reaches into the object you handed over and rewrites its contents:
def increment(time, seconds):
time.second += seconds
if time.second >= 60:
time.second -= 60
time.minute += 1
if time.minute >= 60:
time.minute -= 60
time.hour += 1After calling increment(start, 30), start itself has changed — no new object was created. The function returns nothing because it does not need to; the change already happened to the thing you gave it.
There's also a hidden flaw: if seconds is 90, second might still be >= 60 after a single carry, since the if only fires once. You could swap if for while, but that's still patching. There's a much smarter fix.
Prototype and patch vs. planned development
Writing the obvious thing, testing it, and patching whatever broke is prototype and patch. It's fast to start, but it has a nasty habit of producing complicated, fragile code full of special cases. The alternative is planned development: step back and ask, "what is this thing, really?"
Here's the insight: a time of day is just a number. 11 hours, 59 minutes, 30 seconds is really:
11 × 3600 + 59 × 60 + 30 = 43170 seconds since midnightTime is base-60 arithmetic — hours, minutes, and seconds are just columns in a base-60 number system, the way ones, tens, and hundreds are columns in base-10. Once you see that, convert Time to a plain integer, do normal arithmetic (which Python already knows how to do perfectly), and convert back:
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def int_to_time(seconds):
time = Time()
minutes, time.second = divmod(seconds, 60)
time.hour, time.minute = divmod(minutes, 60)
return timedivmod is a built-in that divides the first argument by the second and returns both quotient and remainder as a tuple — divmod(130, 60) gives (2, 10). It handles the carrying for you, without any if or while. Now add_time becomes:
def add_time(t1, t2):
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)Three lines. No if statements, no overflow checks, no special cases — and it handles any possible input correctly, not just the easy ones.
Invariants and valid_time
An invariant is a condition that should always be true about an object — a contract it makes with the world. For a Time object: hour is non-negative, minute and second are each between 0 and 59. If violated, the object is lying — it claims to be a time, but describes a moment that does not exist.
def valid_time(time):
if time.hour < 0 or time.minute < 0 or time.second < 0:
return False
if time.minute >= 60 or time.second >= 60:
return False
return TrueCall this at the top of your functions to catch problems early — before they silently corrupt something further down the line. Even better, use Python's assert statement:
def add_time(t1, t2):
assert valid_time(t1) and valid_time(t2)
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)assert says: this must be true, or crash immediately with an AssertionError. It makes your assumptions visible inside the code itself, instead of a comment that says "time must be valid here."
Common mistakes
- Writing overflow checks with a single
ifthat only fires once, missing multi-step carries - Confusing a pure function (returns a new object) with a modifier (changes the original)
- Not realizing a modifier's changes persist through aliases pointing to the same object
- Reaching for
assertto validate untrusted user input instead ofraise ValueError
Common questions
What is a pure function in Python?
A pure function takes inputs, computes a result, and returns it without modifying the objects it was given or causing any side effects. This makes pure functions easy to test and safe to call anywhere.
What is a modifier function in Python?
A modifier is a function that changes the object passed into it directly, rather than returning a new object. Because objects are passed by reference, the caller's original object reflects the change after the function returns.
What does divmod() do in Python?
divmod(a, b) returns a tuple of (a // b, a % b) — the quotient and remainder of dividing a by b. It's useful for converting a total (like seconds) into components (like minutes and leftover seconds) without manual overflow checks.
What is an invariant in programming?
An invariant is a condition that should always hold true for an object, such as a Time object's minute always being between 0 and 59. Checking invariants — often with assert — catches corrupted data early, before it causes confusing bugs elsewhere.
When should I use assert instead of raise ValueError?
Use assert to catch programmer errors — bugs in your own code that should never happen if the code is correct. Use raise ValueError (or similar) to validate untrusted external input, like user-provided data, since assert statements can be disabled with Python's -O flag.
Explore
Related topics
Keep going — these sit next to this concept in a real learning path.
Classes and Objects in Python
Defining classes and attributes — the foundation this builds on.
Classes and Methods in Python (self, __init__, Operator Overloading)
self, __init__, __str__, and operator overloading.
Functions in Python in Depth: The Complete Guide
The full picture of parameters, returns, and scope.
Python course hub
All free Python explainers and the path into Agentic practice.