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__"]
23 A Pythonic Object
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:
- Implement
__repr__and__str__for both developers and users. - Use
@propertyto expose read-only attributes. - Implement
__format__so your class works withformat()and f-strings. - Make a class hashable by implementing
__hash__and__eq__together. - Use
__slots__to save memory when you have many instances. - Choose between
@classmethodand@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:
__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__xhere so subclasses don’t accidentally clobber the field.@propertyexposesxandyas read-only attributes — the getter has no setter, sov.x = 99raisesAttributeError.__iter__makes the vector iterable. Once it exists,tuple(self)works, unpacking likex, y = vworks, and__repr__and__eq__can lean on it.__repr__usestype(self).__name__instead of hard-coding"Vector2d"— that way subclasses report their own type. The!rformat spec callsrepr()on each component, which matters once components are non-numeric.__abs__is whatabs(v)calls; we return the Euclidean length.__bool__is whatbool(v)andif 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)isTrue. 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:
@classmethodmakes the first parametercls— the class itself, not an instance. When you callVector2dV2.frombytes(b), Python passesVector2dV2in ascls.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 callingclsinstead of hard-codingVector2dV2, 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_specis the part after the colon inf"{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.2fgets applied to each float. The result of the comprehension is two pre-formatted strings, whichouter.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 == v2isTruebecause__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.
__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
V2dDictis 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.
@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 → ShortVector2d → Vector2d) 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.
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
Override
__str__. Replace thetupleform of__str__withf"{self.x:g}, {self.y:g}". What does that change inprint(v)?__bytes__and round-trip. Add a__bytes__method toVector2d(the snippet in the chapter shows the form). VerifyVector2d.frombytes(bytes(v)) == v.Drop
__hash__. Remove__hash__fromVector2dH. Try inserting an instance into a set. What error do you get? Why?__slots__and inheritance. SubclassVector2dSlotsand add a third slot. Comparesys.getsizeofto a class without__slots__.Custom
__format__codes. Add a"d"(degrees) format code that returns the angle in degrees. Makeformat(v, ".0fd")work.
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.