23  A Pythonic Object

NoteCore idea

Build a complete, idiomatic Python class by implementing just the right special methods. The Vector2d class in this chapter is the canonical example — it shows what a well-designed Python object looks like.

In this chapter you will learn to:

  1. Implement __repr__ and __str__ for both developers and users.
  2. Use @property to expose read-only attributes.
  3. Implement __format__ so your class works with format() and f-strings.
  4. Make a class hashable by implementing __hash__ and __eq__ together.
  5. Use __slots__ to save memory when you have many instances.
  6. Choose between @classmethod and @staticmethod (hint: usually @classmethod).

The chapter wires up the left column of this dispatch picture; the right column is what makes built-ins reach into your class:

flowchart LR
  repr["repr(v)"] --> r["__repr__"]
  str["str(v)"] --> s["__str__"]
  abs["abs(v)"] --> a["__abs__"]
  bool["bool(v)"] --> b["__bool__"]
  iter["iter(v)"] --> it["__iter__"]
  fmt["format(v, '.2f')"] --> f["__format__"]
  add["v1 + v2"] --> ad["__add__"]
  eq["v1 == v2"] --> e["__eq__"]
  hash["hash(v)"] --> h["__hash__"]

__add__ is implemented in Chapter 28; the rest are wired in this chapter.

23.1 Object representations

Python has two string-conversion dunders. __repr__ is for developers — it should be unambiguous and ideally eval()-able. __str__ is for users — readable. To see them work together, we’ll build a 2-D vector that integrates with repr(), str(), abs(), bool(), iteration, and == — the canonical “Pythonic object.”

import math

class Vector2d:
    typecode = "d"

    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def __iter__(self):
        return (i for i in (self.x, self.y))

    def __repr__(self):
        class_name = type(self).__name__
        return f"{class_name}({self.x!r}, {self.y!r})"

    def __str__(self):
        return str(tuple(self))

    def __abs__(self):
        return math.hypot(self.x, self.y)

    def __bool__(self):
        return bool(abs(self))

    def __eq__(self, other):
        return tuple(self) == tuple(other)
v = Vector2d(3, 4)
repr(v), str(v), abs(v), bool(v)
('Vector2d(3.0, 4.0)', '(3.0, 4.0)', 5.0, True)

Walking through the moving parts:

  • typecode = "d" is a class attribute — a shared default we’ll use later for byte-level serialization. Every instance reads this value unless it shadows it.
  • __x (double underscore prefix) triggers Python’s name mangling: outside the class, you access it as _Vector2d__x. The single _ convention says “private by convention”; the double __ says “private to this class, even subclasses see a different name.” We use __x here so subclasses don’t accidentally clobber the field.
  • @property exposes x and y as read-only attributes — the getter has no setter, so v.x = 99 raises AttributeError.
  • __iter__ makes the vector iterable. Once it exists, tuple(self) works, unpacking like x, y = v works, and __repr__ and __eq__ can lean on it.
  • __repr__ uses type(self).__name__ instead of hard-coding "Vector2d" — that way subclasses report their own type. The !r format spec calls repr() on each component, which matters once components are non-numeric.
  • __abs__ is what abs(v) calls; we return the Euclidean length. __bool__ is what bool(v) and if v: call — we make zero-length vectors falsy, mirroring how zero numbers are falsy.
  • __eq__ compares components by reducing both sides to tuples — quick and correct for any other iterable with the same components, but loose: Vector2d(3, 4) == (3, 4) is True. Tighter equality is exercise material.

The general shape: a Pythonic class implements the small handful of dunders that the built-ins (repr, str, abs, bool, iter, ==) reach into. Each one buys a piece of native-feeling integration.

23.2 classmethod versus staticmethod

A @classmethod receives the class as its first argument. The most common use is alternative constructors — you’ve serialized a vector to bytes and now want to read it back. __init__ only takes (x, y); bytes need their own factory. Rather than a free-standing function, we put it on the class as a @classmethod:

import array

class Vector2dV2(Vector2d):
    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)

# Round-trip through bytes
v = Vector2dV2(3, 4)
b = bytes([ord(v.typecode)]) + bytes(array.array(v.typecode, v))
Vector2dV2.frombytes(b)
Vector2dV2(3.0, 4.0)

