SythraOpen app

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().

Sythra

10 min read

XLinkedIn
Classes and Functions in Python (Pure Functions vs. Modifiers) — cover illustration

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 = 30

Nothing 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:30

And 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 total

This 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:00

10: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 total

This 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   += 1

After 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 midnight

Time 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 time

divmod 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 True

Call 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 if that 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 assert to validate untrusted user input instead of raise 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.

Browse all python explainers →