Blog

When a Class Earns Its Place in Python

Most Python codebases contain classes that should have been functions. They have one method, no state worth protecting, and exist because someone learned that object-oriented code means classes.

So this series starts with the argument against writing one.

The function wearing a costume

# a class with one method and no state is a function wearing a costume
class TaxCalculator:
    def __init__(self, rate):
        self.rate = rate
    def calculate(self, amount):
        return amount * self.rate

calc = TaxCalculator(0.2)
print(calc.calculate(100))

def calculate_tax(amount, rate=0.2):
    return amount * rate

print(calculate_tax(100))

It prints:

20.0
20.0

Same answer. The function is one line, has no construction step, and can be tested without instantiating anything. The class bought you nothing.

This is the most common unnecessary class there is: it holds configuration and has one method that uses it. In Python that is a function with a default argument.

When data is just data

If you are only grouping values that travel together, a dict or a namedtuple says so more clearly than a class:

# a dict is enough when the data is just data
point = {'x': 3, 'y': 4}
print(point['x'], point['y'])

from collections import namedtuple
Point = namedtuple('Point', 'x y')
p = Point(3, 4)
print(p, p.x, p.x + p.y)

It prints:

3 4
Point(x=3, y=4) 3 7

The namedtuple gives you attribute access, a readable repr and immutability for one line. Part 8 of this series compares all four options properly.

The test that actually works

Not “is this a noun”. Nouns are everywhere and most of them are dicts.

The test is: is there an invalid state that the class can prevent?

# state that changes together is what a class is for
class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
        self.history = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError(f"deposit must be positive, got {amount!r}")
        self.balance += amount
        self.history.append(('deposit', amount))

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError(f"balance is {self.balance}, cannot withdraw {amount}")
        self.balance -= amount
        self.history.append(('withdraw', amount))

acct = BankAccount(100)
acct.deposit(50)
acct.withdraw(30)
print(acct.balance, acct.history)

It prints:

120 [('deposit', 50), ('withdraw', 30)]

Here the class earns its place. balance and history must change together — a deposit that updates one and not the other is a bug, and there is no way to write that bug from outside.

Watch it refuse:

acct2 = BankAccount(10)
try:
    acct2.withdraw(999)
except ValueError as err:
    print(type(err).__name__ + ':', err)
print('balance untouched:', acct2.balance)

It prints:

ValueError: balance is 10, cannot withdraw 999
balance untouched: 10

A dict would have let that through and left you with a negative balance and no history entry.

What to remember

  • One method and no state means it wants to be a function.

  • Values that travel together want a dict, a namedtuple or a dataclass.

  • A class earns its place when two or more pieces of state must change together, and it can refuse to be put in an invalid state.

  • “Is it a noun” is not the test. “Can it prevent a bug” is.

If you cannot name an invalid state your class rules out, you have written a namespace with extra steps. That is not always wrong — but it should be a decision, not a reflex.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.