Some programs need enormous numbers of fine-grained objects: the characters in a document, the tiles in a game map, the strings in a compiler’s symbol table. The Flyweight pattern supports them by sharing. Instead of many objects, you keep one object per distinct value and reference it many times.
Two ideas make sharing work.
First, split each object’s state in two. Intrinsic state belongs to the value and is identical across every use, so it can live in the shared object. Extrinsic state varies per use, so it must live outside, supplied by the context. Second, route construction through a factory that returns the already-existing instance for a given value.
Handing out one object under many names is only safe when nobody can change it, so a flyweight must be immutable (see Rethinking Objects).
CPython flyweights its most common values.
It creates small integers once and shares them:
# small_integer_flyweights.py
low, low2 = int("256"), int("256")
high, high2 = int("100000"), int("100000")
print(low is low2, high is high2)
#: True FalseBoth int("256") calls return the same cached
object, while each int("100000") call builds a
fresh one. The int("...") spelling is load-bearing.
Write the literals directly, low, low2 = 256, 256,
and the demonstration silently breaks: the compiler pools equal
constants within a code object, so even
100000 is 100000 prints True, sharing
that comes from constant folding rather than from the integer
cache. Parsing the value out of a string at runtime defeats the
compiler’s pooling and leaves only the cache to explain any
sharing. (Python warns about is on a literal for
exactly this reason: the compiler makes the answer
misleading.)
String interning keeps one copy of identifier-like
strings. sys.intern() gives you the string pool
directly:
# string_interning.py
from sys import intern
joined = "".join(["fly", "weight"])
joined2 = "".join(["fly", "weight"])
print(joined == joined2, joined is joined2)
#: True False
print(intern(joined) is intern(joined2))
#: TrueThe two join() calls build equal but distinct
strings, and intern() maps both to one shared copy.
Interned strings make comparison cheap. Equal means identical,
so == collapses to a pointer check.
The small-integer cache and string interning are CPython implementation details, not language guarantees. Do not write code that depends on them, but notice the technique.
A map can hold millions of cells, but only a handful of tile kinds. Here, we’ll limit it to grass, water, and rock.
The tile’s symbol, name, and walkability are intrinsic, so they go in a frozen data class.
The tile’s position is extrinsic. It is the cell’s
coordinates in the grid, so the Tile object never
stores it.
The factory pairs functools.cache with a
constructor function, the same building block behind Singleton’s
cached factory. There the function took no arguments, so caching
produced one shared instance overall. Here tile()
takes a symbol, so caching produces one shared instance per
distinct symbol instead.
# tile_map.py
from dataclasses import dataclass
from functools import cache
from typing import Final, Literal
type Symbol = Literal[".", "~", "#"]
type TileSpec = tuple[str, bool]
@dataclass(frozen=True)
class Tile:
symbol: Symbol
name: str
walkable: bool
SPECS: Final[dict[Symbol, TileSpec]] = {
".": ("grass", True),
"~": ("water", False),
"#": ("rock", False),
}
@cache
def tile(symbol: Symbol) -> Tile:
name, walkable = SPECS[symbol]
return Tile(symbol, name, walkable)
def to_symbol(char: str) -> Symbol:
if char not in SPECS:
raise KeyError(char)
return char
def parse_map(text: str) -> list[list[Tile]]:
return [[tile(to_symbol(s)) for s in line]
for line in text.split()]
if __name__ == "__main__":
field = parse_map("""
..~~..
..~~.#
......
##..~~
""")
cells = [t for row in field for t in row]
print(len(cells), len({id(t) for t in cells}))
print(field[0][2] is field[3][5])
#: 24 3
#: TrueTwenty-four cells, three objects. The grid can grow to any
size and the object count stays at the number of tile kinds,
because @cache returns the same Tile
for the same symbol every time. A cell’s position never needs
storing. Asking “is the cell at row 1, column 5 walkable?” is
field[1][5].walkable, with the coordinates supplied
by the asker. That is the intrinsic/extrinsic split doing its
work.
Symbol names the closed set of valid map
characters, so Tile.symbol and SPECS
can only hold one of them. Add a kind to SPECS
without adding it to Symbol, or the reverse, and
the checker rejects the mismatch. tile() trusts its
argument is already a Symbol, so the untrusted
boundary is to_symbol(), the one place raw text
meets the checked type. It checks membership in
SPECS at runtime and raises a KeyError
if the character is not there. The checker reads that guard.
SPECS has key type Symbol, so
char not in SPECS failing to raise an exception
leaves char narrowed to Symbol on the
line below, and return char satisfies the declared
return type with nothing added. This is narrowing doing the work
a cast() would otherwise do by assertion (see Static
Typing). Prefer a guard the checker can read. Keep
cast() for the cases where no guard exists, because
cast() is believed rather than verified.
# test_tile_map.py
import pytest
from tile_map import parse_map, tile, to_symbol
def test_same_symbol_same_object() -> None:
assert tile(".") is tile(".")
assert tile(".") is not tile("#")
def test_map_shares_tiles() -> None:
field = parse_map("..\n~~")
assert field[0][0] is field[0][1]
assert field[1][0] is field[1][1]
assert not field[1][0].walkable
def test_unknown_symbol_raises() -> None:
with pytest.raises(KeyError):
to_symbol("?")Because Tile is frozen, sharing is invisible to
clients. Nothing they can do to one cell’s tile affects another,
because nothing they can do affects the tile.
Remove frozen=True and the pattern fails. Mutate
the grass tile in one cell and every grass cell in the map
changes.
A factory function like tile() is an honest
extra name. Its different syntax warns callers that something
unusual is happening. If you want callers to keep writing
Color(...), hide the pool inside
__new__ instead. This is the same maneuver the Singleton
chapter uses. Here the cache is keyed by the constructor
arguments instead of a single fixed key:
# interned_color.py
from typing import ClassVar
type RGB = tuple[int, int, int]
class Color:
_pool: ClassVar[dict[RGB, Color]] = {}
red: int
green: int
blue: int
def __new__(cls, red: int, green: int, blue: int) -> Color:
key: RGB = (red, green, blue)
cached: Color | None = cls._pool.get(key)
if cached is not None:
return cached
self = super().__new__(cls)
self.red, self.green, self.blue = red, green, blue
cls._pool[key] = self
return self
if __name__ == "__main__":
crimson = Color(220, 20, 60)
print(crimson is Color(220, 20, 60))
print(len(Color._pool))
#: True
#: 1The construction syntax is unchanged, and callers cannot tell
they received a shared object (this is how CPython’s
small-integer cache works). The cost is bookkeeping by hand.
When __new__() returns an instance of the class, as
it does here, Python calls __init__() on it, so an
__init__() would re-run on the cached instance at
every construction. This class therefore defines no
__init__(). This rules out @dataclass,
whose generated __init__() would reintroduce
exactly that re-run. Color loses the
__repr__() and __eq__() that
Tile gets, so printing a Color falls
back to the default object.__repr__(). The missing
__eq__() costs less than it appears: for a
perfectly interned type, equal values are the same
object, so the default identity comparison already answers
correctly. (@dataclass(init=False) could restore
those two generated methods, at the price of still more care
with the by-hand field assignment.) A defaultdict
cannot replace _pool either, because building a
Color needs the three color components, not just
the key that names them. Unless you need the constructor syntax,
the @cache factory from tile_map.py
does the same job with less machinery.
One more property carries over from Singleton’s cached factory: every lazy check-then-insert pool races under threads. Two threads asking for the same new color can each build “the” shared object, one wins the pool, and identity between their two results fails. When flyweights meet threads, populate the pool eagerly or guard the insert with a lock.
Both pools so far hold their objects forever.
@cache keeps strong references to every argument
and result, and Color._pool never shrinks. For tile
kinds and colors that is fine, since the universe of values is
small. When the universe is unbounded, such as symbols in a
long-running parser, the pool becomes a memory leak.
weakref.WeakValueDictionary fixes this. It holds
its values weakly, so an entry disappears as soon as no one else
uses the object:
# weak_pool.py
from dataclasses import dataclass
from typing import Final
from weakref import WeakValueDictionary
@dataclass(frozen=True)
class Symbol:
name: str
_pool: Final[WeakValueDictionary[str, Symbol]] = (
WeakValueDictionary())
def symbol(name: str) -> Symbol:
found: Symbol | None = _pool.get(name)
if found is None:
found = Symbol(name)
_pool[name] = found
return found
if __name__ == "__main__":
alpha = symbol("alpha")
alias = symbol("alpha")
print(alpha is alias, len(_pool))
del alpha, alias
print(len(_pool))
#: True 1
#: 0While any reference to the Symbol survives,
every call to symbol("alpha") returns that same
object. When the last reference dies, CPython’s reference
counting frees the object and the pool entry evaporates with it.
The pool guarantees sharing without extending lifetimes, which
is the same design as sys.intern(). If you want
bounded memory with fixed construction cost instead,
functools.lru_cache(maxsize=n) gives the factory an
eviction policy, at the price of keeping the most recent
n alive.
# test_weak_pool.py
from weak_pool import _pool, symbol
def test_symbols_are_shared() -> None:
keep = symbol("x")
assert symbol("x") is keep
assert symbol("y") is not keep
def test_pool_releases_unused() -> None:
temp = symbol("temp")
assert "temp" in _pool
del temp
assert "temp" not in _poolWhen you know the full set of shared values as you write the program, you do not need a pool at runtime. An Enum is a flyweight pool the language maintains for you. Python constructs each member once, at class creation, and any reference produces that one object.
# tile_enum.py
from enum import Enum
class Tile(Enum):
GRASS = (".", True)
WATER = ("~", False)
ROCK = ("#", False)
walkable: bool
def __new__(cls, symbol: str, walkable: bool) -> Tile:
member = object.__new__(cls)
member._value_ = symbol
member.walkable = walkable
return member
if __name__ == "__main__":
print(Tile.GRASS is Tile["GRASS"] is Tile("."))
print(Tile.WATER.value, Tile.WATER.walkable)
print([t.value for t in Tile])
#: True
#: ~ False
#: ['.', '~', '#']walkable is a bare annotation, not a
ClassVar. It declares a per-member attribute, the
same role a dataclass field plays, except __new__()
assigns it by hand instead of a generated
__init__(). __new__() runs before any
member becomes visible, so there is no window where
walkable is unset. It needs no default or
sentinel.
Each member’s tuple goes to __new__(), which
stores the walkability and assigns _value_, so the
member’s value is its map symbol rather than the tuple. The
customization must happen in __new__(). The lookup
table behind Tile(".") keys on the value
__new__() establishes, so setting
_value_ later, in __init__(), would
leave that table keyed by the tuples. With _value_
set in __new__(), Tile(".") is a
lookup.
object.__new__(cls) builds a bare instance
directly, skipping Tile.__new__() so the call does
not recurse. _value_ is not an ordinary attribute
name. Enum’s metaclass reads it to build the
Tile(".") lookup table and the member’s
repr(), so it keeps that exact name rather than
something like _symbol_.
Name, symbol, and attribute access all land on the same
shared member. The enum version also brings iteration,
exhaustive match, and protection against inventing
a tile kind that does not exist. The constraint is less
flexibility. tile() could load SPECS
from a file, while Tile.GRASS is source code. The
table-driven state machine in State
Machines exploits the same property, using members as
shared, comparable states.
The pattern is easy to spot once you know its shape. Compilers and interpreters intern identifiers so that scope lookups compare pointers instead of characters. Column stores such as Pandas and Polars offer categorical types. A column of a million country names stores small integer codes into a pool of distinct strings. Text systems share one glyph object per character and font, with each occurrence supplying its own position. In every case the benefit is the same: memory proportional to the number of distinct values, not the number of uses, and equality checks that collapse to identity.
+, walkable) and tree
(T, not walkable) kinds to
tile_map.py. Extend Symbol and
SPECS to match, then write
walkable_neighbors(field, row, col) returning the
count of adjacent walkable cells. Confirm the tile pool size
still equals the number of kinds, however large the map.tracemalloc to compare
parse_map() on a large map against a version whose
tile() has no @cache. How does the
ratio change as the map grows?frozen=True from Tile and
set field[0][0].walkable = False on a parsed map.
Write a test that exposes the resulting bug, then restore
frozen=True.Piece (color, kind) and a
board that is a dict mapping squares to pieces. A
full opening position holds thirty-two piece references. How
many Piece objects exist? How do you capture and
promote?interned_color.py to use the weak pool
technique from weak_pool.py, and show that building
and dropping a palette of colors leaves the pool empty.red, green, and
blue to 0-255 in
interned_color.py. Raise ValueError
from __new__() for an out-of-range component, and
write a test for it.