Format user defined types in format.h (#1470)

Co-authored-by: John Wellbelove <jwellbelove@users.noreply.github.com>
This commit is contained in:
Roland Reichwein 2026-06-24 11:27:53 +02:00 committed by GitHub
parent 8f4373e555
commit 5e5012c62c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 247 additions and 24 deletions

View File

@ -302,6 +302,89 @@ Requires `ETL_USING_FORMAT_FLOATING_POINT`.
`nan`, `inf` (lowercase for `e`/`f`/`g`/`a`, uppercase for `E`/`F`/`G`/`A`). `nan`, `inf` (lowercase for `e`/`f`/`g`/`a`, uppercase for `E`/`F`/`G`/`A`).
## Formatting User-Defined Types
Custom types can be made formattable by providing a specialisation of
`etl::formatter<T>`, mirroring the `std::formatter<T>` mechanism. Once a
specialisation is in scope, values of that type can be passed to `format_to`,
`format_to_n`, `formatted_size`, `print`, and `println` exactly like the
built-in types.
### The `etl::formatter` primary template
```cpp
template <class T, class CharT = char>
struct formatter;
```
The primary template is empty. A type `T` becomes formattable when you provide
a specialisation (in namespace `etl`) that defines two member functions:
| Member | Signature | Purpose |
|---|---|---|
| `parse` | `format_parse_context::iterator parse(format_parse_context& parse_ctx)` | Parses the format-spec portion of the replacement field (the characters after `:`, up to the closing `}`). Returns an iterator to the first unconsumed character, which must be the closing `}`. Return `parse_ctx.begin()` to consume nothing. |
| `format` | `template <class OutputIt> format_context<OutputIt>::iterator format(const T& value, format_context<OutputIt>& fmt_ctx)` | Writes the formatted representation of `value` through `fmt_ctx.out()` and returns the iterator past the last character written. |
### Context types
| Type | Description |
|---|---|
| `etl::format_parse_context` | Alias for `etl::basic_format_parse_context<char>`. Provides `begin()`, `end()`, and `advance_to()` over the format-spec characters. Its `iterator` is `etl::string_view::const_iterator`. |
| `etl::format_context<OutputIt>` | Alias for `etl::basic_format_context<OutputIt, char>`. Provides `out()` (the current output iterator) and `advance_to()`. Its `iterator` is `OutputIt`. |
### Example
```cpp
// A simple user-defined type.
struct coordinate
{
int x;
int y;
};
// Specialise etl::formatter for it. Here format() simply delegates to
// etl::format_to, writing the two members as "(x, y)".
namespace etl
{
template <>
struct formatter<coordinate>
{
format_parse_context::iterator parse(format_parse_context& parse_ctx)
{
return parse_ctx.begin();
}
template <class OutputIt>
typename format_context<OutputIt>::iterator format(const coordinate& c, format_context<OutputIt>& fmt_ctx)
{
return etl::format_to(fmt_ctx.out(), "({}, {})", c.x, c.y);
}
};
}
```
With the specialisation in scope, `coordinate` behaves like any other argument:
```cpp
etl::string<100> s;
coordinate c{3, 7};
etl::format_to(s, "{}", c); // "(3, 7)"
etl::format_to(s, "point={}", c); // "point=(3, 7)"
etl::format_to(s, "{0} and {0}", c); // "(3, 7) and (3, 7)"
etl::format_to(s, "{} {} {}", 'a', c, 5); // "a (3, 7) 5"
etl::formatted_size("{}", c); // 6
```
### How it works
When an argument type provides an `etl::formatter<T>` specialisation, ETL stores
the value type-erased inside `basic_format_arg::handle` rather than in one of the
built-in alternatives. During formatting the handle invokes the formatter's
`parse` and `format` members. Detection is automatic: a type is treated as a
custom type when `etl::formatter<T>` exposes a callable `parse()` member, so no
opt-in trait or registration is required.
## Escape Sequences and Literal Braces ## Escape Sequences and Literal Braces
### Literal braces ### Literal braces
@ -378,7 +461,7 @@ etl::format_to(s, "{:d}", sv); // invalid type for string_view
| **`etl::istring&` overload** | Not available | `format_to(etl::istring&, ...)` automatically resizes the string. | | **`etl::istring&` overload** | Not available | `format_to(etl::istring&, ...)` automatically resizes the string. |
| **`print` / `println` output** | Writes to `FILE*` / `stdout` | Writes character-by-character via user-defined `etl_putchar(int)`. | | **`print` / `println` output** | Writes to `FILE*` / `stdout` | Writes character-by-character via user-defined `etl_putchar(int)`. |
| **Floating-point support** | Always available | Opt-in via `ETL_USING_FORMAT_FLOATING_POINT`. | | **Floating-point support** | Always available | Opt-in via `ETL_USING_FORMAT_FLOATING_POINT`. |
| **User-defined formatters** | `std::formatter<T>` specialisations | Not yet supported. | | **User-defined formatters** | `std::formatter<T>` specialisations | Supported via `etl::formatter<T>` specialisations (see [Formatting User-Defined Types](#formatting-user-defined-types)). |
| **Locale** | `L` flag uses `std::locale` | `L` flag is parsed but has no effect. | | **Locale** | `L` flag uses `std::locale` | `L` flag is parsed but has no effect. |
| **Compile-time validation** | Enforced via `consteval` on C++20 | Planned; currently validates at run time and throws `etl::bad_format_string_exception`. | | **Compile-time validation** | Enforced via `consteval` on C++20 | Planned; currently validates at run time and throws `etl::bad_format_string_exception`. |
| **`format_to_n` return type** | `std::format_to_n_result` | Returns the underlying `OutputIt` directly. | | **`format_to_n` return type** | `std::format_to_n_result` | Returns the underlying `OutputIt` directly. |

View File

@ -513,21 +513,6 @@ namespace etl
template <class... Args> template <class... Args>
using format_string = basic_format_string<type_identity_t<Args>...>; using format_string = basic_format_string<type_identity_t<Args>...>;
// Supported types to format
//
// This is the limited number of types as defined in std::basic_format_arg
// https://en.cppreference.com/w/cpp/utility/format/basic_format_arg.html
//
// Further types to be supported are added via converting constructors in
// etl::basic_format_arg
using supported_format_types = etl::variant< etl::monostate, bool, char, int, unsigned int, long long int, unsigned long long int,
#if ETL_USING_FORMAT_FLOATING_POINT
float, double, long double,
#endif
const char*, etl::string_view, const void*
// basic_format_arg::handle,
>;
template <class CharT> template <class CharT>
class basic_format_parse_context class basic_format_parse_context
{ {
@ -595,25 +580,89 @@ namespace etl
using format_parse_context = basic_format_parse_context<char>; using format_parse_context = basic_format_parse_context<char>;
// Forward declaration of the formatter primary template (defined later). This
// lets basic_format_arg detect user-provided formatter specialisations for
// custom types and route them through basic_format_arg::handle.
template <class T, class CharT = char>
struct formatter;
// Forward declaration of the format context (defined later) so that
// is_formattable can probe for a usable formatter<T>::format() member, which
// is templated on the output iterator type.
template <class OutputIt, class CharT>
class basic_format_context;
namespace private_format
{
// Detects whether etl::formatter<T> exposes a usable parse() member.
template <typename T, typename = void>
struct has_formatter_parse : etl::false_type
{
};
template <typename T>
struct has_formatter_parse<T, etl::void_t<decltype(etl::declval<etl::formatter<T>&>().parse(etl::declval<format_parse_context&>()))> >
: etl::true_type
{
};
// Detects whether etl::formatter<T> exposes a usable format() member.
// format() is templated on the output iterator, so it is probed with a
// representative char* output iterator context.
template <typename T, typename = void>
struct has_formatter_format : etl::false_type
{
};
template <typename T>
struct has_formatter_format<T, etl::void_t<decltype(etl::declval<etl::formatter<T>&>().format(
etl::declval<const T&>(), etl::declval<etl::basic_format_context<char*, char>&>()))> > : etl::true_type
{
};
// The primary formatter template is empty, so a type is only formattable
// when a (user-)provided specialisation supplies both a usable parse() and
// a usable format() member - mirroring the std::formattable requirements.
template <typename T>
struct is_formattable : etl::bool_constant<has_formatter_parse<T>::value && has_formatter_format<T>::value>
{
};
} // namespace private_format
template <class Context> template <class Context>
class basic_format_arg class basic_format_arg
{ {
public: public:
// Type-erased wrapper that allows user-defined types to be formatted via an
// etl::formatter<T> specialisation, mirroring std::basic_format_arg::handle.
class handle class handle
{ {
public: public:
void format(etl::basic_format_parse_context<char>& /* parse_ctx */, Context& /*format_ctx*/) template <typename T>
explicit handle(const T& value)
: obj(static_cast<const void*>(etl::addressof(value)))
, func(&format_custom_type<T>)
{ {
// typename Context::template formatter_type<TD> f; }
// parse_ctx.advance_to(f.parse(parse_ctx));
// format_ctx.advance_to(f.format(const_cast<TQ&>(static_cast<const void format(etl::basic_format_parse_context<char>& parse_ctx, Context& format_ctx) const
// TD&>(ref)), format_ctx)); {
func(parse_ctx, format_ctx, obj);
} }
private: private:
template <typename T>
static void format_custom_type(etl::basic_format_parse_context<char>& parse_ctx, Context& format_ctx, const void* ptr)
{
const T& value = *static_cast<const T*>(ptr);
etl::formatter<T> f;
parse_ctx.advance_to(f.parse(parse_ctx));
format_ctx.advance_to(f.format(value, format_ctx));
}
const void* obj; const void* obj;
typedef void (*function_type)(etl::basic_format_parse_context<char>&, Context&, const void*); typedef void (*function_type)(etl::basic_format_parse_context<char>&, Context&, const void*);
function_type func; function_type func;
@ -725,6 +774,15 @@ namespace etl
{ {
} }
// Converting constructor for user-defined types that provide an
// etl::formatter<T> specialisation. The value is stored type-erased in a
// handle, matching the std::basic_format_arg behaviour for custom types.
template <typename T, typename = etl::enable_if_t<private_format::is_formattable<T>::value> >
basic_format_arg(const T& v)
: data(handle(v))
{
}
basic_format_arg& operator=(const basic_format_arg& other) basic_format_arg& operator=(const basic_format_arg& other)
{ {
data = other.data; data = other.data;
@ -744,7 +802,21 @@ namespace etl
private: private:
supported_format_types data; // Storage for the argument value.
//
// This is the limited number of types as defined in std::basic_format_arg
// https://en.cppreference.com/w/cpp/utility/format/basic_format_arg.html
//
// Further types to be supported are mapped onto these via the converting
// constructors above. User-defined types are stored type-erased in the
// special handle member, which formats them through their etl::formatter
// specialisation.
etl::variant< etl::monostate, bool, char, int, unsigned int, long long int, unsigned long long int,
#if ETL_USING_FORMAT_FLOATING_POINT
float, double, long double,
#endif
const char*, etl::string_view, const void*, handle >
data;
}; };
template <class Context, class... Args> template <class Context, class... Args>
@ -1157,7 +1229,7 @@ namespace etl
} }
} // namespace private_format } // namespace private_format
template <class T, class CharT = char> template <class T, class CharT>
struct formatter struct formatter
{ {
using char_type = CharT; using char_type = CharT;
@ -1906,7 +1978,7 @@ namespace etl
{ {
} }
// for all types in supported_format_types // for all the built-in alternatives stored in basic_format_arg
template <typename T> template <typename T>
void operator()(T value) void operator()(T value)
{ {
@ -1917,6 +1989,12 @@ namespace etl
fmt_ctx.advance_to(fit); fmt_ctx.advance_to(fit);
} }
// for user-defined types routed through basic_format_arg::handle
void operator()(typename basic_format_arg<format_context<OutputIt> >::handle h)
{
h.format(parse_ctx, fmt_ctx);
}
format_parse_context& parse_ctx; format_parse_context& parse_ctx;
format_context<OutputIt>& fmt_ctx; format_context<OutputIt>& fmt_ctx;
}; };

View File

@ -34,6 +34,38 @@ SOFTWARE.
#if ETL_USING_CPP11 #if ETL_USING_CPP11
namespace
{
// A simple user-defined type with two members, used to demonstrate the
// formatting of custom types through an etl::formatter specialisation.
struct coordinate
{
int x;
int y;
};
} // namespace
// Custom formatter specialisation for the user-defined 'coordinate' type.
// Like a std::formatter, it provides parse() and format(); here format()
// simply delegates to etl::format_to, writing the two members as "(x, y)".
namespace etl
{
template <>
struct formatter<coordinate>
{
format_parse_context::iterator parse(format_parse_context& parse_ctx)
{
return parse_ctx.begin();
}
template <class OutputIt>
typename format_context<OutputIt>::iterator format(const coordinate& c, format_context<OutputIt>& fmt_ctx)
{
return etl::format_to(fmt_ctx.out(), "({}, {})", c.x, c.y);
}
};
} // namespace etl
namespace namespace
{ {
using iterator = etl::back_insert_iterator<etl::istring>; using iterator = etl::back_insert_iterator<etl::istring>;
@ -928,6 +960,36 @@ namespace
CHECK_EQUAL("07", test_format(s, "{:#o}", 7)); CHECK_EQUAL("07", test_format(s, "{:#o}", 7));
CHECK_EQUAL("010", test_format(s, "{:#o}", 8)); CHECK_EQUAL("010", test_format(s, "{:#o}", 8));
} }
//*************************************************************************
TEST(test_format_custom_type)
{
etl::string<100> s;
coordinate c{3, 7};
// Default presentation of a custom type.
CHECK_EQUAL("(3, 7)", test_format(s, "{}", c));
// Surrounded by literal text.
CHECK_EQUAL("point=(3, 7)", test_format(s, "point={}", c));
// Manual argument index, referenced more than once.
CHECK_EQUAL("(3, 7) and (3, 7)", test_format(s, "{0} and {0}", c));
// Mixed in with built-in argument types.
CHECK_EQUAL("a (3, 7) 5", test_format(s, "{} {} {}", 'a', c, 5));
// Negative member values.
coordinate n{-1, 42};
CHECK_EQUAL("(-1, 42)", test_format(s, "{}", n));
// A temporary custom-type argument.
CHECK_EQUAL("(8, 9)", test_format(s, "{}", coordinate{8, 9}));
// formatted_size also works with custom types.
CHECK_EQUAL(6, etl::formatted_size("{}", c));
}
} }
} // namespace } // namespace