The Flyweight Pattern in C++: Sharing Intrinsic State to Cut Memory in Text and Tile Maps
Key takeaways
Flyweight stores the data that many objects have in common (intrinsic state) once, and keeps only the per-object differences (extrinsic state) in each object or passes them in at call time. It only pays off with many objects and a large shared part, the shared part must be immutable, and the cheapest handle is often a small index rather than a shared_ptr.
The problem Flyweight solves
Imagine a scene with ten thousand trees. If every Tree object owns its own copy of the texture and mesh, memory grows with the number of trees even though only the position differs between them:
// Every tree carries its own copy of the heavy data
class Tree {
Texture texture_; // large, identical for every oak
Mesh mesh_; // large, identical for every oak
float x_, y_; // the only thing that actually differs
};
Flyweight splits the object in two. The intrinsic state is the part that is identical across many objects and does not depend on context (texture, mesh, glyph bitmap, tile properties). It is stored once and shared. The extrinsic state is what varies per use (position, color tint, rotation) and stays with each object or is passed in when an operation runs:
class TreeType { // intrinsic, shared
Texture texture_;
Mesh mesh_;
};
class Tree { // extrinsic, one per tree
const TreeType* type_; // 8 bytes on a 64-bit system
float x_, y_;
};
Now the heavy data exists once per kind of tree, and each tree costs a pointer plus two floats. The saving is roughly (number of objects - number of kinds) * size of intrinsic state, so the pattern only matters when both numbers are large: many objects, and a shared part that is big compared with the per-object part. With a hundred objects and a few bytes of shared data, Flyweight adds indirection and a factory for nothing.
A useful way to decide what is intrinsic: if changing the value for one object would be a bug for all the others, it is intrinsic. If two objects of the same kind can legitimately disagree about it, it is extrinsic.
Basic structure: a glyph cache
Text rendering is the textbook case. A document can contain millions of characters but only a few hundred distinct glyphs.
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
// Intrinsic state: identical for every occurrence of the character
struct Glyph {
const char ch;
const int width, height;
const std::string bitmap; // stands in for real rasterized data
Glyph(char c, int w, int h)
: ch(c), width(w), height(h), bitmap(w * h, '#') {
std::cout << "Creating Glyph '" << ch << "'\n";
}
};
class GlyphFactory {
std::unordered_map<char, std::shared_ptr<const Glyph>> cache_;
public:
std::shared_ptr<const Glyph> get(char c) {
auto it = cache_.find(c);
if (it != cache_.end()) {
std::cout << "Reusing Glyph '" << c << "'\n";
return it->second;
}
auto g = std::make_shared<const Glyph>(c, 8, 16);
cache_[c] = g;
return g;
}
std::size_t size() const { return cache_.size(); }
};
// Extrinsic state (position) is passed in at call time
void draw(const Glyph& g, int x, int y) {
std::cout << "Draw '" << g.ch << "' at (" << x << "," << y << ")\n";
}
int main() {
GlyphFactory factory;
std::string text = "HELLO WORLD";
int x = 0;
for (char c : text) {
if (c != ' ') draw(*factory.get(c), x, 0);
x += 8;
}
std::cout << "\nTotal unique glyphs: " << factory.size() << '\n';
}
Output (abridged):
Creating Glyph 'H'
Draw 'H' at (0,0)
Creating Glyph 'E'
Draw 'E' at (8,0)
Creating Glyph 'L'
Draw 'L' at (16,0)
Reusing Glyph 'L'
Draw 'L' at (24,0)
Creating Glyph 'O'
Draw 'O' at (32,0)
...
Total unique glyphs: 7
Eleven characters, seven glyph objects (H, E, L, O, W, R, D). The members are const and the factory hands out shared_ptr<const Glyph>, so no caller can change a shared glyph. Notice that the glyph does not store its position; draw receives it. That is the essential move of the pattern.
Fonts shared by many characters
The same idea at a coarser grain: characters in a document share a small number of font objects keyed by name and size.
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
class Font {
std::string name_;
int size_;
std::string fontData_; // stands in for megabytes of font tables
public:
Font(std::string name, int size)
: name_(std::move(name)), size_(size), fontData_(1'000'000, 'F') {
std::cout << "Loading font: " << name_ << " " << size_ << "pt\n";
}
void render(char c, int x, int y) const {
std::cout << "Render '" << c << "' with " << name_
<< " at (" << x << "," << y << ")\n";
}
};
class FontFactory {
std::unordered_map<std::string, std::shared_ptr<const Font>> fonts_;
public:
std::shared_ptr<const Font> getFont(const std::string& name, int size) {
std::string key = name + "_" + std::to_string(size);
auto it = fonts_.find(key);
if (it != fonts_.end()) return it->second;
auto font = std::make_shared<const Font>(name, size);
fonts_.emplace(std::move(key), font);
return font;
}
};
// Context object: the extrinsic state plus a handle to the flyweight
class Character {
char ch_;
int x_, y_;
std::shared_ptr<const Font> font_;
public:
Character(char ch, int x, int y, std::shared_ptr<const Font> font)
: ch_(ch), x_(x), y_(y), font_(std::move(font)) {}
void draw() const { font_->render(ch_, x_, y_); }
};
int main() {
FontFactory factory;
std::vector<Character> text;
text.emplace_back('H', 0, 0, factory.getFont("Arial", 12));
text.emplace_back('i', 10, 0, factory.getFont("Arial", 12)); // reused
text.emplace_back('!', 20, 0, factory.getFont("Times", 14));
for (const auto& ch : text) ch.draw();
}
Loading font: Arial 12pt
Loading font: Times 14pt
Render 'H' with Arial at (0,0)
Render 'i' with Arial at (10,0)
Render '!' with Times at (20,0)
The key string is the part to be careful with. Everything that makes two fonts different (family, size, weight, style) has to be in the key, or two different fonts will collide and one will silently be returned for the other. A struct key with a hash function is safer than string concatenation once there are more than two fields.
Tile maps: the handle can be a single byte
Game maps are the other classic case, and they show that the “pointer to the flyweight” does not have to be a pointer at all. A tile’s position is already implied by where it sits in the grid, and there are only a handful of tile kinds, so each cell can store a one-byte index into a table of kinds:
#include <cctype>
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
// Flyweight: everything that is the same for every tile of one kind
struct TileType {
std::string name;
std::string texturePath; // stands in for a large texture
bool walkable;
};
class TileMap {
public:
using TypeId = std::uint8_t;
TileMap(int width, int height)
: width_(width), height_(height), cells_(width * height) {
const TypeId wall = addType({"wall", "wall.png", false});
const TypeId grass = addType({"grass", "grass.png", true});
for (int y = 0; y < height_; ++y)
for (int x = 0; x < width_; ++x) {
bool edge = x == 0 || y == 0 || x == width_ - 1 || y == height_ - 1;
cells_[index(x, y)] = edge ? wall : grass;
}
}
const TileType& at(int x, int y) const { return types_[cells_[index(x, y)]]; }
bool isWalkable(int x, int y) const { return at(x, y).walkable; }
void render() const {
for (int y = 0; y < height_; ++y) {
for (int x = 0; x < width_; ++x)
std::cout << '[' << static_cast<char>(std::toupper(at(x, y).name[0])) << ']';
std::cout << '\n';
}
}
private:
TypeId addType(TileType t) {
types_.push_back(std::move(t));
return static_cast<TypeId>(types_.size() - 1);
}
int index(int x, int y) const { return y * width_ + x; }
int width_, height_;
std::vector<TileType> types_; // the shared intrinsic state
std::vector<TypeId> cells_; // one byte per tile
};
int main() {
TileMap map(10, 5);
map.render();
std::cout << "walkable(1,1): " << map.isWalkable(1, 1) << '\n';
std::cout << "walkable(0,0): " << map.isWalkable(0, 0) << '\n';
std::cout << "sizeof(std::shared_ptr<TileType>) = " << sizeof(std::shared_ptr<TileType>) << '\n';
std::cout << "sizeof(TileMap::TypeId) = " << sizeof(TileMap::TypeId) << '\n';
}
Output with GCC on x86-64:
[W][W][W][W][W][W][W][W][W][W]
[W][G][G][G][G][G][G][G][G][W]
[W][G][G][G][G][G][G][G][G][W]
[W][G][G][G][G][G][G][G][G][W]
[W][W][W][W][W][W][W][W][W][W]
walkable(1,1): 1
walkable(0,0): 0
sizeof(std::shared_ptr<TileType>) = 16
sizeof(TileMap::TypeId) = 1
Fifty tiles, two TileType objects. Compared with a version where every tile object stores its own x, y and a shared_ptr<TileType>, this layout drops the per-tile cost from over 20 bytes to 1, removes the atomic reference-count update every time a tile handle is copied, and keeps the grid contiguous, which is friendlier to the cache when you iterate over it. The trade-off is that the index only means something together with its TileMap, and you are limited to 256 kinds with uint8_t; widen the type if you need more.
The table is a std::vector that is filled once in the constructor. If kinds could be added later, remember that push_back can reallocate, which invalidates any TileType& a caller is holding. Indices survive reallocation; references and pointers do not.
Pitfalls
Mutating shared state
auto glyph = factory.get('A');
glyph->width = 20; // changes every 'A' in the document
Once an object is shared, a “local” change is a global change, and it tends to show up far from the line that caused it. Make the flyweight immutable: const members, only const member functions, and hand out shared_ptr<const T> or const T&. With the glyph above, that line fails to compile with an error along the lines of assignment of read-only member 'Glyph::width', which is exactly what you want. If some objects genuinely need a variant (a bold A), that variant is a different flyweight with a different key, not a modified copy.
The mistake I see most often here is subtler than direct assignment. Someone adds a mutable cache field or a “last drawn at” position to the shared class because it is convenient, and the pattern silently stops being a flyweight: two objects rendered in the same frame now overwrite each other’s value. If a field is written per use, it is extrinsic by definition, and it belongs in the context object or the call arguments.
A cache that only grows
A factory holding shared_ptrs keeps every flyweight alive for the lifetime of the factory, even when nothing uses it anymore. For glyphs of a fixed font that is fine. For textures loaded as the player walks through a large world, it is a slow leak. Holding weak_ptrs lets unused objects die:
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
struct Texture {
std::string path;
explicit Texture(std::string p) : path(std::move(p)) { std::cout << "load " << path << '\n'; }
~Texture() { std::cout << "unload " << path << '\n'; }
};
class TextureCache {
public:
std::shared_ptr<const Texture> get(const std::string& path) {
auto& slot = cache_[path];
if (auto sp = slot.lock()) return sp;
auto sp = std::make_shared<const Texture>(path);
slot = sp;
return sp;
}
void purgeExpired() {
for (auto it = cache_.begin(); it != cache_.end();)
it = it->second.expired() ? cache_.erase(it) : std::next(it);
}
std::size_t entries() const { return cache_.size(); }
private:
std::unordered_map<std::string, std::weak_ptr<const Texture>> cache_;
};
int main() {
TextureCache cache;
{
auto a = cache.get("grass.png");
auto b = cache.get("grass.png");
std::cout << "same object: " << (a == b) << '\n';
}
std::cout << "entries after release: " << cache.entries() << '\n';
cache.purgeExpired();
std::cout << "entries after purge: " << cache.entries() << '\n';
}
load grass.png
same object: 1
unload grass.png
entries after release: 1
entries after purge: 0
Two details are easy to miss. First, the texture is unloaded as soon as the last user releases it, even though the map entry is still there; weak_ptr does not keep the object alive. That can cause “thrashing” where an object is loaded, dropped, and loaded again every frame, so for data that is expensive to load you may want an LRU policy that keeps recently used items alive. Second, the expired weak_ptr entries themselves stay in the map until something removes them, which is why purgeExpired exists. Also note that make_shared puts the object and its control block in one allocation, so with outstanding weak_ptrs the memory block is freed only when the last weak reference goes away, although the destructor has already run.
Too many extrinsic parameters
If every call needs position, color, rotation, scale and clip rectangle, passing eight arguments gets unwieldy. Bundle them:
struct RenderContext {
int x, y;
Color color;
float rotation, scale;
};
void draw(const Glyph& g, const RenderContext& ctx);
Thread safety of the factory
Reading an immutable flyweight from many threads is safe. The factory is not: two threads calling get for a new key both modify the map. Guard lookups and insertions with a mutex, or populate the factory up front before worker threads start and treat it as read-only afterwards.
When lookups vastly outnumber insertions, a std::shared_mutex lets readers proceed in parallel:
std::shared_ptr<const Glyph> get(char c) {
{
std::shared_lock lock(mutex_); // many readers at once
if (auto it = cache_.find(c); it != cache_.end()) return it->second;
}
std::unique_lock lock(mutex_); // one writer
auto [it, inserted] = cache_.try_emplace(c, nullptr);
if (inserted) it->second = std::make_shared<const Glyph>(c, 8, 16);
return it->second;
}
The second lookup under the exclusive lock is not redundant. Between releasing the shared lock and acquiring the exclusive one, another thread may have inserted the same key, and try_emplace handles that case by leaving the existing entry alone. Constructing the glyph while holding the exclusive lock blocks every reader for the duration; if construction is expensive (loading a file, rasterizing), that stall shows up as a hitch in every thread that needs any glyph. The usual remedy is to build the object outside the lock and insert it afterwards, accepting that two threads may occasionally build the same object and one copy is discarded.
Existing implementations
Before writing a factory, check whether the problem is simply “many equal strings”. String interning (a table of unique strings, with objects storing an index or a std::string_view into it) is Flyweight by another name, and it is common in compilers, loggers and config systems. Boost.Flyweight packages the whole pattern: boost::flyweight<std::string> behaves like a const std::string but stores each distinct value once, with policies for the factory (hashed or set-based), locking (none or mutex) and tracking (reference-counted, so unused values are removed, or never removed). It is a reasonable default when the intrinsic state is a regular value type and you do not need a custom handle such as the one-byte tile index above.
When Flyweight is and is not worth it
Use it when all of these hold: there are many objects, a large part of each object is identical across a group, that part can be made immutable, and the per-object part is small or computable from context (like a grid position). Glyphs, fonts, tile kinds, particle templates, and interned strings are the usual candidates.
Skip it when objects are few, when most state is unique, or when the “shared” part is small enough that a handle to it costs about as much as the data. Measure before and after with a real workload; sizeof and the number of distinct keys are usually enough to predict whether it will help.