UP | HOME

Custom Formatters in C++

Overview

The std::format function enables users to create formatters for their own custom types. What follows is a guide for writing a custom formatter for a type called MyType, in a way that covers most scenarios.

The Partial Template Specialization

  1. Assert that MyType is formattable: static_assert(std::formattable<MyType>)
    • Not necessary for functionality, but will prevent your code from compiling until the custom formatter has the correct interface.
  2. Create a partial class specialization of std::formatter for your type in the std namespace:

    /// \brief Enable formatting for MyType objects
    /// \tparam CharT The character type to use
    template<class CharT = char>
    struct std::formatter<MyType, CharT>
    // (Optional) : std::formatter<AnotherType, CharT> to reuse a parser
    {
    
        /// \brief Parse the format-spec, storing the results in *this so that
        ///   they can be used for controlling how MyType is formatted
        /// \param parse_ctx An std::basic_format_parse_Context<CharT>
        ///   This contains .begin() and .end() iterators for all characters after
        ///   the : in the format-spec (including the "}").  If the
        ///   format-spec is empty (e.g., "{}") then .begin() == .end()
        /// \returns std::basic_format_parse_context<CharT>::iterator
        /// The iterator points to the character that is past the end of the
        /// last character parsed by the parse function
        /// If re-using a parser via inheritance, do not include this function here.
        constexpr auto parse(auto & parse_ctx);
    
        /// \brief Writes a string representation of t to the fmt_ctx range
        /// \param t The type to output.
        ///    (Note: this can also be taken by value instead of const &, if desired).
        /// \param fmt_ctx An std::basic_format_context<LegacyOutputIterator, CharT>
        ///    Contains an iterator to output characters to. There is no guarantee
        ///    of what type LegacyOutputIterator is, so your code should not
        ///    depend it being a particular iterator type
        /// \returns std::basic_format_context<>::iterator. The iterator should
        ///   point to one past the last output character (e.g., where the next
        ///   character from whatever else is being added to the string should be inserted)
        auto format(const MyType & t, auto & fmt_ctx) const;
    };
    
  3. The reason for a partial specialization is to handle both regular and wide characters.
    • In our work it's not strictly necessary because we tend not to use wide characters, but might as well.
  4. The formatter must implement two member functions: parse and format
    • parse is for reading the formatting options, as specified in the format-spec
    • format is for actually creating the string

Implementing Parse

There are three main patterns for implementing parse:

  1. Re-use a formatter for another type T. This method is particularly useful if you have, for example, a type that is displayed as two double values.
    • In simple cases, you can inherit from the parser in question and omit the parse member function entirely.
    • In more complex cases (e.g., your output requires formatting multiple values) it makes sense to use composition and delegate the parsing:

      std::formatter<T, CharT> T_formatter; // Member variable
      constexpr auto parse(auto & parse_ctx)
      {
          return T_formatter.parse(parse_ctx);
      }
      
  2. Don't allow any formatting specifiers (essentially removing any way to control what the output looks like for the user):

    constexpr std::basic_format_parse_context<CharT>::iterator parse(std::basic_format_parse_context<CharT> & parse_ctx)
    {
        if(parse_ctx.begin() == parse_ctx.end() || *parse_ctx.begin() == "}")
        {
            return parse_ctx.begin();
        }
        else
        {
            throw std::format_error("Unsupported format string.");
        }
    }
    
  3. Implement (and document) your own format-specification language
    • This involves iterating over the parse_ctx range, interpreting characters, and setting members in *this so the information can be used by format

Implementing Format

  1. The format member function is responsible for writing characters to the iterator provided by fmt_ctx.
  2. If you inherited from another formatter (e.g., std::formatter<int, CharT>, you can call that formatter's format function directly:

    auto format(const MyType & t, auto & fmt_ctx) const
    {
        return std::formatter<int, charT>.format(t.int_val, fmt_ctx);
    }
    
  3. Likewise, if you used composition, you can then use the .format method of the enclosed formatter:

    std::formatter<T, CharT> T_formatter; // Member variable
    auto format(const MyType & t, auto & fmt_ctx) const
    {
        return T_formatter.format(t.my_T, fmt_ctx);
    }
    
  4. Another useful function is std::format_to. This function is like std::format but instead of formatting to a string it writes it's output to an Output Iterator

    auto format(const MyType & t, auto & fmt_ctx) const
    {
        return std::format_to(fmt_ctx.out(), "The value is {}", t.my_val);
    }
    
  5. It is very important that if, after calling functions that take the fmt_ctx as an argument you explicitly update the iterator if you plan on using the fmt_ctx again. A pattern to ensure the correct behavior is shown below:

    std::formatter<T, CharT> T_formatter; // Member variable
    auto format(const MyType & t, auto & fmt_ctx) const
    {
        fmt_ctx.advance_to(format_to(fmt_ctx.out(), "some text first"));
        fmt_ctx.advance_to(T_formatter.format(t.my_T, fmt_ctx));
        return fmt_ctx.out();
    }
    
    • formatter.format takes the fmt_ctx directly whereas format_to takes the underlying output iterator.
    • Both format_to and formatter.format return an OutputIterator pointing to the next location to write characters
    • The calls to fmt_ctx.advance_to ensure that the underlying OutputIterator in fmt_ctx is always pointing to the next location where characters should be written.
    • The code therefore maintains fmt_ctx.out() as the proper location to write the next item in the string

References

Author: Matthew Elwin.