Die Formatierungsbibliothek in C++20: Formatieren benutzerdefinierter Datentypen
Neben den Basistypen und std::string lassen sich in C++20 auch benutzerdefinierte Typen formatieren.
- Rainer Grimm
In meinen vorangegangenen Artikeln habe ich Basistypen und std::string
formatiert:
- Softwareentwicklung: Die Formatierungsbibliothek in C++20
- Die Formatierungsbibliothek in C++20: Der Formatstring
- Die Formatierungsbibliothek in C++20: Details zum Formatstring
Nun widme ich mich dem Formatieren benutzerdefinierter Typen.
std::formatter
ermöglicht es, benutzerdefinierte Datentypen zu formatieren. Dazu muss man die Klasse std::formatter
für den benutzerdefinierten Typ spezialisieren. Insbesondere müssen die Mitgliedsfunktionen parse
und format
implementiert werden.
parse
: Diese Funktion parst den Formatstring und gibt im Falle eines Fehlers einenstd::format_error
aus.
Die Funktion parse
sollte constexpr
sein, um das Parsen zur Compilezeit zu ermöglichen. Sie akzeptiert einen Parse-Kontext (std::format_parse_context
) und sollte das letzte Zeichen des Formatspezifizierers zurückgeben (die schließende geschweifte Klammer }
). Verzichtet man auf den Einsatz des Formatspezifizierers, ist dies auch das erste Zeichen des Formatspezifizierers.
Die folgenden Zeilen zeigen ein paar Beispiele für das erste Zeichen des Formatspezifizierers:
"{}" // context.begin() points to }
"{:}" // context.begin() points to }
"{0:d}" // context.begin() points to d}
"{:5.3f}" // context.begin() points to: 5.3f}
"{:x}" // context.begin() points to x}
"{0} {0}" // context.begin() points to: "} {0}"
context.begin()
zeigt auf das erste Zeichen des Formatbezeichners und context.end()
auf das letzte Zeichen des gesamten Formatstrings. Gibt man keinen Formatbezeichner an, muss man alles zwischen context.begin()
und context.end()
parsen und die Position der abschließenden }
zurückgeben.
format
: Diese Funktion sollte konstant sein. Sie erhält den Wertval
und den Formatkontextcontext
.format
, formatiert den Wertval
und schreibt ihn entsprechend dem geparsten Format incontext.out()
. Der Rückgabewert voncontext.out()
kann direkt instd::format_to
eingegeben werden.std::format_to
muss die neue Position für die weitere Ausgabe zurückgeben. Es gibt einen Iterator zurück, der das Ende der Ausgabe darstellt.
So viel zur Theorie. Nun möchte ich anhand von Beispielen zeigen, wie sie sich anwenden lässt.
Ein Formatierer für einen einzelnen Wert
// formatSingleValue.cpp
#include <format>
#include <iostream>
class SingleValue { // (1)
public:
SingleValue() = default;
explicit SingleValue(int s): singleValue{s} {}
int getValue() const { // (2)
return singleValue;
}
private:
int singleValue{};
};
template<> // (3)
struct std::formatter<SingleValue> {
constexpr auto parse(std::format_parse_context& context) { // (4)
return context.begin();
}
auto format(const SingleValue& sVal, std::format_context& context) const { // (5)
return std::format_to(context.out(), "{}", sVal.getValue());
}
};
int main() {
std::cout << '\n';
SingleValue sVal0;
SingleValue sVal2020{2020};
SingleValue sVal2023{2023};
std::cout << std::format("Single Value: {} {} {}\n", sVal0, sVal2020, sVal2023);
std::cout << std::format("Single Value: {1} {1} {1}\n", sVal0, sVal2020, sVal2023);
std::cout << std::format("Single Value: {2} {1} {0}\n", sVal0, sVal2020, sVal2023);
std::cout << '\n';
}
SingleValue
(Zeile 1) ist eine Klasse, die nur einen Wert hat. Die Mitgliedsfunktion getValue
(Zeile 2) gibt diesen Wert zurück. Ich spezialisiere std::formatter
(Zeile 3) für SingleValue
. Diese Spezialisierung besitzt die Funktionen parse
(Zeile 4) und format
(Zeile 5). parse
gibt das Ende der Formatspezifikation zurück. Das Ende der Formatspezifikation ist die abschließende }. format
formatiert den Wert, und context.out
erstellt ein Objekt, das an std::format_to
übergeben wird. format
gibt die neue Position für die weitere Ausgabe zurück.
Das Ausführen dieses Programms liefert das erwartete Ergebnis:
Dieser Formatierer hat jedoch einen gravierenden Nachteil: Er unterstützt keine Formatspezifikation. Das werde ich im nächsten Beispiel verbessern.
Ein Formatierer, der einen Formatspezifikator unterstützt
Die Implementierung eines Formatierers für einen benutzerdefinierten Datentyp ist ziemlich einfach, wenn man für den Formatierer einen Standardformatierer verwendet. Es gibt zwei Möglichkeiten, einen Standardformatierer zu verwenden: Delegation und Vererbung.
Delegation
Der folgende Formatierer delegiert seine Aufgabe an einen Standardformatierer.
// formatSingleValueDelegation.cpp
#include <format>
#include <iostream>
class SingleValue {
public:
SingleValue() = default;
explicit SingleValue(int s): singleValue{s} {}
int getValue() const {
return singleValue;
}
private:
int singleValue{};
};
template<> // (1)
struct std::formatter<SingleValue> {
std::formatter<int> formatter; // (2)
constexpr auto parse(std::format_parse_context& context) {
return formatter.parse(context); // (3)
}
auto format(const SingleValue& singleValue, std::format_context& context) const {
return formatter.format(singleValue.getValue(), context); // (4)
}
};
int main() {
std::cout << '\n';
SingleValue singleValue0;
SingleValue singleValue2020{2020};
SingleValue singleValue2023{2023};
std::cout << std::format("{:*<10}", singleValue0) << '\n';
std::cout << std::format("{:*^10}", singleValue2020) << '\n';
std::cout << std::format("{:*>10}", singleValue2023) << '\n';
std::cout << '\n';
}
std::formatter<SingleValue>
(Zeile 1) hat einen Standardformatierer für int: std::formatter<int> formatter
(Zeile 2). Ich delegiere den Parsing-Auftrag an den Formatter (Zeile 3). Dementsprechend wird auch der Formatierungsauftrag an den Formatierer delegiert (Zeile 4).
Die Ausgabe des Programms zeigt, dass der Formatter Füllzeichen und Ausrichtung unterstützt.
Vererbung
Dank der Vererbung gelingt die Implementierung des Formatierers für den benutzerdefinierten Datentyp SingleValue
ganz leicht.
// formatSingleValueInheritance.cpp
#include <format>
#include <iostream>
class SingleValue {
public:
SingleValue() = default;
explicit SingleValue(int s): singleValue{s} {}
int getValue() const {
return singleValue;
}
private:
int singleValue{};
};
template<>
struct std::formatter<SingleValue> : std::formatter<int> { // (1)
auto format(const SingleValue& singleValue, std::format_context& context) const {
return std::formatter<int>::format(singleValue.getValue(), context);
}
};
int main() {
std::cout << '\n';
SingleValue singleValue0;
SingleValue singleValue2020{2020};
SingleValue singleValue2023{2023};
std::cout << std::format("{:*<10}", singleValue0) << '\n';
std::cout << std::format("{:*^10}", singleValue2020) << '\n';
std::cout << std::format("{:*>10}", singleValue2023) << '\n';
std::cout << '\n';
}
Ich leite std::formatter<SingleValue>
von std::formatter<int>
ab (Zeile 1). Nur die Formatfunktion muss implementiert werden. Die Ausgabe dieses Programms ist identisch mit der Ausgabe des vorherigen Programms formatSingleValueDelegation.cpp
.
Das Delegieren an einen Standardformatierer oder das Erben von einem solchen ist ein einfacher Weg, um einen benutzerdefinierten Formatierer zu implementieren. Diese Strategie funktioniert nur für benutzerdefinierte Datentypen, die einen Wert haben.
Wie geht's weiter?
In meinem nächsten Beitrag werde ich einen Formatter für einen benutzerdefinierten Datentyp mit mehr als einem Wert implementieren. (map)