Flyweight Pattern — Deep Dive
Flyweight factory implementation
The factory ensures each unique combination of intrinsic state is created only once:
from dataclasses import dataclass
@dataclass(frozen=True)
class CharacterStyle:
"""Flyweight — shared intrinsic state."""
font: str
size: int
color: str
bold: bool = False
italic: bool = False
class StyleFactory:
_cache: dict[tuple, CharacterStyle] = {}
@classmethod
def get(cls, font: str, size: int, color: str,
bold: bool = False, italic: bool = False) -> CharacterStyle:
key = (font, size, color, bold, italic)
if key not in cls._cache:
cls._cache[key] = CharacterStyle(font, size, color, bold, italic)
return cls._cache[key]
@classmethod
def cache_size(cls) -> int:
return len(cls._cache)
A document with 100,000 characters but only 15 unique styles creates 15 CharacterStyle instances instead of 100,000:
# Extrinsic state — position and character value
class DocumentChar:
__slots__ = ("char", "position", "style")
def __init__(self, char: str, position: int, style: CharacterStyle):
self.char = char
self.position = position
self.style = style # shared flyweight reference
# Building a document
factory = StyleFactory()
body_style = factory.get("Helvetica", 12, "#333333")
heading_style = factory.get("Helvetica", 24, "#000000", bold=True)
chars = []
for i, c in enumerate("Hello World"):
style = heading_style if i == 0 else body_style
chars.append(DocumentChar(c, i, style))
# Only 2 style objects exist, regardless of document length
print(f"Styles in cache: {factory.cache_size()}") # 2
Memory savings with __slots__
__slots__ eliminates the per-instance __dict__, which saves 56-112 bytes per object depending on Python version and platform:
import sys
class WithDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
class WithSlots:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
d = WithDict(1, 2, 3)
s = WithSlots(1, 2, 3)
print(sys.getsizeof(d) + sys.getsizeof(d.__dict__)) # ~200 bytes
print(sys.getsizeof(s)) # ~72 bytes
At a million instances, that’s ~128MB saved. Combined with flyweight sharing of common attributes, the savings compound.
Flyweight with __new__ for automatic interning
Override __new__ to make the class itself act as a flyweight factory:
class Color:
"""Interned color objects — same RGB values always return same instance."""
__slots__ = ("r", "g", "b", "_hash")
_cache: dict[tuple[int, int, int], "Color"] = {}
def __new__(cls, r: int, g: int, b: int) -> "Color":
key = (r, g, b)
if key in cls._cache:
return cls._cache[key]
instance = super().__new__(cls)
instance.r = r
instance.g = g
instance.b = b
instance._hash = hash(key)
cls._cache[key] = instance
return instance
def __hash__(self) -> int:
return self._hash
def __eq__(self, other) -> bool:
if not isinstance(other, Color):
return NotImplemented
return self.r == other.r and self.g == other.g and self.b == other.b
def __repr__(self) -> str:
return f"Color({self.r}, {self.g}, {self.b})"
# Same values → same object
red1 = Color(255, 0, 0)
red2 = Color(255, 0, 0)
assert red1 is red2 # True — same instance
This is exactly how Python interns small integers internally.
Weakref-based flyweight cache
A problem with flyweight caches: they keep objects alive forever, even after all external references are gone. Use weakref.WeakValueDictionary to let the garbage collector reclaim unused flyweights:
import weakref
from dataclasses import dataclass
@dataclass(frozen=True)
class Texture:
name: str
resolution: tuple[int, int]
data: bytes # large payload
def __hash__(self):
return hash((self.name, self.resolution))
class TexturePool:
_pool: weakref.WeakValueDictionary[str, Texture] = weakref.WeakValueDictionary()
@classmethod
def get(cls, name: str, resolution: tuple[int, int], loader) -> Texture:
key = f"{name}_{resolution[0]}x{resolution[1]}"
texture = cls._pool.get(key)
if texture is None:
data = loader(name, resolution)
texture = Texture(name, resolution, data)
cls._pool[key] = texture
return texture
When no game entity references a texture anymore, the garbage collector frees it. If it’s needed again, the pool reloads it. This balances memory conservation with automatic cleanup.
Flyweight vs array-of-structs
For numerical data, the flyweight pattern is often inferior to a columnar layout:
import numpy as np
# Flyweight approach — Python objects
class Particle:
__slots__ = ("x", "y", "type_id")
def __init__(self, x, y, type_id):
self.x = x
self.y = y
self.type_id = type_id
# 1 million particles ≈ 72 bytes × 1M = ~72MB
# Columnar approach — NumPy arrays
xs = np.random.rand(1_000_000).astype(np.float32) # 4MB
ys = np.random.rand(1_000_000).astype(np.float32) # 4MB
types = np.random.randint(0, 10, 1_000_000, dtype=np.uint8) # 1MB
# Total: ~9MB + vectorized operations
When all your objects are homogeneous (same fields, numerical data), NumPy or struct arrays beat flyweights. Flyweights shine when objects are heterogeneous or when you’re working with business logic objects, not raw numbers.
Real-world flyweights in Python
sys.intern()
Explicitly interns strings. Two interned strings with the same value are the same object, saving memory and enabling is comparison instead of ==.
Python’s type objects
Every integer 42 in your code points to the same int type object. The type itself is a shared flyweight.
enum.Enum
Enum members are singletons — Color.RED is Color.RED is always True. Each unique value exists once, making enums natural flyweights.
Django’s ContentType framework
Content types are cached in memory. Multiple queries for the same content type return the same cached instance.
Memory profiling to validate savings
Use tracemalloc to measure before and after:
import tracemalloc
tracemalloc.start()
# Without flyweight
particles_naive = [
{"type": "fire", "color": (255, 100, 0), "x": i, "y": i * 2}
for i in range(100_000)
]
snapshot1 = tracemalloc.take_snapshot()
# Reset
del particles_naive
tracemalloc.clear_traces()
# With flyweight
fire_type = ("fire", (255, 100, 0)) # shared
class FlyParticle:
__slots__ = ("type_info", "x", "y")
def __init__(self, type_info, x, y):
self.type_info = type_info
self.x = x
self.y = y
particles_fly = [FlyParticle(fire_type, i, i * 2) for i in range(100_000)]
snapshot2 = tracemalloc.take_snapshot()
Always profile before applying the pattern. Premature optimization with flyweights adds complexity without guaranteed benefit.
Tradeoffs
| Advantage | Disadvantage |
|---|---|
| Dramatic memory savings at scale | Added complexity in state management |
| Shared immutable state is thread-safe | Extrinsic state must be passed around |
Faster equality checks via identity (is) | Cache management (eviction, weak refs) |
Pairs well with __slots__ | Not useful for objects with mostly unique state |
The one thing to remember: Flyweight works by splitting state into shared (intrinsic) and per-instance (extrinsic) parts — combine it with __slots__, frozen dataclasses, and weakref caches to handle millions of similar objects without exhausting memory.
See Also
- Python Adapter Pattern How Python's Adapter Pattern works like a travel power plug — making incompatible things work together.
- Python Bridge Pattern Why separating what something does from how it does it keeps your Python code from becoming a tangled mess.
- Python Builder Pattern Why building complex Python objects step by step beats cramming everything into one giant constructor.
- Python Composite Pattern How the Composite Pattern lets you treat a group of things the same way you'd treat a single thing in Python.
- Python Facade Pattern How the Facade Pattern gives you one simple button instead of a confusing control panel in Python.