⇤ home

a good class - part 1

note: this is part 1 in a series of posts about oop. part 2.


consider how humans, or any other autonomous creatures of nature, operate with their model of the world. we all have our own model of the world contained in our own heads, i.e. we have a copy of the world state for our own use. we mutate the state in our heads based on inputs (events/messages) we receive via our senses. as we process these inputs and apply them to our model we may take action that produces outputs, which others can take as their own inputs. none of us reach directly into each other’s heads and mess with the neurons. if we did this it would be a serious breach of encapsulation! originally, object oriented (oo) design was all about message passing, and somehow along the way we bastardized the message passing to be method calls and even allowed direct field manipulation – yuck! whose bright idea was it to allow public access to fields of an object? you deserve your own special hell.

mechanical sympathy

i recently watched good videos about object-oriented programming:

bottom line: most classes are bad.

so… how do you make a good class?

i propose you check for these 3 things:

let’s explain.

dataclass + slots

first of all, please use dataclasses.

there’s zero (0) reason not to use them: you reduce the boilerplate, you improve readability, you type attributes…

and you don’t lose anything except footguns.

from dataclasses import dataclass

@dataclass
class Rectangle:
    width: float
    height: float

the next step is activating slots.

this will really boost performance (because of c shenanigans) at the cost of not being able to add attributes at runtime, which you shouldn’t do anyway.

@dataclass(slots=True)
class Rectangle:
    width: float
    height: float

next is the property check.

@property

a class is just grouped data, in other words data that belongs together.

if this data reeaaaally belongs together, you should be able to compute something out of it.

in other words, you should have a property:

@dataclass(slots=True)
class Rectangle:
    width: float
    height: float

    @property
    def area(self):
        return self.width * self.height

no property is a bad tell, ask yourself why you grouped this data in the first place. maybe your class isn’t really useful.

the only exception where it’s ok to not have a property is if your class is frozen, which we’re gonna see in the next chapter.

frozen or read()

alright, now you have to make a choice.

either you freeze your dataclass, because you wanted immutability on the data, for example, config parameters of a script:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class AppConfig:
    host: str
    port: int
    debug: bool = False

    @property
    def url(self):
        return f"http://{self.host}:{self.port}"

and that’s ok. this is a perfectly acceptable class. thanks for coming to my ted talk.


oh, i know what you’re going to say:

“but that’s not why i make classes! i want to model entities and modify their attributes!”

i know.

you want to make a class “monster” to make little monsters in your game, give them weapons, armor, attack damage, reduce their hp when they take a hit, apply status effects…

look, the only status effect you’re gonna get is “poisoned” when your state is all over the place. you’re gonna die from oop.

please reread the citation at the beginning.

if your class isn’t frozen, its attributes should only be changed by this class.

the way you’d do this is with a read() method:

@dataclass(slots=True)
class Rectangle:
    width: float
    height: float

    @property
    def area(self):
        return self.width * self.height

    def read(self, message):
        """message receiver: decodes message and updates state"""
        msg_type = message.get('type')
        
        if msg_type == 'resize':
            self.width = message.get('width', self.width)
            self.height = message.get('height', self.height)
        elif msg_type == 'scale':
            factor = message.get('factor', 1.0)
            self.width *= factor
            self.height *= factor
        elif msg_type == 'set_width':
            self.width = message.get('width')
        elif msg_type == 'set_height':
            self.height = message.get('height')
        else:
            raise ValueError(f"unknown message type: {msg_type}")

nb: people sometimes advocate for the syntax _method() as a pythonic way to indicate a “private” method. i think it looks awful, and because it doesn’t actually enforce anything, you’d be better off with nice comments in plain english.

of course, you can also write a send() method if you want your class to emit messages.

but, in a nutshell, this is how you do proper oop.

red flags

to conclude, some red flags. if you see:

burn the code to the ground and rewrite functional.


next up, you’ll see why everything you just read is bullshit.