Skip to main content

A Pythonic Object, part 2

Source: Fluent Python, 2nd Edition — Chapter 11 by Luciano Ramalho (formatted displays through class attributes)

Part 1 built a vector that can be printed, unpacked, and reconstructed. This half is about how it is displayed (__format__), whether it can live in a set (__hash__ plus immutability), positional match, name mangling, __slots__ inheritance, and using a class attribute as a default you can shadow.

f'{x:0.2f}', format(x, '0.2f'), and '{0:0.2f}'.format(x) all call __format__(format_spec). The specifier is only the text after the colon. The name or expression before the colon is a different language: field names for .format(), or any expression inside an f-string.

'{0.mass:5.3e}'
^^^^^^ ^^^^
field format spec (mini-language)

Study format(obj, spec) first. Then read formatted string literals and format-string syntax for {} fields, including !s, !r, and !a. F-strings do not retire str.format(): keep the template in a variable when the layout is not next to the values.

Built-in presentation codes​

Each type interprets format_spec itself. Integers understand b and x. Floats understand f and %. datetime reuses strftime codes inside __format__.

🧪 format codes and datetime

If a class never defines __format__, object.__format__ returns str(obj) when the spec is empty. A non-empty spec raises TypeError. A vector that already has __str__ still needs __format__ before format(v, '.3f') works.

Format each component​

A first __format__ can treat the user’s spec as “apply this to every float in the vector, then wrap with parentheses.”

def __format__(self, fmt_spec=''):
parts = (format(c, fmt_spec) for c in self)
return '({}, {})'.format(*parts)

That yields format(v, '.2f') → (3.00, 4.00) and format(v, '.3e') for scientific notation on each axis.

Quiz

What is the specifier?

1 / 1

In f'1 USD = {1 / brl:0.2f} BRL', the format specifier is

Pick an answer to see if you are right.

A custom code: polar p​

The mini-language is per-class. Integers already use bcdoxXn, floats eEfFgGn%, strings s. A letter unused by those types is easier for humans: p for polar.

If the spec ends with p, strip that letter, format magnitude (abs) and angle (math.atan2(y, x)) with the remainder of the spec, and wrap with <{}, {}>. Otherwise keep Cartesian (x, y).

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 = '({}, {})'
parts = (format(c, fmt_spec) for c in coords)
return outer.format(*parts)

Reusing a letter another type uses is not a syntax error (each class owns its spec), but it confuses readers. Pick unused letters when you can.

Hashable vectors need a stable identity​

hash(v) and set([v]) fail until __hash__ exists. Equal objects must share a hash, so mix the same fields __eq__ uses. XOR of component hashes is the usual small example:

def __hash__(self):
return hash(self.x) ^ hash(self.y)

Hashes are only honest if the value cannot change after it is used as a key. Store self.__x / self.__y, expose @property getters named x and y, iterate via those properties. Assignment v.x = 7 then raises AttributeError.

You can still hash a type that is technically mutable if you never mutate it. Properties are the teaching device, not a language requirement.

If the type has a natural scalar, you might add __int__, __float__, or __complex__. Mapping a 2-D vector to complex is a reasonable extra; this lesson does not require it.

Positional match needs __match_args__​

Keyword patterns such as case Vector2d(x=0, y=0): already work if those names are public attributes. A positional pattern case Vector2d(_, 0): fails with “accepts 0 positional sub-patterns” until the class lists order:

class Vector2d:
__match_args__ = ('x', 'y')

You do not have to list every public field. Required constructor arguments are the usual tuple; optional extras can stay keyword-only in patterns.

Private names are mangled, not locked​

Python has no Java-style private. Two leading underscores (and at most one trailing underscore) trigger name mangling: Dog.__mood is stored as _Dog__mood, Beagle.__mood as _Beagle__mood. Subclasses do not accidentally overwrite the parent’s internal name.

That is a safety cover, not a lock. Anyone can read v._Vector2d__x or assign it. Production code that pokes mangled names should not be surprised when the class breaks.

Many Pythonistas prefer a single underscore: self._x. The interpreter does not mangle it. The community treats it as “do not touch from outside.” Some docs call this “protected”; the word is informal.

Vector2d components are “private” and instances are “immutable” only in scare quotes. There is no real privacy and no real freeze.

Quiz

Name mangling

1 / 2

After Vector2d stores self.__x, v.__dict__ typically contains

Pick an answer to see if you are right.

__slots__: cheaper storage, inherited halfway​

By default each instance has a __dict__. A class attribute __slots__ = ('x', 'y') (set when the class is created) stores those names in a compact layout instead. Effects:

  • No __dict__ unless you put '__dict__' in the slot list
  • Setting an unknown name raises AttributeError
  • Subclasses without their own __slots__ get a __dict__ again; slot names from the parent still live in the compact layout, extras go in the dict
  • Empty __slots__ = () on the subclass: no dict, only parent slot names
  • Extra names: declare them in the subclass __slots__ (remember the comma in ('color',))
  • '__dict__' in slots allows dynamic attributes and @cached_property, and can erase the memory win
  • '__weakref__' is needed if you both use slots and need weak references

Millions of numeric records still belong in NumPy, not a slotted Python class. Slots are a tool for when you really have many small objects of one shape.

🧪 slots inheritance

Class attributes as defaults you can shadow​

typecode = 'd' on the class is the default self.typecode because instances do not store that name. Writing v.typecode = 'f' creates an instance attribute and leaves Vector2d.typecode as 'd'. Byte dumps then use 4-byte 'f' floats for that one object.

To change the default for a whole family, assign on the class, or — clearer — subclass:

class ShortVector2d(Vector2d):
typecode = 'f'

Django class-based views do this a lot: a tiny subclass whose only job is a different class attribute.

That is why __repr__ should use type(self).__name__ instead of a hardcoded 'Vector2d'. ShortVector2d then shows the right constructor name without copying __repr__.

You only implement these dunders when the application or the library audience needs them. End users of a GUI do not care. Other Python programmers using your types will try format, hash, match, and subclasses.