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, where the context supplies it. Second, route construction through a factory that returns the existing instance for a given value.
Sharing one object under many names is safe when the object stays the same for everyone, so a flyweight must be immutable (see Rethinking Objects).
CPython 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 cache covers a fixed range of values chosen at
CPython build time. The range usually quoted is -5
through 256, but each build picks its own. This one
caches up to 1024, so the example that needs a fresh object uses
100000 rather than 257. The listing
parses each value from a string for a reason. The compiler pools
equal constants within one code object, so with literals
(low, low2 = 256, 256) even
100000 is 100000 prints True. That
sharing comes from the pooling, not from the integer cache.
Parsing at runtime keeps the compiler out, so any sharing that
remains comes from the cache. (That pooling is also why Python
warns about is on a literal.)
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, the handful is 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 takes no arguments, so
caching produces 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 = [*row for row in field]
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.
[*row for row in field] flattens the grid into one
list of cells, the comprehension unpacking from Comprehensions.
The listing counts id(t) rather than
len(set(cells)) on purpose. Tile is a
frozen data class, so its generated __eq__()
compares field values, and a set of cells would collapse to
three with or without sharing. Only identity proves sharing. 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 asker supplying the coordinates. The listing shows the
object count. Exercise 2 measures the memory behind it.
Symbol names the closed set of valid map
characters, so Tile.symbol and SPECS
can hold only one of them. If you add a kind to
SPECS without adding it to Symbol, the
type 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 type checker reads that guard
too. SPECS has key type Symbol, so
past the guard char is a key of SPECS.
The checker narrows it to Symbol, and
return char satisfies the declared return type with
nothing added. The narrowing proves what a cast()
would assert (see Static
Types). Prefer a guard the type checker can read. Keep
cast() for the cases where no guard exists, because
the type checker believes a cast() rather than
verifying it.
# test_tile_map.py
import pytest
from tile_map import Tile, 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("?")
def test_direct_construction_bypasses_pool() -> None:
bypassed = Tile("~", "water", False)
assert bypassed == tile("~")
assert bypassed is not tile("~")Freezing Tile hides the sharing from clients.
Nothing they can do to one cell’s tile affects another, because
nothing they can do affects the tile.
If you remove frozen=True, the pattern fails.
Mutating the grass tile in one cell changes every grass cell in
the map.
frozen=True must hold all the way down. It
blocks assignment to a field, not mutation inside one, so a
Tile holding a list would leak that
list to every cell that shares the tile (the shallow-freezing
trap in Rethinking
Objects). Every field here is immutable, which makes the
sharing safe.
A factory function like tile() has a visibly
different name and call syntax, which warns callers of something
unusual. If you want callers to keep writing
Color(...), hide the pool inside
__new__() instead. Hiding the pool in
__new__() is the maneuver Singleton
uses. Here the cache keys on the constructor arguments instead
of a single fixed key. A pool of singletons keyed this way is
sometimes called Multiton:
# 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 stays the same, 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__() re-runs on the cached instance at every
construction. This class therefore defines no
__init__(). The call still reaches
object.__init__(). Color overrides
__new__() and not __init__(), so that
inherited __init__() accepts the three arguments
and ignores them. That rules out @dataclass, whose
generated __init__() reintroduces the re-run. The
damage is invisible at first, since re-assigning the same
components changes nothing. It appears the moment a field has a
default_factory or __post_init__() has
a side effect, because both run again on an object that was
already finished. Tile’s @dataclass
generated its __repr__() and __eq__();
Color has only object’s versions, so
printing a Color shows 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 answers correctly.
@dataclass(init=False) could restore those two
generated methods, at a price: the generated
__eq__() sets __hash__ to
None unless you also pass frozen=True,
and frozen=True then forces
object.__setattr__() for the by-hand assignment in
__new__(). A defaultdict calls its
default_factory with no arguments, and building a
Color needs the three components, so
_pool stays a plain dict with an explicit
get().
_pool keys on the components alone, and every
subclass shares the one dict, so a subclass and
Color asking for the same components both receive
the object the first call built. Key the pool by
(cls, red, green, blue) if you need to
subclass.
The two forms differ in one guarantee. tile()
interns the calls that go through it, and a direct
Tile("~", "water", False) bypasses it, building a
second object equal to the pooled water tile.
Color(...) routes every construction through the
pool, so is is as trustworthy as ==.
That guarantee, or the constructor syntax, is what the
bookkeeping buys. When you need neither, 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. @cache races the
same way. Its C implementation invites the assumption that one
call is atomic, but the lookup, the call to your function, and
the store are three separate steps, so threads that all miss on
the same key each run the function and each keep their own
result. 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 grows without bound, such as symbols in
a long-running parser, the pool becomes a memory leak.
weakref.WeakValueDictionary, the live-instance
registry from Cleanup,
fixes the leak. 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 Name:
text: str
_pool: Final[WeakValueDictionary[str, Name]] = (
WeakValueDictionary())
def name(text: str) -> Name:
found: Name | None = _pool.get(text)
if found is None:
found = Name(text)
_pool[text] = found
return found
if __name__ == "__main__":
alpha = name("alpha")
alias = name("alpha")
print(alpha is alias, len(_pool))
del alpha, alias
print(len(_pool))
#: True 1
#: 0While any reference to the Name survives, every
call to name("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 a bounded
pool instead, functools.lru_cache(maxsize=n) gives
the factory an eviction policy, at the price of keeping the most
recent n alive whether or not anyone uses them.
Eviction also weakens the sharing guarantee: requesting an
evicted value builds a fresh object, equal to any surviving
original but not the same one. The weak pool never produces such
a pair: its entry lives exactly as long as someone holds the
object.
Flyweight cuts the number of objects, and
slots=True (Performance)
cuts the size of each one, so the two are worth combining once
memory is the point. They collide at one spot. A slotted class
drops the __weakref__ slot a weak reference needs,
so slotting Name makes
_pool[text] = found raise a TypeError.
weakref_slot=True puts that slot back.
# test_weak_pool.py
from weak_pool import _pool, name
def test_names_are_shared() -> None:
keep = name("x")
assert name("x") is keep
assert name("y") is not keep
def test_pool_releases_unused() -> None:
temp = name("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 need no pool at runtime. An Enum
is a flyweight pool the language maintains. Python constructs
each member once, at class creation, and any reference produces
that one object. Here is tile_map.py’s
Tile recast as an enum, with the pool moved into
the language and the member name replacing the name
field:
# 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 every member has its
walkable by the time anything can read it, and the
annotation 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.
__new__(), not __init__(), must assign
_value_. Enum reads _value_ as soon as
__new__() returns, so an __init__()
that assigns _value_ later comes too late: the
lookup table behind Tile(".") stays 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 a name Enum’s metaclass
reads, to build the Tile(".") lookup table and the
member’s repr(), so __new__() must
assign to that exact name rather than something like
_symbol_.
Name, symbol, and attribute access all reach the same shared
member. The enum version also brings iteration, exhaustive
match, and protection against inventing a tile kind
that does not exist. A match over Tile
needs no case _: catch-all once every member has a
case, and if you leave one out, the type checker reports the gap
before any Tile value reaches the code at
runtime:
# tile_enum_match.py
from tile_enum import Tile
# ty: function can implicitly return `None`,
# not assignable to return type `str`
def describe(tile: Tile) -> str: # type: ignore
match tile:
case Tile.GRASS:
return "grass"
case Tile.WATER:
return "water"
if __name__ == "__main__":
print(describe(Tile.GRASS))
#: grassWithout the # type: ignore,
ty check reports:
error[invalid-return-type]: Function can implicitly return
`None`, which is not assignable to return type `str`
--> tile_enum_match.py:6:29
|
6 | def describe(tile: Tile) -> str:
| ^^^
The missing Tile.ROCK case is the gap; adding it
back makes the diagnostic disappear with no other change. The
cost is 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 chapter showed four mechanisms, and the question that
decides between them is how much you know about the set of
values. If you know it as you write the program, use an
Enum and let the language hold the pool. If callers
must keep writing C(...), intern in
__new__() and pay the bookkeeping. If the set grows
without bound, use a WeakValueDictionary so the
pool cannot become a leak. Otherwise use a @cache
factory, which is the least machinery for the job.
These four answers read as an if/elif chain, but the
questions behind them are independent. Constructor syntax and
leak-safety are separate questions, so __new__()
interning can hold its pool weakly too: key the
WeakValueDictionary on the constructor arguments
the way interned_color.py keys
_pool. Combine mechanisms when more than one
requirement applies.
Compilers and interpreters intern identifiers so that scope
lookups compare pointers instead of characters. Dataframe
libraries such as Pandas and Polars offer categorical types. A
column of a million country names stores small integer codes
that index 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. When every instance of a type comes from the
pool, you can write its equality checks as is.
+, 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 hold
its pool weakly, as weak_pool.py does, and
show that building and dropping a palette of colors leaves the
pool empty. Say what the rewrite gave up to get there.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.tile_map.py on top of tile_enum.py’s
Tile, so parse_map() returns
list[list[Tile]] of enum members and
to_symbol() disappears. What does the type checker
now catch that the Literal version caught, and what
does it catch that the Literal version did
not?tile()’s body slow, with a
time.sleep(0.05) before it builds the
Tile, and call it from four threads with the same,
previously unseen symbol. How many Tile objects get
built, and how many distinct objects do the four threads hold?
Fix it two ways: populate the pool eagerly at import, and guard
the factory with a threading.Lock.