How fmtlib Handles Arguments and Performs Type Erasure in C++
{fmt} passes all formatting arguments through a lightweight type-erasure pipeline so the formatting engine can process heterogeneous C++ types uniformly using a compact, contiguous array of tagged values.
The fmtlib/fmt library achieves high performance and type safety by decoupling argument storage from the formatting engine. When you call fmt::format(), your values are transformed into a portable, type-erased representation that the formatter traverses at runtime. This article examines the complete argument handling pipeline in the {fmt} source code, from ingestion to the final formatting call.
The Three Core Components of Argument Handling
{fmt} implements type erasure through three tightly integrated classes, each defined in a specific header file.
basic_format_arg — The Type-Erased Value Container
The fundamental unit of argument storage is basic_format_arg<Context>, defined in include/fmt/core.h. This small object combines a value union with a type tag (detail::type) that tells the formatter how to interpret the stored bits.
The union inside basic_format_arg can hold:
- Integral types (
int_type,uint_type,long_long_type, etc.) - Floating-point values (
double_type,long_double_type) - Pointers and strings (
pointer_type,cstring_type,string_view_type) - Custom types via a
custom_valuehandle - Named argument references
When constructed, basic_format_arg receives the actual value (or pointer) plus a compile-time-deduced detail::type enum value. This tag completely replaces compile-time type information with a runtime identifier that the formatting engine checks when extracting values.
basic_format_args — The Lightweight View
The basic_format_args<Context> class in include/fmt/format.h provides a non-owning view over a contiguous array of basic_format_arg objects. It stores:
- A pointer to the first argument (
data_) - The argument count (
size_) - A boolean flag indicating named argument presence (
named_)
This design follows the C++ Core Guidelines principle of separating ownership from access. The view is what low-level functions like vformat() and format_to() actually consume—no templates, no heavy machinery, just pointer arithmetic over type-tagged values.
dynamic_format_arg_store — The Dynamic Builder
When argument count or types are unknown at compile time, dynamic_format_arg_store<Context> in include/fmt/args.h builds the basic_format_arg array dynamically. This class manages two separate storage arenas:
data_— astd::vector<basic_format_arg<Context>>holding the type-erased argument descriptorsdynamic_args_— adetail::dynamic_arg_listlinked list owning any heap-allocated objects that cannot fit directly in thebasic_format_argunion
The store's critical responsibility is deciding whether each incoming argument needs copying or can be referenced.
Copy vs. Reference: The need_copy Decision
Inside dynamic_format_arg_store::push_back<T>() (lines 78-90 of args.h), the trait need_copy<T> makes the storage determination:
| Condition | Behavior |
|---|---|
need_copy<T>::value == false |
Store reference directly in basic_format_arg; no heap allocation |
need_copy<T>::value == true |
Allocate typed_node<T> in dynamic_args_ linked list, store reference in vector |
A copy is required for:
- Non-reference-wrapper types that aren't simple string views
- Custom types not already
basic_format_arg-compatible - Values that would dangle if referenced (temporary objects)
The dynamic_arg_list uses a node hierarchy where each typed_node<T> contains the actual storage plus type-erased deallocation logic. This lets {fmt} handle arbitrarily complex types while keeping the hot-path basic_format_arg vector compact and cache-friendly.
The Complete Argument Pipeline
Here's how the pieces flow together in a typical formatting call:
-
Argument ingestion —
fmt::format("{:d} {}", 42, "hello")instantiatesformat_string<T...>and forwards each argument throughdetail::make_arg<T>()via thefmt::arghelper. -
Type erasure — Each
basic_format_argconstructor receives the value and itsdetail::typetag (e.g.,type::int_type,type::cstring_type). The original C++ type information is discarded; only the tag remains. -
View creation —
dynamic_format_arg_storeexposesoperator basic_format_args<Context>() const, which constructs a view pointing atdata_.data(). -
Formatting execution —
vformat()or equivalent iterates the view, reads each argument's type tag, extracts the appropriate union member, and applies format specifications. -
Named argument handling — When
fmt::arg("name", value)is used, the store inserts a placeholder at index 0 and maintains a parallelnamed_info_vector mapping names to indices. Thenamedflag inbasic_format_argssignals the formatter to resolve{name}references.
Working with Type-Erased Arguments: Code Examples
Basic Compile-Time Formatting
#include <fmt/format.h>
// Type erasure happens automatically behind this simple API
std::string s = fmt::format("The answer is {} and {}", 42, 3.14);
// Result: "The answer is 42 and 3.14"
Even this straightforward call internally populates a dynamic_format_arg_store, converts to basic_format_args, and dispatches through vformat.
Dynamic Store for Runtime Argument Counts
#include <fmt/args.h>
#include <fmt/format.h>
#include <functional> // std::cref
fmt::dynamic_format_arg_store<fmt::format_context> store;
store.push_back(42); // copied
store.push_back(std::string("hello")); // copied (via node)
store.push_back(std::cref(s)); // referenced, no copy
// Convert to view and format
std::string result = fmt::vformat("{} – {}", store);
// Result: "42 – hello"
Named Arguments
#include <fmt/args.h>
#include <fmt/format.h>
auto args = fmt::make_format_args(
fmt::arg("x", 10),
fmt::arg("y", std::string_view{"world"}));
std::string txt = fmt::vformat("{x} says {y}", args);
// Result: "10 says world"
All three examples compile to the same underlying mechanism: a type-erased argument store converted to a lightweight view that the formatting engine processes uniformly.
Key Design Trade-offs
The {fmt} argument handling architecture makes deliberate engineering choices:
- Contiguous storage for the hot path — The
basic_format_argvector minimizes cache misses during format string parsing. - Separate dynamic storage for cold data — Only values requiring allocation hit the linked list; simple types stay inline.
- Type tags instead of RTTI — The
detail::typeenum provides fast switching withouttypeidoverhead. - Views instead of owning containers —
basic_format_argsenables zero-cost passing to internal functions.
These decisions align with {fmt}'s goal of matching or exceeding printf performance while maintaining full type safety.
Summary
basic_format_argininclude/fmt/core.hholds type-erased values via a tagged union, removing original C++ type information at runtime.basic_format_argsininclude/fmt/format.hprovides a non-owning view over argument arrays for uniform formatter consumption.dynamic_format_arg_storeininclude/fmt/args.hbuilds argument collections dynamically, usingneed_copy<T>to decide between referencing and heap-allocating values.- The copy-or-reference decision keeps simple arguments inline while safely managing complex or temporary objects through
dynamic_arg_list. - The named argument mechanism uses index 0 placeholders and a separate name-to-index map, signaled via the
namedflag in the view.
Frequently Asked Questions
How does {fmt} avoid virtual function overhead in type erasure?
Rather than using virtual functions or std::any, {fmt} stores arguments in a union with an explicit detail::type enum tag. The formatter switches on this integer tag to extract the correct union member—branch prediction and inlining make this faster than virtual dispatch or RTTI lookups.
Can I use dynamic_format_arg_store with custom types?
Yes. If your custom type has a formatter<T> specialization, dynamic_format_arg_store will store it via typed_node<T> in the dynamic_arg_list when need_copy<T> requires copying. The type erasure preserves enough information for the formatter to invoke your custom format() method via the stored function pointer.
Why does {fmt} use a linked list for dynamic storage instead of std::vector?
The dynamic_arg_list linked list in args.h allows heterogeneous types in a single container without std::any's type-erasure overhead. Each typed_node<T> knows its own deallocation size, enabling type-safe cleanup without virtual destructors. This isolates allocation costs to only those arguments that require it.
What's the performance cost of type erasure in {fmt}?
For primitive types and string views, the cost is negligible—the basic_format_arg vector stores values inline with no indirection. Heap allocation and indirect access only occur for types where need_copy<T> is true. Benchmarks show {fmt} matching or exceeding printf performance despite the type safety guarantees.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →