cpp.fmt: Eine benutzerdefinierte Klasse formatieren

Mittwoch, 4. Januar 2023

Wer in C++ versucht Zeichenketten zu formatieren wird früher oder später auf die fmt Bibliothek stoßen. Solange mit gängigen Datentypen umgegangen wird, sind sogar Ausgaben von Container-Inhalten wie z.B. std::vector<int> {1, 2, 4, 8} keine Hürde. Interessant wird es aber sobald es um Benutzer-Definierte-Typen geht.

Für dieses Beispiel definiere ich die Klasse User mit einfachem Inhalt.

class User 
{
  std::string m_name {};
  int m_value {0};

  public:
    User(const std::string& name, int value);

    auto name() const -> std::string;
    auto value() const -> int;
};

Der Code, den die Benutzer der User Klasse schreiben wollen, könnte so aussehen:

auto users = std::vector<User> {};
// ... adding users to vector ... 
fmt::print("{}", users);

Vorerst hat fmt kein Wissen über die Inhalte der User Klasse, weshalb der obere Code nicht kompilieren wird. Es wird aber eine Schnittstelle angeboten um fmt, mit expliziten Anweisungen zur Formatierung dieser Klasse, zu erweitern.

Mit einer expliziten Template Spezialisierung der Struktur fmt::formatter<User> und der Implementierung der Funktionen parse und format werden Regeln für die User Klasse festgelegt. Das Grundgerüst sieht wie folgt aus:

template <>
struct fmt::formatter<User> 
{ 
  constexpr auto parse(format_parse_context& ctx) -> decltype(ctx.begin()) 
  {
    // parse content of the replacement field surrounded by curly braces {}
  }

  template <typename FormatContext>
  auto format(const User& u, FormatContext& ctx) -> decltype(ctx.out()) 
  {
    // generate the format output
  }

  // if needed, store some state from parsing as member
};

Über mögliche Member-Variablen können Optionen, welche beim Parsen des replacement field erkannt wurden, gespeichert und in der format Funktion abgerufen werden. Ich halte mein Beispiel klein und verzichte auf jegliche Auswertung von Argumenten. Es werden nur leere geschweifte Klammern akzeptiert. Die parse Funktion sieht dann so aus:

constexpr auto parse(format_parse_context& ctx) -> decltype(ctx.begin()) 
{
  auto it = ctx.begin();
  // 'begin' is expected not to be at 'end'
  // and also no arguments are accept
  // thus the only thing to look for are closing braces 
  // otherwise we throw
  if(it != ctx.end() && *it != '}' )
  {
    throw format_error("lol nope!");
  }
  return it;
}

Nun kann in der format Funktion der Kreativität freien Lauf gelassen werden. Ich möchte den Namen links- und den Wert rechtsbündig anordnen und verwende ein - als füllendes Trennsymbol.


template <typename FormatContext>
auto format(const User& u, FormatContext& ctx) -> decltype(ctx.out()) 
{
  return format_to(ctx.out(), "{0:-<7}{1:->7}", u.name(), u.value());
}

Wenn nun in einer Loop einzelne User mit fmd::print("{}\n", u); angezeigt werden, dann sieht die Ausgabe so aus:

muro------1337
junipa------42

Es brauch aber nicht einmal eine Loop um sich den ganzen Inhalt von z.B. einem std::vector<User> ausgeben zu lassen. fmt hat da bereits etwas vorbereitet. Und zwar können Standard-Container bereits von fmt Abgehandelt werden, wenn für den darin enthaltenen Typ Formatierungsregeln vorliegen. Dazu reicht es den <fmt/ranges.h> Header zu inkludieren. So sieht dann die Ausgabe von fmt::print("{}\n", users}; aus:

[muro------1337, junipa------42]

Aber es hört an dieser Stelle nicht auf! Denn so eine einfache aneinander Reihung ist nicht immer optimal um viele Datensätze zu veranschaulichen. Drum kann man noch den extra Schritt gehen und eine Template Spezialisierung für z.B. fmt::formater<std::vector<User>> anlegen um solch eine Formatierung zu erhalten:

┌────────────────┐
| Player---Score |
|────────────────|
| muro------1337 |
| junipa------42 |
└────────────────┘

Den Code dazu kannst du dir im git auf codeberg anschauen.

Referenzen