“Reflection is the beginning of reform.”
— Mark Twain
1. Introduction
Formatting an enumeration as its underlying type currently requires either a
formatter specialization or an explicit conversion at every call site.
[P3070] proposed , an ADL-based customization point that makes
this more convenient and allows the conversion to be performed before type
erasure.
Since P3070 was written, C++26 has gained reflection ([P2996]) and annotations ([P3394]). These provide a more ergonomic way to opt into formatting behavior without introducing a new ADL customization point.
This paper supersedes P3070 and proposes annotation-based enum formatting, including formatting enumerators as strings.
The underlying-type formatting proposal also makes formattable by
annotating its definition, so that it supports integer formatting and is mapped
to its underlying type before type erasure.
2. Motivation and scope
Consider the following from P3070:
namespace kevin_namespacy { enum class film { house_of_cards , american_beauty , se7en = 7 }; }
Formatting it as its underlying type currently requires a
specialization:
template <> struct std :: formatter < kevin_namespacy :: film > : std :: formatter < int > { auto format ( kevin_namespacy :: film f , std :: format_context & ctx ) const { return formatter < int >:: format ( std :: to_underlying ( f ), ctx ); } };
This is unnecessarily verbose for a common operation.
Another option is to perform the conversion at the call site:
film f = kevin_namespacy :: se7en ; auto s = std :: format ( "{}" , std :: to_underlying ( f ));
This reduces formatting overhead but requires the conversion to be repeated at every call site.
P3070 tried to address both problems with an ADL customization:
namespace kevin_namespacy { enum class film {...}; auto format_as ( film f ) { return std :: to_underlying ( f ); } }
This is more concise and allows the formatting implementation to convert the enum before type erasure. However, ADL has well-known drawbacks and is no longer necessary now that C++ has annotations.
3. Problems with format_as
3.1. ADL claims a generic name
is found by argument-dependent lookup. As a result, the otherwise
generic name effectively becomes a formatting customization name in
associated namespaces.
For example:
namespace lib { struct widget {}; // An existing function unrelated to std::format. int format_as ( widget ); }
P3070 can cause this function to acquire formatting semantics even though it was not written as a formatting customization.
This is not an explicit opt-in.
[P2279] discusses this problem for ADL-based customization points and notes that ADL effectively requires globally reserving the customization identifier. Two libraries cannot independently use the same ADL customization name without the possibility of interaction.
An annotation does not have this problem:
enum class [[ = std :: format_as_underlying ]] film { // ... };
The formatting behavior is explicitly attached to the declaration it affects.
3.2. ADL pollutes the overload set
ADL adds functions from associated namespaces and classes to the overload set. For a class template specialization, associated entities can include the types of its template arguments, making the lookup nonlocal.
For example, a call involving a type such as:
wrapper < some_namespace :: widget >
can consider overloads from both the namespace containing
and the namespace containing .
This can increase compile time and make diagnostics include unrelated overload candidates.
[P2547] discusses a more severe version of the same problem for
, where large ADL overload sets increase compilation cost and
diagnostic size. is less problematic because only overloads with one
name participate, but there is no benefit in introducing this cost when the
customization can be expressed without overload resolution.
Annotations require neither lookup nor overload resolution.
3.3. format_as requires unnecessary boilerplate for enums
For the common case of formatting an enum as its underlying type, the conversion is already defined by the language. With format_as, the user must still define a function that merely performs this conversion:
constexpr auto format_as ( color c ) { return std :: to_underlying ( c ); }
An annotation can express the intent directly, without requiring a user-defined conversion function.
3.4. General format_as needs further design
P3070 proposes as a general customization mechanism for
user-defined types, not just enumerations. That generality is useful, but the
proposed design leaves broader questions about interactions with existing
formatting customizations, constraints on the result type, and exception
specifications such as .
These issues should be addressed before standardizing a general
facility. The annotation-based approach proposed here can be extended to
user-defined types in the future once these design questions have been
resolved.
The motivating enum use case does not require arbitrary conversion. Conversion from an enum to its underlying type is defined by the language, nonthrowing, and has no side effects.
4. Argument mapping is not formatter forwarding
P3070 states that the semantics of are the same as those of a
forwarding specialization. This is not quite true when the
conversion is performed before type erasure.
Consider:
enum class width { small = 4 , large = 20 }; auto format_as ( width w ) { return std :: to_underlying ( w ); }
With argument mapping this can be used as:
std :: format ( "{:{}}" , "foo" , width :: large );
because is converted to an integer before construction of
.
A forwarding formatter does not have this property:
template <> struct std :: formatter < width > : std :: formatter < int > { // ... };
Dynamic width and precision require the corresponding format argument to have an appropriate integer formatting argument type. A custom formatter does not make the type an integer format argument.
Therefore there are two observably different facilities:
-
forwarding formatting of
toE ; andformatter < underlying_type_t < E >> -
mapping
toE as a formatting argument.underlying_type_t < E >
This paper deliberately proposes the second.
5. Formatting as the underlying type
This paper proposes the annotation:
[[ = std :: format_as_underlying ]]
which can be applied to an enumeration definition:
enum class [[ = std :: format_as_underlying ]] color { red = 1 , green = 2 , blue = 4 };
For formatting purposes, a value of such an enum is mapped as if
had been applied before passing the argument to the
formatting facility.
For example:
auto s1 = std :: format ( "{}" , color :: green ); // "2" auto s2 = std :: format ( "{:04x}" , color :: blue ); // "0004"
The mapping also applies when an argument is used as dynamic width or precision:
enum class [[ = std :: format_as_underlying ]] width { small = 4 , large = 10 }; auto s = std :: format ( "{:{}}" , 42 , width :: large ); // s == " 42"
This is an intentional part of the semantics and allows the conversion to be performed before type erasure, improving efficiency.
The mapped value is type-erased as its mapped type, avoiding the custom-type handle and formatter dispatch.
5.1. std :: byte
As part of the underlying-type formatting proposal, opts in by
applying the proposed annotation to its definition:
enum class [[ = std :: format_as_underlying ]] byte : unsigned char {};
This gives the ordinary integer format specifiers and maps it to its
underlying type before type erasure. For example:
std :: format ( "{}" , std :: byte { 42 }); // "42" std :: format ( "{:x}" , std :: byte { 42 }); // "2a"
retains its type safety outside formatting while behaving like its
underlying integer where the formatting facility specifically requires an
integer argument.
P3070 showed that mapping an enum to a built-in formatting argument before type erasure can be approximately twice as fast as dispatching through a custom formatter for this use case. The same speedup applies to this proposal because it uses the same argument-mapping mechanism.
--------------------------------------------------------------------- Benchmark Time CPU Iterations --------------------------------------------------------------------- BM_Formatter 17.7 ns 17.7 ns 38037070 BM_FormatAs 8.90 ns 8.88 ns 79036210
6. Lightweight opt-in header
Formatting annotations appear on type declarations, which are commonly placed in widely included headers.
Requiring merely to name an annotation would therefore impose the
compile-time cost of on translation units that do not use formatting:
// color.h #include <format>enum class [[ = std :: format_as_underlying ]] color { red , green , blue };
This paper instead proposes a lightweight header
containing the annotation marker objects:
#include <format_annotations>enum class [[ = std :: format_as_underlying ]] color { red , green , blue };
includes .
The intended dependency is therefore:
<format_annotations>
↑
<format>
The lightweight header does not need to include or . It only
declares the annotation values used at the type declaration.
This is particularly important for declaration-site customization because the cost of the customization mechanism should not be paid by every translation unit that sees the declaration.
7. Formatting as an identifier
Reflection enables another common enum formatting mode that does not require user-written conversion code.
This paper additionally proposes:
[[ = std :: format_as_identifier ]]
For example:
enum class [[ = std :: format_as_identifier ]] color { red , green , blue }; auto s = std :: format ( "{}" , color :: green ); // "green"
The implementation obtains the enumerators with and
their names with .
This is substantially more convenient than writing:
auto format_as ( color c ) -> std :: string_view { switch ( c ) { case color :: red : return "red" ; case color :: green : return "green" ; case color :: blue : return "blue" ; } }
and cannot become stale when enumerators are added.
7.1. Aliases
Multiple enumerators can have the same value:
enum class [[ = std :: format_as_identifier ]] status { ok = 0 , success = 0 };
When multiple enumerators have the same value, the first matching enumerator is used:
std :: format ( "{}" , status :: success ); // "ok"
There is no way to distinguish the two values at runtime.
7.2. Values without a matching enumerator
An enum value is not necessarily the value of an enumerator:
auto c = static_cast < color > ( 42 );
If no enumerator has the value being formatted, the value is represented as the decimal value of the underlying type.
The resulting representation follows string formatting semantics:
std :: format ( "{:>5}" , static_cast < color > ( 42 )); // " 42"
Integer presentation specifiers such as are not accepted for
. Whether the value happens to match an enumerator is a
runtime property and cannot change the grammar accepted by the formatter.
Users who want integer formatting should use instead.
7.3. Interaction between the annotations
and specify different
representations and are mutually exclusive.
The following is ill-formed:
enum class [[ = std :: format_as_underlying ]] [[ = std :: format_as_identifier ]] color { red , green , blue };
The two parts of this proposal are otherwise independent and can be polled separately.
8. Why annotations?
Annotations have several useful properties for this facility.
First, the opt-in is local and explicit:
enum class [[ = std :: format_as_underlying ]] color { // ... };
A reader can determine the enum’s default formatting behavior from its declaration.
Second, annotations do not introduce a customization name into associated namespaces. There is no overload set, no ADL and no possibility that an unrelated function accidentally becomes part of the formatting protocol.
Third, annotations are well suited to properties that apply to a declaration as a whole. P3394 motivates annotations with declaration-level library customization such as serialization, command-line parsing and test parametrization. Formatting is another instance of the same pattern.
Finally, reflection makes identifier-based formatting possible without code generation or manually maintained lookup tables.
9. Prior art
Declaration-site opt-in for generated textual representations is common in other languages.
Rust provides ([RUST-DEBUG]):
#[derive(Debug)] enum Color { Red , Green , Blue , }
Elixir provides @ ([ELIXIR-DERIVE]), and Groovy provides
@ ([GROOVY-TOSTRING]). These mechanisms differ in implementation
but have the same general property: textual representation behavior is
requested declaratively on the type.
Other languages also distinguish symbolic and numeric enum representations. For example, C# enum formatting provides both name-oriented and numeric forms ([CSHARP-ENUM]).
The proposed annotations bring a similar declarative model to C++ using the C++26 reflection facilities.
10. Implementation experience
The underlying-type annotation can use the same format argument mapping that
{fmt} already uses for arithmetic results, except that the
conversion is fixed to and cannot invoke arbitrary user
code.
{fmt} also has a prototype for formatting enumerators as identifiers:
enum class [[ = fmt :: as_identifiers ]] color { red , green , blue };
The implementation uses C++26 reflection to retrieve the enumerators and their identifiers, with the annotation providing the opt-in.
11. Proposed change
Add a new header with:
namespace std { struct format_as_underlying_t {}; inline constexpr format_as_underlying_t format_as_underlying ; struct format_as_identifier_t {}; inline constexpr format_as_identifier_t format_as_identifier ; }
includes .
An enumeration whose definition is annotated with
is formatted by mapping the value to its underlying type before the normal
format argument mapping is applied.
As part of this proposal, the definition of is annotated with
.
An enumeration whose definition is annotated with
is formatted using the identifier of the first enumerator with the same value.
If there is no such enumerator, its underlying value is converted to decimal.
Identifier formatting uses string format specifications.
An enumeration cannot have both annotations.
The first change applies to both and formatting because it
delegates to existing integral formatting.
Identifier formatting initially applies to formatting.
produces a narrow string representation;
wide-character identifier formatting requires additional encoding specification
and can be added separately if there is demand.
12. Wording
Add a new subclause [format.annotations] and header synopsis:
// <format_annotations> namespace std { struct format_as_underlying_t {}; inline constexpr format_as_underlying_t format_as_underlying ; struct format_as_identifier_t {}; inline constexpr format_as_identifier_t format_as_identifier ; }
For an enumeration type , let format-as-underlying be true if an
annotation whose underlying constant has type
appertains to the definition of , and false otherwise.
Let format-as-identifier be defined analogously for .
An enumeration type shall not be both format-as-underlying and format-as-identifier.
Modify the format argument mapping in
[format.arg] so that if
is an enumeration type for which format-as-underlying is
true, is first mapped to and normal format argument
mapping is then applied to the result.
[Note: As a consequence, an enumeration annotated with
can be used where the formatting facility requires an integer argument,
including dynamic width and precision. — end note]
Add an enabled specialization for identifier formatting:
template < class E > requires ( is_enum_v < E > && format - as - identifier < E > ) struct formatter < E , char > { private : formatter < string_view , char > fmt_ ; // exposition only public : constexpr format_parse_context :: iterator parse ( format_parse_context & ctx ); template < class FormatContext > typename FormatContext :: iterator format ( E value , FormatContext & ctx ) const ; };
constexpr format_parse_context :: iterator parse ( format_parse_context & ctx );
Returns: .
template < class FormatContext > typename FormatContext :: iterator format ( E value , FormatContext & ctx ) const ;
Let be the first element of whose
value compares equal to .
If such an element exists, let be . Otherwise let
be the decimal character representation of with no
additional formatting options.
Returns: .
Modify the definition of in
[cstddef.syn] as follows:
enum class [[ = format_as_underlying ]] byte : unsigned char {};
13. Feature-test macro
Set the value of to a value corresponding to the date
of adoption of this proposal.
14. Acknowledgements
Thanks to Avi Kivity for implementing annotation-based enumerator identifier formatting in {fmt} and providing valuable implementation experience.
Thanks to Matthias Wippich for suggesting annotations as an opt-in mechanism for enum stringification.