Walking through frombytes:

  • @classmethod makes the first parameter cls — the class itself, not an instance. When you call Vector2dV2.frombytes(b), Python passes Vector2dV2 in as cls.
  • chr(octets[0]) reads the first byte as the typecode ("d" for double, "f" for float). The serialization format we’re parsing prepends the typecode so we know how to interpret the rest.
  • memoryview(octets[1:]).cast(typecode) reinterprets the remaining bytes as numeric components — no copy, just a view.
  • return cls(*memv) is the point. By calling cls instead of hard-coding Vector2dV2, the constructor returns an instance of whatever class was used to call it — so subclasses get their own type back automatically.

The general pattern: a @classmethod is the right home for an “alternative constructor” — from_string, from_dict, frombytes. A @staticmethod has no special first argument; it’s just a plain function that happens to live in the class namespace. Use @classmethod by default; reach for @staticmethod rarely.

__bytes__ is the dunder for bytes(v). Modern code typically serializes through json/pickle/struct instead — exercise 2 has you implement it.

23.3 Formatted displays

__format__ is what format(obj, spec) and f"{obj:spec}" call. The format spec is a string; you parse it however you like. We’ll add a custom p suffix that switches the vector to polar coordinates, and pass the rest of the spec straight through to the components — so all the usual numeric format codes (.2f, .3e, …) keep working.

class Vector2dF(Vector2d):
    def angle(self):
        return math.atan2(self.y, self.x)

    def __format__(self, fmt_spec=""):
        if fmt_spec.endswith("p"):
            fmt_spec = fmt_spec[:-1]
            coords = (abs(self), self.angle())
            outer = "<{}, {}>"
        else:
            coords = self
            outer = "({}, {})"
        components = (format(c, fmt_spec) for c in coords)
        return outer.format(*components)

v = Vector2dF(3, 4)
format(v, ".2f"), format(v, ".3ep"), f"{v:.1f}"
('(3.00, 4.00)', '<5.000e+00, 9.273e-01>', '(3.0, 4.0)')

Walking through __format__:

  • The argument fmt_spec is the part after the colon in f"{v:.2f}" — Python strips the colon and passes the rest. An empty spec means “use defaults.”
  • endswith("p") recognizes our custom polar code. We strip it, then build (magnitude, angle) instead of (x, y), and wrap them in angle brackets to flag the change visually.
  • For the default Cartesian path, we iterate self (which works because we defined __iter__ earlier) and use round parens.
  • format(c, fmt_spec) recursively formats each component — that’s how .2f gets applied to each float. The result of the comprehension is two pre-formatted strings, which outer.format(*components) slots into the wrapper.

The general pattern: __format__ lets your class participate in format() and f-strings. The convention is to add a custom suffix for class-specific behavior and pass the rest through to the components — that way you extend the format mini-language without breaking it.

23.4 A hashable Vector2d

To put a Vector2d in a set or use it as a dict key, two methods are required: __hash__ and __eq__. Both must be consistent — equal objects must have equal hashes — and both depend on attributes that don’t change.

class Vector2dH(Vector2d):
    def __hash__(self):
        return hash((self.x, self.y))

v1 = Vector2dH(3, 4)
v2 = Vector2dH(3.0, 4.0)
hash(v1) == hash(v2), v1 == v2, len({v1, v2})
(True, True, 1)

Walking through what happened:

  • hash((self.x, self.y)) builds a tuple of the components and asks the built-in tuple hash to compose them. Tuples already know how to hash their elements; we don’t have to invent a mixing function.
  • v1 == v2 is True because __eq__ on the base class reduces both sides to tuples and (3.0, 4.0) == (3.0, 4.0). Their hashes match because both tuples-of-floats hash identically.
  • {v1, v2} collapses to a single element. Sets first compare hashes (cheap), then == to confirm — and both agree.

The general pattern: hashing a tuple of the immutable components is the standard recipe. Compose the hashes of the parts, don’t try to mix them by hand.

Warning__hash__ and __eq__ go together

If you implement __eq__, Python sets __hash__ to None unless you also define it. This is by design — equal objects with different hashes would silently break set/dict membership. The rule: implement both, or neither.

23.5 Saving memory with __slots__

By default, every Python instance has a __dict__ — a hash table for its attributes. That’s flexible but expensive: ~200 bytes per instance even for two floats. __slots__ swaps the dict for a fixed-size array of attribute slots:

class Vector2dSlots:
    __slots__ = ("__x", "__y")
    typecode = "d"

    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self): return self.__x

    @property
    def y(self): return self.__y

import sys
class V2dDict:
    def __init__(self, x, y):
        self.x, self.y = x, y

sys.getsizeof(Vector2dSlots(3, 4)), sys.getsizeof(V2dDict(3, 4)) + sys.getsizeof(V2dDict(3, 4).__dict__)
(48, 336)

Walking through the comparison:

  • __slots__ = ("__x", "__y") declares the only attribute names this class will ever have. Python pre-allocates exactly those two slots and refuses to create a __dict__.
  • The control class V2dDict is the same data without slots. We measure both the instance and its __dict__ because the dict lives in a separate allocation — getsizeof(V2dDict(3, 4)) alone undercounts.
  • The numbers will vary by Python build, but the slotted class is dramatically smaller — typically 3–4× — because the per-instance dict is gone.

The general pattern: __slots__ is a memory-density tool. The cost is that instances can’t grow new attributes (v.z = 7 raises AttributeError), and subclasses must redeclare __slots__ to keep the saving. Reach for it only when you have millions of instances. For everything else, the dict is fine and the flexibility is worth the bytes.

NoteModern alternative: @dataclass(slots=True)

Python 3.10 added slots=True to @dataclass — it generates the __slots__ declaration for you, so you don’t write the manual recipe above. The full treatment is in Chapter 17.

23.6 Class attributes and overriding them

typecode = "d" on the class is a class attribute — a shared default. Reading v.typecode looks at the instance first, then falls back to the class:

v = Vector2d(1, 2)
v.typecode, Vector2d.typecode
('d', 'd')

Both reads return "d". The instance has no typecode of its own, so attribute lookup walks up to the class.

Assigning to v.typecode creates an instance attribute, shadowing the class one:

v.typecode = "f"
v.typecode, Vector2d.typecode
('f', 'd')

Now v.typecode finds the instance copy first and returns "f". Vector2d.typecode is untouched at "d" — only this one instance changed. The idiomatic way to change the default for a kind of vector (not just one instance) is to subclass:

class ShortVector2d(Vector2d):
    typecode = "f"

ShortVector2d(1, 2).typecode, Vector2d(1, 2).typecode
('f', 'd')

ShortVector2d declares its own class attribute. Lookup on a ShortVector2d instance walks the MRO (instance → ShortVector2dVector2d) and finds "f" first. The original Vector2d is unaffected.

The general rule: writing through an instance always creates an instance attribute. To change a default for the whole class, write to the class. To change it for a category, subclass.

TipWhy this matters

A well-designed Python class uses special methods to integrate with the language. It implements __repr__ for debugging, __str__ for users, __eq__ and __hash__ together (or neither), __bool__ for truthiness, __iter__ for unpacking. These are the contracts that make your class feel like a built-in.

23.7 Build: a Pythonic Money value class

Money is a textbook value object: immutable, hashable (so it can sit in a set or a dict key), printable two ways (developer + user), and formattable. We’ll build it incrementally, applying the chapter’s tools one at a time so the payoff of each is visible.

Step 1: data, name-mangled storage, __repr__ and __str__. Start with the bones: read-only amount and currency, exposed via properties; double-underscore storage so subclasses can’t accidentally clobber the fields:

class Money:
    def __init__(self, amount, currency="USD"):
        self.__amount = float(amount)
        self.__currency = currency

    @property
    def amount(self): return self.__amount

    @property
    def currency(self): return self.__currency

    def __repr__(self):
        return f"{type(self).__name__}({self.__amount!r}, {self.__currency!r})"

    def __str__(self):
        return f"{self.__currency} {self.__amount:.2f}"

m = Money(19.99, "EUR")
[repr(m), str(m), m.amount]
["Money(19.99, 'EUR')", 'EUR 19.99', 19.99]

__amount (with two underscores) triggers name-mangling: outside the class it’s _Money__amount. The properties are read-only — m.amount = 5 raises AttributeError, which is the right behaviour for a value object.

Step 2: equality + hash, plus __bool__. Implement __eq__ and __hash__ together (the rule from the chapter). The hash composes the components’ hashes — hash((amount, currency)). __bool__ makes zero-amount money falsy, mirroring how 0 is falsy:

class Money:
    def __init__(self, amount, currency="USD"):
        self.__amount = float(amount)
        self.__currency = currency

    @property
    def amount(self): return self.__amount
    @property
    def currency(self): return self.__currency

    def __repr__(self):
        return f"{type(self).__name__}({self.__amount!r}, {self.__currency!r})"

    def __str__(self):
        return f"{self.__currency} {self.__amount:.2f}"

    def __eq__(self, other):
        return (isinstance(other, Money)
                and self.__amount == other.__amount
                and self.__currency == other.__currency)

    def __hash__(self):
        return hash((self.__amount, self.__currency))

    def __bool__(self):
        return self.__amount != 0

