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.13bytes
Tree (6 slots)80
species string45
colour tuple64
texture bytes289
total478

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 its TreeType.

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 TreeType themselves; all of them go through tree_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, True and False exist 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