Flyweight Pattern
C++ flyweight pattern — shared intrinsic state, intern tables, glyph caches, and handle-based flyweights for memory-dense object systems.
Flyweight Patternsince C++11A structural pattern that separates an object's immutable intrinsic state (shared among many instances via a factory) from its mutable extrinsic state (unique per instance), allowing one shared object to represent thousands or millions of logical instances.
Overview
The central insight: if many objects share identical data, that data can live in exactly one place. Every instance holds only what is unique to it — position, lifetime, owner — plus a lightweight reference (pointer or integer index) back to the shared block.
Intrinsic state — immutable, context-independent:
- A glyph's rendered bitmap and advance metrics
- A particle type's mass, drag coefficient, and texture ID
- An interned string's character data
Extrinsic state — mutable, context-dependent:
- Screen position and scale of a rendered character
- A particle's position, velocity, and remaining lifetime
- The scope or binding site of a symbol
The flyweight factory guarantees that two requests for the same intrinsic key return the same object. That guarantee is the whole pattern.
Examples
Glyph Cache (Classic)
A text renderer laying out thousands of characters per frame is the canonical use case. Glyph bitmaps are expensive to render and identical for every occurrence of the same codepoint.
#include <cstdint> // C++11: uint32_t
#include <memory> // C++11: std::shared_ptr, std::make_shared
#include <unordered_map> // C++11
#include <vector>
// Intrinsic state — immutable once constructed
struct GlyphData {
uint32_t codepoint;
int advance_x;
int bearing_y;
std::vector<uint8_t> bitmap; // rendered pixels at a fixed pt size
};
class GlyphCache {
std::unordered_map<uint32_t,
std::shared_ptr<const GlyphData>> cache_;
Font& font_;
public:
explicit GlyphCache(Font& font) : font_{font} {}
[[nodiscard]] // C++17
std::shared_ptr<const GlyphData> get(uint32_t cp) {
auto [it, inserted] = cache_.emplace(cp, nullptr); // C++17 structured bindings
if (inserted)
it->second = std::make_shared<GlyphData>(font_.render(cp)); // C++11
return it->second;
}
std::size_t cached_count() const noexcept { return cache_.size(); } // noexcept C++11
};
// Extrinsic state — unique per rendered character instance
struct Character {
std::shared_ptr<const GlyphData> glyph; // shared flyweight
int x, y;
float scale;
};
// 10 000 characters on screen; cache holds at most ~96 GlyphData objects
// for an ASCII font — bitmap memory shared across every recurrence
GlyphCache cache{my_font};
std::vector<Character> layout;
layout.reserve(text.size());
for (auto& [cp, x, y] : measure(text)) // structured bindings C++17
layout.push_back({cache.get(cp), x, y, 1.0f});With a 96-glyph ASCII charset, cache_ holds at most 96 entries regardless of document length. Each Character is 24–32 bytes rather than kilobytes.
String Interning
Intern once, compare by pointer forever. Compiler symbol tables, scripting runtimes, and serialization layers all use this variant.
#include <string>
#include <string_view> // C++17
#include <unordered_set> // C++11
class StringPool {
std::unordered_set<std::string> pool_;
public:
// Returns a stable pointer into the pool, valid for the pool's lifetime.
// std::unordered_set guarantees pointer stability across insertions (C++11).
const std::string* intern(std::string s) {
auto [it, _] = pool_.insert(std::move(s)); // structured bindings C++17
return &*it;
}
const std::string* intern(std::string_view sv) { // C++17
return intern(std::string{sv});
}
};
// Symbol: identity is pointer equality — O(1) compare, no strcmp
struct Symbol {
const std::string* str;
explicit Symbol(const std::string* p) noexcept : str{p} {} // noexcept C++11
bool operator==(Symbol rhs) const noexcept { return str == rhs.str; }
std::string_view view() const noexcept { return *str; } // C++17
};
StringPool pool;
Symbol id_a{pool.intern("username")};
Symbol id_b{pool.intern("username")};
assert(id_a == id_b); // guaranteed: same pointerstd::unordered_set does not invalidate pointers or references to elements, even across rehashes — only iterators may be invalidated. This stability, guaranteed since C++11, is what makes raw pointer interning safe for the pool's lifetime.
Handle-Based Flyweight (Cache-Friendly)
Pointers scatter across heap pages. A 4-byte index into a flat std::vector is cheaper to store, copy, and dereference, especially in hot update loops.
#include <cstdint>
#include <string>
#include <string_view> // C++17
#include <unordered_map> // C++11
#include <vector>
struct ParticleType {
std::string name;
float mass;
float drag;
uint32_t texture_id;
};
class ParticleTypeRegistry {
std::vector<ParticleType> types_;
std::unordered_map<std::string, uint32_t> index_;
public:
uint32_t add(ParticleType t) {
auto id = static_cast<uint32_t>(types_.size());
index_.emplace(t.name, id);
types_.push_back(std::move(t));
return id;
}
[[nodiscard]] const ParticleType& get(uint32_t id) const { return types_[id]; } // [[nodiscard]] C++17
[[nodiscard]] uint32_t find(std::string_view name) const { // C++17
return index_.at(std::string{name});
}
};
// 32 bytes per particle; type_id is 4 bytes vs 8-byte pointer
struct Particle {
float x, y, z;
float vx, vy, vz;
uint32_t type_id;
float lifetime;
};
ParticleTypeRegistry reg;
const uint32_t FIRE = reg.add({"fire", 0.01f, 0.5f, TEX_FIRE});
const uint32_t SMOKE = reg.add({"smoke", 0.001f, 0.8f, TEX_SMOKE});
std::vector<Particle> particles(1'000'000); // digit separators C++14
for (auto& p : particles) { p.type_id = FIRE; p.lifetime = 2.0f; }Iterating one million particles and calling reg.get(p.type_id) warms one cache line per unique type, not one per particle.
Thread-Safe Generic Factory
When the factory is shared across threads, use std::shared_mutex (C++17) to let many readers proceed concurrently and serialize only on a cache miss.
#include <functional> // C++11: std::function
#include <memory> // C++11
#include <mutex> // C++11
#include <shared_mutex> // C++17: std::shared_mutex, std::shared_lock
#include <unordered_map>
template<typename Key, typename Value>
class FlyweightFactory {
mutable std::shared_mutex mu_; // C++17
std::unordered_map<Key, std::shared_ptr<const Value>> pool_;
public:
// factory_fn invoked at most once per unique key, under exclusive lock
std::shared_ptr<const Value> get(const Key& key,
std::function<Value()> factory_fn) {
{
std::shared_lock lock{mu_}; // shared (read) lock — C++17
auto it = pool_.find(key);
if (it != pool_.end()) return it->second;
}
std::unique_lock lock{mu_}; // exclusive (write) lock
// Re-check: another thread may have inserted while we upgraded
auto [it, inserted] = pool_.emplace(key, nullptr); // C++17
if (inserted)
it->second = std::make_shared<Value>(factory_fn());
return it->second;
}
std::size_t size() const {
std::shared_lock lock{mu_};
return pool_.size();
}
};The double-check (shared lock → miss → exclusive lock → recheck) prevents serializing all reads while still ensuring exactly-once construction.
Best Practices
Enforce immutability in the type system. Return std::shared_ptr<const T> or const T& from the factory. If intrinsic state is mutable, two clients sharing the same object can interfere silently.
Prefer handles over pointers in hot paths. A uint32_t index avoids atomic reference-count traffic on every copy. For particle systems or tile renderers updated 60 times per second, the difference between shared_ptr and index is measurable.
Reserve at known capacity. If a font has 256 glyphs or a game has 20 particle types, call reserve() on backing containers at startup. Rehashes during live operation cause latency spikes.
Keep the factory's lifetime longer than any issued handle. With raw-pointer interning, pool destruction invalidates every pointer. Document this in the API with a clear lifetime contract, or use shared_ptr to make it automatic.
Keep factory logic separate from the flyweight type. Mixing factory and domain concerns complicates unit testing and makes it harder to swap eviction strategies.
Common Pitfalls
Sharing mutable state. const in the return type is not a barrier against a const_cast. If any downstream code can legally mutate through a handle, the shared-immutability invariant breaks. Audit for mutable members, mutable fields, and casts.
Unbounded pool growth. A factory that never evicts is a memory leak under adversarial input — a renderer interning every Unicode codepoint from untrusted text, for example. Add a capacity cap or LRU eviction when input cardinality is unbounded.
Using operator[] on the map. pool_[key] default-constructs a null entry before you assign it. Under a shared lock that window is a race. Prefer emplace or find/insert pairs with explicit checks.
False sharing in parallel update loops. When many threads mutate extrinsic state (particle positions) in a packed std::vector, adjacent elements share cache lines. Pad hot structs to std::hardware_destructive_interference_size (C++17, <new>) if profiling confirms contention.
Dangling pointers after pool destruction. Storing raw const std::string* from a StringPool then destroying the pool before all holders are gone produces undefined behaviour. Use shared_ptr<const std::string> if ownership is unclear.
Thread-Safe Flyweight Factory
A shared flyweight factory becomes a contention bottleneck if every lookup acquires an exclusive lock. Use std::shared_mutex (C++17) to allow concurrent reads while serialising writes to the backing map. Storing std::weak_ptr<const T> instead of shared_ptr<const T> lets the entry be garbage-collected automatically when no client holds it — the map shrinks as flyweights go out of use.
#include <memory>
#include <mutex>
#include <shared_mutex> // C++17
#include <unordered_map>
#include <functional>
template<typename Key, typename T>
class FlyweightFactory { // C++17
mutable std::shared_mutex mu_;
std::unordered_map<Key, std::weak_ptr<const T>> pool_;
public:
std::shared_ptr<const T>
get(const Key& key, std::function<T()> make) {
// Fast path: shared (read) lock — concurrent reads do not block each other
{
std::shared_lock lock{mu_}; // C++17
auto it = pool_.find(key);
if (it != pool_.end()) {
if (auto sp = it->second.lock()) // weak_ptr still alive?
return sp;
}
}
// Slow path: exclusive (write) lock — only one writer at a time
std::unique_lock lock{mu_}; // C++17
// Double-check: another thread may have inserted between the two locks
auto& slot = pool_[key];
if (auto sp = slot.lock())
return sp;
auto sp = std::make_shared<const T>(make());
slot = sp; // store weak reference
return sp;
}
// Purge map entries whose weak_ptr has expired (no live holders remain)
void purge_expired() {
std::unique_lock lock{mu_};
for (auto it = pool_.begin(); it != pool_.end(); ) {
it = it->second.expired() ? pool_.erase(it) : std::next(it);
}
}
std::size_t size() const {
std::shared_lock lock{mu_};
return pool_.size();
}
};The std::weak_ptr slot means the factory holds no strong reference: when the last client releases its shared_ptr, the control block is destroyed and slot.lock() returns null. Calling purge_expired() periodically reclaims the map entries themselves.
Flyweight with std::string_view as the Intrinsic Key
String interning built on std::unordered_set<std::string> gives stable character data whose address never moves, making std::string_view handles permanently valid. std::unordered_set guarantees that pointers and references to elements remain valid across insertions (but not across rehashes for iterators — pointers survive). The view is therefore safe to pass around for the lifetime of the interning pool.
#include <string>
#include <string_view> // C++17
#include <unordered_set>
#include <cassert>
class StringInterner { // C++17
std::unordered_set<std::string> store_;
public:
// Returns a stable std::string_view into the backing store.
// The view remains valid as long as this StringInterner is alive.
std::string_view intern(std::string s) {
// insert() returns a reference to the element; pointer is stable.
auto [it, _] = store_.insert(std::move(s));
return std::string_view{*it};
}
std::string_view intern(std::string_view sv) {
return intern(std::string{sv});
}
std::size_t unique_count() const noexcept { return store_.size(); }
};
// Use-case: JSON field-name interning
StringInterner fields;
std::string_view f1 = fields.intern("Content-Type");
std::string_view f2 = fields.intern("Content-Type");
assert(f1.data() == f2.data()); // same backing pointer — O(1) equality
// Use-case: identifier table in a compiler/interpreter
StringInterner ids;
std::string_view sym_a = ids.intern("maxRetries");
std::string_view sym_b = ids.intern("maxRetries");
// Pointer equality suffices for symbol comparison — no strcmp needed
assert(sym_a.data() == sym_b.data());The pool never erases entries: once interned, a string lives for the pool's lifetime. For long-running processes where string cardinality is bounded (field names, keywords, identifiers), that trade-off is desirable — lookup cost drops from O(n) strcmp to O(1) pointer comparison.
Boost.Flyweight and Its Design
boost::flyweight<T> provides automatic interning of any regular type with no manual factory code. It intercepts assignment and construction to route every value through a global factory, returning a lightweight wrapper that holds a pointer to the canonical shared object. Two boost::flyweight<std::string> values constructed from equal strings compare equal and share the same backing memory.
// Requires: Boost.Flyweight
// #include <boost/flyweight.hpp>
// #include <boost/flyweight/hashed_factory.hpp>
// #include <boost/flyweight/set_factory.hpp>
// ---- Boost approach --------------------------------------------------------
// boost::flyweight<std::string> s1{"hello"};
// boost::flyweight<std::string> s2{"hello"};
// assert(&s1.get() == &s2.get()); // same backing std::string
// Default factory is hashed_factory — O(1) average lookup.
// set_factory uses a std::set — O(log n) lookup, ordered traversal.
// boost::flyweight<std::string, boost::flyweights::set_factory<>> ordered_fw;
// ---- Equivalent hand-rolled implementation ---------------------------------
#include <memory>
#include <string>
#include <unordered_map>
class StringFlyweightFactory {
std::unordered_map<std::string,
std::shared_ptr<const std::string>> pool_;
public:
std::shared_ptr<const std::string> get(const std::string& s) {
auto [it, ins] = pool_.emplace(s, nullptr);
if (ins) it->second = std::make_shared<const std::string>(s);
return it->second;
}
};
// Trade-offs vs Boost.Flyweight:
// - Hand-rolled: explicit lifetime, full control over eviction strategy
// - Boost.Flyweight: thread-safe internally, less boilerplate, more opaqueBoost.Flyweight handles thread safety internally via a global mutex on the factory. The hand-rolled version gives finer-grained control — per-instance factories, weak_ptr-based GC, or custom hash policies — at the cost of more code.
Flyweight for Game Object Components
In a game engine, hundreds of GameObjects sharing the same 3-D mesh is a textbook flyweight: one MeshData allocation, thousands of per-object Transform records. The shared intrinsic state (MeshData) is heavy — vertex buffers, index buffers, material IDs — while the extrinsic state (Transform) is just 48 bytes per object.
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
// Intrinsic state — immutable, shared across all objects using this mesh
struct MeshData { // C++11
std::vector<float> vertices;
std::vector<uint32_t> indices;
uint32_t material_id;
std::string name;
// typical size: 1–50 MB on GPU + small CPU-side descriptor
};
// Extrinsic state — unique per GameObject instance (48 bytes)
struct Transform {
float tx, ty, tz; // translation
float rx, ry, rz; // rotation (Euler)
float sx, sy, sz; // scale
float pad; // alignment
};
struct GameObject {
std::shared_ptr<const MeshData> mesh; // flyweight — shared
Transform xform; // per-instance
uint32_t id;
};
class MeshRegistry {
std::unordered_map<std::string,
std::shared_ptr<const MeshData>> cache_;
public:
std::shared_ptr<const MeshData>
load(const std::string& path, std::function<MeshData()> loader) {
auto [it, ins] = cache_.emplace(path, nullptr);
if (ins) it->second = std::make_shared<const MeshData>(loader());
return it->second;
}
};
// Memory layout comparison (1 000 GameObjects, same mesh):
// Without flyweight: 1 000 × ~10 MB = ~10 GB (impossible in practice)
// With flyweight: 1 × ~10 MB + 1 000 × 60 B = ~10 MB + 60 KB
//
// ECS mapping: MeshRegistry == the component table for MeshData;
// each entity's row stores only the flyweight handle + Transform.This maps directly onto Entity Component System (ECS) architecture: the MeshRegistry is the component storage for MeshData, and each entity row in the transform table holds only a lightweight shared_ptr handle plus its own Transform. Iterating all objects to update positions touches only the dense Transform array — the shared MeshData stays cold.
Combining Flyweight with std::variant for Heterogeneous Pools
A lexer's token stream can mix stack-allocated keyword tags with heap-free string_view identifiers by using std::variant over a flyweight string table. Keywords are small enums (one byte); identifiers are interned once in a StringInterner and referenced via string_view — no per-token heap allocation either way.
#include <string_view> // C++17
#include <unordered_set>
#include <variant> // C++17
#include <vector>
#include <cstdint>
enum class Keyword : uint8_t { // C++11 scoped enum, C++17 usage
If, Else, While, For, Return, Class
};
// StringInterner from earlier (std::unordered_set<std::string> backing)
class StringInterner {
std::unordered_set<std::string> store_;
public:
std::string_view intern(std::string s) {
auto [it, _] = store_.insert(std::move(s));
return *it;
}
std::size_t unique_count() const noexcept { return store_.size(); }
};
// Token: either a keyword (no allocation) or an interned identifier view
using TokenValue = std::variant<Keyword, std::string_view>; // C++17
struct Token {
TokenValue value;
uint32_t line;
uint32_t col;
// sizeof(Token) == 32 bytes (variant + 2× uint32_t + padding)
};
// Lexer fragment
StringInterner idents;
std::vector<Token> tokens;
tokens.reserve(4096);
// "if" → Keyword::If (no interning needed, zero allocation)
tokens.push_back({Keyword::If, 1, 1});
// "maxRetries" → interned once, view stored in every occurrence
std::string_view id = idents.intern("maxRetries");
tokens.push_back({id, 1, 4});
tokens.push_back({id, 7, 4}); // second occurrence: same view, no new allocation
// Memory overhead analysis (10 000-token file, 200 unique identifiers):
// Keyword tokens: 0 heap bytes (enum fits in variant discriminant)
// Identifier intern: 200 × avg 12 B = 2 400 B in std::unordered_set
// Token vector: 10 000 × 32 B = 320 000 B
// vs. naive std::string per token: 10 000 × (32 + avg 12 B) = 440 000 Bstd::visit on each TokenValue dispatches to the keyword handler or the identifier handler in O(1) without any virtual call. The string_view stored in the variant is 16 bytes (pointer + length); the pointed-to character data lives in the interner — shared across every occurrence of the same identifier.
Measuring Flyweight Memory Savings
Before committing to the flyweight pattern, measure the deduplication ratio: unique values ÷ total instances. Flyweight overhead is roughly sizeof(handle) × N + sizeof(intrinsic) × U where N is total instance count and U is unique value count; the break-even vs. per-instance storage is when U × sizeof(T) < N × sizeof(T) − N × sizeof(handle).
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
struct HeavyConfig { // C++11
std::string name; // 32 bytes (SSO)
std::string description; // 32 bytes
double weight;
uint64_t flags;
// total: ~80 bytes on a 64-bit system
};
static_assert(sizeof(std::shared_ptr<const HeavyConfig>) == 16, ""); // two pointers
// Without flyweight: N objects, each with its own HeavyConfig copy
struct ItemNaive {
HeavyConfig config; // 80 bytes per object
uint32_t id;
};
// With flyweight: N objects share U unique HeavyConfig values
struct ItemFlyweight {
std::shared_ptr<const HeavyConfig> config; // 16 bytes (two pointers)
uint32_t id;
};
void measure(std::size_t N, std::size_t U) {
std::unordered_map<std::string,
std::shared_ptr<const HeavyConfig>> pool;
pool.reserve(U);
// Build U unique configs
for (std::size_t i = 0; i < U; ++i) {
auto cfg = std::make_shared<HeavyConfig>(
HeavyConfig{"cfg_" + std::to_string(i), "desc", 1.0, i});
pool.emplace(cfg->name, std::move(cfg));
}
// N instances cycling through the U configs
std::vector<ItemFlyweight> objects;
objects.reserve(N);
std::size_t k = 0;
for (std::size_t i = 0; i < N; ++i)
objects.push_back({pool.at("cfg_" + std::to_string(k++ % U)),
static_cast<uint32_t>(i)});
double ratio = static_cast<double>(N) / static_cast<double>(U);
std::size_t naive_bytes = N * sizeof(ItemNaive);
std::size_t flyweight_bytes = N * sizeof(ItemFlyweight)
+ U * sizeof(HeavyConfig);
std::cout << "N=" << N << " U=" << U
<< " dedup_ratio=" << ratio << ":1\n"
<< " naive: " << naive_bytes << " B\n"
<< " flyweight: " << flyweight_bytes << " B\n"
<< " saving: "
<< (naive_bytes - flyweight_bytes) * 100 / naive_bytes
<< "%\n";
// N=100000, U=100, ratio=1000:1 =>
// naive: 8 400 000 B
// flyweight: 2 408 000 B (~71% saving)
// N=100000, U=50000, ratio=2:1 =>
// naive: 8 400 000 B
// flyweight: 5 800 000 B (~31% saving — smaller win)
}
// sizeof comparisons
static_assert(sizeof(ItemNaive) == 84 || true, "platform-dependent");
static_assert(sizeof(ItemFlyweight) == 24 || true, "shared_ptr + uint32_t + pad");Rule of thumb: flyweight pays off when the deduplication ratio exceeds 10:1 and intrinsic state is larger than 64 bytes. Below that threshold, the indirection cost (cache miss on the shared_ptr dereference, atomic ref-count traffic) can outweigh the memory saved. Profile with pool.size() vs objects.size() to verify the ratio in production data before applying the pattern.
See Also
- RAII — the factory's destructor defines the boundary of flyweight validity
- Type Erasure — hide concrete flyweight types behind a stable interface
- Policy-Based Design — parameterize eviction or construction strategy without virtual dispatch