a, b, c = Money(10, "USD"), Money(10, "USD"), Money(10, "GBP")
[a == b, a == c, len({a, b, c}), bool(Money(0)), bool(a)]
[True, False, 2, False, True]

a == b is True (same amount and currency); a == c is False because the currencies differ. The set {a, b, c} collapses to two elements — the equal ones merge. Because __hash__ and __eq__ agree, the set operation is fast (hash compare first, equality only on collisions).

Step 3: __format__ for custom display, __slots__ for memory. Add a c (compact) suffix to the format spec — f"{m:c}" gives "USD20" (no decimals, no space), f"{m:.2f}" falls through to the default "USD 10.00" form. Add __slots__ to drop the per-instance __dict__:

class Money:
    __slots__ = ("__amount", "__currency")

    def __init__(self, amount, currency="USD"):
        self.__amount = float(amount)
        self.__currency = currency

    @property
    def amount(self): return self.__amount
    @property
    def currency(self): return self.__currency

    def __repr__(self):
        return f"{type(self).__name__}({self.__amount!r}, {self.__currency!r})"

    def __str__(self):
        return f"{self.__currency} {self.__amount:.2f}"

    def __eq__(self, other):
        return (isinstance(other, Money)
                and self.__amount == other.__amount
                and self.__currency == other.__currency)

    def __hash__(self):
        return hash((self.__amount, self.__currency))

    def __bool__(self):
        return self.__amount != 0

    def __format__(self, spec):
        if spec.endswith("c"):                           # compact form
            spec = spec[:-1]
            return f"{self.__currency}{format(self.__amount, spec or '.0f')}"
        return f"{self.__currency} {format(self.__amount, spec or '.2f')}"

m = Money(20, "USD")
[f"{m}", f"{m:c}", f"{m:.4f}", f"{m:.0fc}"]
['USD 20.00', 'USD20', 'USD 20.0000', 'USD20']
m.region = "EU"                                          # __slots__ rejects unknown attrs
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[13], line 1
----> 1 m.region = "EU"                                          # __slots__ rejects unknown attrs

AttributeError: 'Money' object has no attribute 'region' and no __dict__ for setting new attributes

__format__(self, spec) runs whenever format(m, spec) or f"{m:spec}" is evaluated. We branch on the trailing c, strip it off, then forward the rest of the spec to format(self.__amount, ...) — the .4f and .0f work because the float responds to them. __slots__ = ("__amount", "__currency") (note: the slot names use the mangled form — Python rewrites __amount to the same name the storage gets) drops the per-instance dict; m.region = "EU" is rejected at runtime.

The build is the chapter at full strength: a dozen lines of constructor and properties, four dunders for output and comparison (__repr__, __str__, __eq__, __hash__), one for truthiness (__bool__), one for formatting (__format__), and __slots__ on top. Sixty lines of code; behaves like a built-in.

23.8 Exercises

  1. Override __str__. Replace the tuple form of __str__ with f"{self.x:g}, {self.y:g}". What does that change in print(v)?

  2. __bytes__ and round-trip. Add a __bytes__ method to Vector2d (the snippet in the chapter shows the form). Verify Vector2d.frombytes(bytes(v)) == v.

  3. Drop __hash__. Remove __hash__ from Vector2dH. Try inserting an instance into a set. What error do you get? Why?

  4. __slots__ and inheritance. Subclass Vector2dSlots and add a third slot. Compare sys.getsizeof to a class without __slots__.

  5. Custom __format__ codes. Add a "d" (degrees) format code that returns the angle in degrees. Make format(v, ".0fd") work.

NoteFurther reading

Beazley, Python Distilled §7.8–7.9 (“Avoiding Inheritance via Composition” / “Avoiding Inheritance via Functions”) gives concrete anti-patterns and dispatch examples that complement Ramalho’s narrative on object design. It’s the operational companion to this chapter.

23.9 Summary

A well-designed Python class earns its place by implementing the right small contracts: __repr__, __str__, properties, __format__, __hash__/__eq__, optional __slots__. None of these is mandatory, but each one buys you integration with a part of the language — repr(), str(), attribute access, format(), sets, memory layout.

Next, Chapter 24 extends the same approach to a multi-dimensional Vector and shows how protocols make a class behave like a built-in sequence.