C++20: std::format um benutzterdefinierte Datentypen erweitern

Modernes C++ Rainer Grimm  –  7 Kommentare

Peter Gottschling hat in seinem letzten Artikel "std::format in C++20" die Grundlagen für die neue Formatierungsbibliothek in C++20 vorgestellt. Heute widmet sich der Autor dem Formatieren von benutzerdefinierten Datentypen.

Zum Abschluss der Miniserie in diesem Blog möchten wir demonstrieren, wie eine eigene Klasse formatiert ausgegeben werden kann.

Benutzerdefinierte Datentypen formatieren

Als Beispiel soll ein selbst definiertes Klassen-Template für einen Vektor mit numerischen Werten dienen. Dabei möchten wir angeben, wie die einzelnen Werte formatiert werden sollen. Wenn der Format-String ein c (wie curly braces) enthält, sollen die Werte von geschweiften Klammern umschlossen werden, sonst von eckigen. Wir überspringen hier alle Implementierungs­details und nehmen lediglich an, dass der Vektor über eine size-Funktion und einen Indexoperator verfügt.

Zu diesem Zweck müssen wir die Klasse std::formatter (bzw. fmt::formatter mit dem Prototypen) für unseren Typ dmc::vector (dmc ist der Namensraum aus dem Buch „Discovering Modern C++“ des Autors. Die Ende des Jahres erscheinende 2. Auflage wird auch die hier beschriebenen C++20-Features enthalten.) spezialisieren. Unsere Spezialisierung muss die Methoden parse und format enthalten. Beginnen wir mit Ersterer:

template <typename Value>
struct formatter<dmc::vector<Value>>
{
constexpr auto parse(format_parse_context& ctx)
{
value_format= "{:";
for (auto it= begin(ctx); it != end(ctx); ++it) {
char c= *it;
if (c == 'c')
curly= true;
else
value_format+= c;
if (c == '}')
return it;
}
return end(ctx);
}
// ...
bool curly{false};
std::string value_format;
};

Als Argument wird der Parse-Kontext übergegeben, dessen begin-Iterator auf das erste Zeichen der Formatangabe zeigt, das heißt das erste Zeichen nach dem Doppelpunkt (bzw. in dessen Abwesenheit das erste Zeichen nach der öffnenden geschweiften Klammer). Wir kopieren die Formatangabe nahezu identisch in unser lokales value_format. Nur das Zeichen 'c' wird nicht mitkopiert, sondern stattdessen das Flag für geschweifte Klammern gesetzt. Der Einfachheit halber nehmen wir an, dass das Format keine öffnenden oder schließenden geschweiften Klammern enthält, sodass die nächste schließende geschweifte Klammer unsere Format-Deklaration beendet. Am Ende der Funktion geben wir den Iterator, der auf die schließende geschweifte Klammer zeigt, oder den end-Iterator zurück.

Mit diesen Informationen können wir unseren vector in der Methode format ausgeben:

template <typename Value>
struct formatter<dmc::vector<Value>>
{
template <typename FormatContext>
auto format(const dmc::vector<Value>& v, FormatContext& ctx)
{
auto&& out= ctx.out();
format_to(out, curly ? "{{" : "[");
if (v.size() > 0)
format_to(out, value_format, v[0]);
for (int i= 1; i < v.size(); ++i)
format_to(out, ", " + value_format, v[i]);
return format_to(out, curly ? "}}" : "]");
}
// ...
};

Als Erstes holen wir uns aus dem übergebenen Format-Kontext eine Referenz auf den Ausgabepuffer. Dorthin schreiben wir zunächst die öffnende geschweifte oder eckige Klammer. Da geschweifte Klammern in der format-Bibliothek eine besondere Bedeutung haben, benötigen wir eine Escape-Sequenz aus doppelten geschweiften Klammern. Die restliche Ausgabe iteriert über den vector und schreibt die einzelnen Werte unter Verwendung der in der Klasse gespeicherten Formatangabe in den Buffer. Am Ende geben wir den Ausgabepuffer zurück.

Nun können wir verschiedene Formate ausprobieren:

