Part 1 made each tree smaller. This part makes fewer objects instead.
You only need this when two things are true: you have a lot of objects, and a big part of each one is the same as in many others. A million trees with three species qualify. A thousand trees don’t, and neither do a million trees that are all different.
The problem
In a game, a tree also needs to look like something. Say the forest now comes from a file, and every tree has a species with a colour and a texture:
x,y,age,species,r,g,b
12,40,7,oak,34,139,34
13,41,9,birch,240,240,220
...
class Tree:
__slots__ = ("x", "y", "age", "species", "colour", "texture")
def load(row):
x, y, age, species, r, g, b = row.split(",")
return Tree(int(x), int(y), int(age), species,
(int(r), int(g), int(b)), load_texture(species))
load_texture stands in for reading an image file and returns 256 new bytes
on each call. There are only three species, but parsing makes a new string, a
new tuple and a new texture for every row. A million trees carry a million
copies of three things:
| one tree, 3.13 | bytes |
|---|---|
Tree (6 slots) | 80 |
species string | 45 |
colour tuple | 64 |
texture bytes | 289 |
| total | 478 |
That’s 478 MB for the forest, and about 400 MB of it is duplicated species data.
(x, y and age are small ints, which CPython already shares, as Part 1
explained.)
The flyweight
Split each tree’s data in two:
- intrinsic: the same for every tree of a species (name, colour, texture).
Store it once, in a
TreeType, and share it. - extrinsic: different for every tree (
x,y,age). Keep it on the tree, next to a pointer to itsTreeType.
A factory function makes sure each species gets exactly one TreeType:
from dataclasses import dataclass
from functools import cache
@dataclass(frozen=True, slots=True)
class TreeType:
name: str
colour: tuple
texture: bytes
@cache
def tree_type(name, colour):
return TreeType(name, colour, load_texture(name))
class Tree:
__slots__ = ("x", "y", "age", "type")
# __init__ sets the four slots
def load(row):
x, y, age, species, r, g, b = row.split(",")
return Tree(int(x), int(y), int(age),
tree_type(species, (int(r), int(g), int(b))))
@cache keeps a dict keyed on the arguments, so each species is built once
and every later call returns the same object:
Tree ─┐
Tree ─┼──▶ TreeType("oak", (34, 139, 34), <256 B>)
Tree ─┘
Tree ─┬──▶ TreeType("birch", (240, 240, 220), <256 B>)
Tree ─┘
>>> tree_type.cache_info()
CacheInfo(hits=999997, misses=3, maxsize=None, currsize=3)
A tree is now 64 B, down from 478: the forest takes 64 MB instead of 478,
7.5× less. That’s only 8 B more than Part 1’s three-slot tree. The 64 B
already includes the shared part: the three TreeTypes and the cache take
about 1.8 KB in total, under 0.002 B per tree.
Two things keep it working:
frozen=True. A shared object must never change:tree.type.colour = ...would recolour every oak in the forest. Frozen dataclasses raise on assignment.- One way in. Callers must never build a
TreeTypethemselves; all of them go throughtree_type(), or the sharing silently stops.
Where you’ll find it
- Games. This forest is the textbook case. Thousands of trees share one mesh and one set of textures, and each tree stores only its position, size and rotation. GPUs support it directly: instanced rendering draws one mesh many times in a single call, given just the per-tree data. Tile maps work the same way, with each cell pointing at a shared terrain type such as grass or water.
- Text editors. The original example in Design Patterns: one glyph object per letter of the alphabet, not per letter in the document. Where a letter sits comes from the text around it.
- pandas.
astype("category")stores each distinct value once, plus a small integer code per row. - CPython. Small ints from -5 to 256, interned strings,
None,TrueandFalseexist once each and are shared by every use.
Also worth knowing
lru_cache(maxsize=1024) for when the set of types is open-ended. @cache
keeps every type forever; an LRU cache keeps the most recent ones and drops
the rest. A dropped type isn’t freed while trees still use it, so it costs
nothing to get wrong, only some sharing.
weakref.WeakValueDictionary for when types should go away once no tree
uses them. It holds the types without keeping them alive. One catch, straight
from Part 1: a slots class has no __weakref__ slot, so it can’t be weakly
referenced.
>>> weakref.ref(tree_type("oak", (34, 139, 34)))
Traceback (most recent call last):
...
TypeError: cannot create weak reference to 'TreeType' object
Python 3.11+ fixes that with @dataclass(frozen=True, slots=True, weakref_slot=True).
sys.intern is a flyweight for strings. If your trees really did need
their own species string, sys.intern(species) would make every "oak" the
same object.
References
- The flyweight pattern, from Design Patterns (Gamma et al., 1994)
- The Flyweight chapter of Game Programming Patterns (Robert Nystrom), which uses a forest too
functools.cachedataclasses:frozen,slots,weakref_slotweakref.WeakValueDictionary