Five lessons of classes and dataclasses. Now the counter-lesson — when not to reach for one.
# Class version
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value = self.value + 1
c = Counter()
c.increment()
c.increment()
print(c.value) # 2Vs. the functional version:
# Functional version
value = 0
value = value + 1
value = value + 1
print(value) # 2The functional version is shorter for this specific case.
Right. A class earns its place when there's a shape with multiple fields, behaviour tied to the data, or multiple instances that need to be kept separate. A counter you increment in one place and read once is none of those things.
What's the rule of thumb?
Three signals that a class earns its place:
If none of those apply, plain functions and a tuple/dict/list are usually clearer. Classes have a cost — they spread logic across __init__ and methods, they need explicit state setup, they invite over-engineering. "Could be a function" should always be your first thought; only escalate to a class when you can name a specific reason.
Three signals. If at least two apply, a class is probably worth it. If none do, prefer functions.
# Repeated — class earns its place
items = [Item("a", 5), Item("b", 12), Item("c", 3)]
for item in items:
print(item.is_high())A list of items, each with the same name and value fields. Definitely worth a class — or @dataclass.
@dataclass
class Item:
name: str
value: int
def is_high(self) -> bool:
return self.value > 10The predicate is_high operates on the same data the class holds. Bundling them together — both in the class body — makes the relationship explicit.
Two instances with the same data can still be distinct things — separate inboxes, separate connections, separate sessions. When identity is part of the model, classes are the natural fit.
# Don't write this
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
# Do write this
value = 0
value += 1No shape (just one number). No behaviour bundled (the increment is one line). No identity (one counter only). Class is overkill.
# Don't reach for a class for this
class Config:
def __init__(self, host, port, timeout):
self.host = host
self.port = port
self.timeout = timeout
# This is fine
config = {"host": "localhost", "port": 8080, "timeout": 30}The dict is shorter, has no fixed schema beyond what's documented, and you'd serialise it as JSON anyway. Reach for @dataclass only if you need methods or type-checked fields.
# Don't write this
class Doubler:
def __init__(self, n):
self.n = n
def double(self):
return self.n * 2
result = Doubler(5).double()
# Write this
def double(n):
return n * 2
result = double(5)A pure function deserves a function, not a class.
When a class has only one method besides __init__, ask: "Could this just be a function?" Often yes.
Given a 1-class script with one method, refactor it back to a function. Compare line counts; print which version is simpler.
Five lessons of classes and dataclasses. Now the counter-lesson — when not to reach for one.
# Class version
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value = self.value + 1
c = Counter()
c.increment()
c.increment()
print(c.value) # 2Vs. the functional version:
# Functional version
value = 0
value = value + 1
value = value + 1
print(value) # 2The functional version is shorter for this specific case.
Right. A class earns its place when there's a shape with multiple fields, behaviour tied to the data, or multiple instances that need to be kept separate. A counter you increment in one place and read once is none of those things.
What's the rule of thumb?
Three signals that a class earns its place:
If none of those apply, plain functions and a tuple/dict/list are usually clearer. Classes have a cost — they spread logic across __init__ and methods, they need explicit state setup, they invite over-engineering. "Could be a function" should always be your first thought; only escalate to a class when you can name a specific reason.
Three signals. If at least two apply, a class is probably worth it. If none do, prefer functions.
# Repeated — class earns its place
items = [Item("a", 5), Item("b", 12), Item("c", 3)]
for item in items:
print(item.is_high())A list of items, each with the same name and value fields. Definitely worth a class — or @dataclass.
@dataclass
class Item:
name: str
value: int
def is_high(self) -> bool:
return self.value > 10The predicate is_high operates on the same data the class holds. Bundling them together — both in the class body — makes the relationship explicit.
Two instances with the same data can still be distinct things — separate inboxes, separate connections, separate sessions. When identity is part of the model, classes are the natural fit.
# Don't write this
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
# Do write this
value = 0
value += 1No shape (just one number). No behaviour bundled (the increment is one line). No identity (one counter only). Class is overkill.
# Don't reach for a class for this
class Config:
def __init__(self, host, port, timeout):
self.host = host
self.port = port
self.timeout = timeout
# This is fine
config = {"host": "localhost", "port": 8080, "timeout": 30}The dict is shorter, has no fixed schema beyond what's documented, and you'd serialise it as JSON anyway. Reach for @dataclass only if you need methods or type-checked fields.
# Don't write this
class Doubler:
def __init__(self, n):
self.n = n
def double(self):
return self.n * 2
result = Doubler(5).double()
# Write this
def double(n):
return n * 2
result = double(5)A pure function deserves a function, not a class.
When a class has only one method besides __init__, ask: "Could this just be a function?" Often yes.
Given a 1-class script with one method, refactor it back to a function. Compare line counts; print which version is simpler.
Create a free account to get started. Paid plans unlock all tracks.