dmc::vector<double> v{1.394, 1e9, 1.0/3.0, 1e-20};
print("v with empty format = {:}.\n", v);
print("v with f = {:f}.\n", v);
print("v curly with f = {:fc}.\n", v);
print("v width 9, 4 digits = {:9.4f}.\n", v);
print("v scient. = {:ec}.\n", v);

und sehen die entsprechenden Ausgaben:

v with empty format = [1.394, 1000000000.0, 0.3333333333333333, 1e-20].
v with f = [1.394000, 1000000000.000000, 0.333333, 0.000000].
v curly with f = {1.394000, 1000000000.000000, 0.333333, 0.000000}.
v width 9, 4 digits = [ 1.3940, 1000000000.0000, 0.3333,0.0000].
v scient. = {1.394000e+00, 1.000000e+09, 3.333333e-01, 1.000000e-20}.

Wir hoffen, Sie mit den Beispielen von den Vorzügen der format-Bibliothek überzeugt zu haben und möchten diese noch einmal kurz zusammenfassen:

  • Die Formatierungsanweisungen sind deutlich kürzer als mit den I/O-Manipulatoren für Streams.
  • Die Ausgabe ist im Gegensatz zu printf typsicher: Wenn eine Formatangabe für den Typ des entsprechenden Arguments nicht passt, wird eine Ausnahme geworfen.
  • Wir können die Ausgabe von Nutzertypen unterstützen – was mit printf auch nicht möglich ist.
  • Wir können die Argumente in der Ausgabe umsortieren – was zuvor mit keiner I/O-Technik möglich war.

Mit der format-Bibliothek ist es gelungen, die Vorteile der beiden existierenden Techniken zu vereinen – ohne im Gegenzug neue Probleme oder Subtilitäten einzuführen. Wir können daher nur wärmstens empfehlen, sie überall einzusetzen, sobald ausreichende Compiler-Unterstützung verfügbar ist.

Vielen Dank nochmals an Peter Gottschling für seine kompakte Einführung in die neue Formatierungsbibliothek in C++20. Gerne möchte ich noch ein paar Worte zu seiner Einleitung hinzufügen.

Try it out

Wie es der Autor bereits erwähnte, ist die auf GitHub gehostete Bibliothek fmt ein Prototyp für die neue Formatierungsbibliothek in C++20. Die Einstiegsseite des fmt Projekts enthält ein paar einfache Anwendungsfälle und Performanzahlen. Diese Beispiele beeinhalten einen direkten Link zum Compiler Explorer, sodass sich die Programme ausführen lassen.

Dank der neuen Formatierungsbibliothek ist es zum Beispiel möglich, Zeitdauern der chrono Bibliothek direkt auszugeben.

#include <fmt/chrono.h>

int main() {
using namespace std::literals::chrono_literals;
fmt::print("Default format: {} {}\n", 42s, 100ms);
fmt::print("strftime-like format: {:%H:%M:%S}\n", 3h + 15min + 30s);
}

Das Ausführen des Programms auf dem Compiler Explorer ergibt die folgende Ausgabe:

Portieren nach C++20

Das vorherige Programm von fmt auf die C++20 Formatierungsbibliothek zu portieren, ist ein Kinderspiel. Dazu müssen die Standardheader chrono und iostream verwendet werden. Zusätzlich gilt es, den Aufruf fmt::print durch std::format zu ersetzen und die Ausgabe auf std::cout zu schieben. std::format gibt einen String gemäß der Formatangaben und einen optionalen Lokal zurück.

// formatChrono.cpp

#include <chrono>
#include <iostream>

int main() {
using namespace std::literals::chrono_literals;
std::cout << std::format("Default format: {} {}\n", 42s, 100ms) << "\n";
std::cout << std::format("strftime-like format:
{:%H:%M:%S}\n", 3h + 15min + 30s) << "\n";
}

Wie geht's weiter?

Mein nächster Artikel dreht sich weiter um die praktischen Funktionen. In C++20 lässt sich der Mittelpunkt zweier Werte berechnen, prüfen, ob ein String mit einem Teilstring beginnt oder endet, und ein Funktionsobjekt mit der Funktion std::bind_front erzeugen.