std::format in C++20

Modernes C++ Rainer Grimm  –  139 Kommentare

Heute freue ich mich darauf, Peter Gottschlings Gastartikel zur neuen Formatierungsbibliothek in C++20 zu präsentieren: std::format. Dank std::format wird Textformatierung in C++20 so einfach wie in Python.

Peter Gottschling ist Autor der Pflichtlektüre "Discovering Modern C++" für professionelle C++Entwickler. Sein Buch ist auch in Deutsch unter dem Titel "Forschung mit modernem C++" erhältlich.

Die neue Formatierung

Traditionelle Stream-Formatierung erfordert ein hohes Maß an Tipparbeit. Format-Strings in printf und ähnlichen Befehlen sind deutlich ausdrucksstärker und erlauben uns, mit wenigen Symbolen zu deklarieren, was wir sonst mit mehreren I/O-Manipulatoren geschrieben haben.

Dennoch kann man von der Verwendung von printf nur abraten. Aus zwei Gründen: Nutzertypen werden nicht unterstützt und die Funktion ist nicht typsicher. Der Format-String wird zur Laufzeit geparst und die folgenden Argumente werden mit einem obskuren Makromechanismus verarbeitet. Wenn die Argumente nicht mit der Formatierungszeichenfolge übereinstimmen, ist das Verhalten undefiniert und kann zu Programmabstürzen führen. Beispielsweise wird ein String als Zeiger übergeben und ab der angegebenen Adresse werden die Bytes gelesen und als char ausgegeben, bis ein binäres 0 im Speicher gefunden wird. Wenn wir versehentlich versuchen, einen int als Zeichenkette auszugeben, wird der Wert int als Adresse fehlinterpretiert, von der aus eine Sequenz von char ausgegeben werden soll. Dies führt entweder zu einer absolut unsinnigen Ausgabe oder (was wahrscheinlicher ist) zu einem Speicherfehler, wenn auf die Adresse nicht zugegriffen werden darf. Wir müssen einräumen, dass neuere Compiler Formatierungszeichenketten parsen (wenn sie zur Kompilierzeit bekannt sind) und vor Argumentfehlern warnen.

Die neue format-Bibliothek in C++20 kombiniert die Expressivität des Format-Strings mit der Typsicherheit und der Nutzer-Erweiterbarkeit von Stream-I/O. Des Weiteren erlaubt sie, Argumente in der Ausgabe neu anzuordnen. (Zum Zeitpunkt des Schreibens unterstützt kein Compiler die format-Bibliothek, und die Beispiele wurden mit ihrer Prototyp-Version implementiert: der fmt-Bibliothek.)

Integrale

Statt einer formalen Spezifikation haben wir zunächst einige printf-Beispiele von cppreference.com in das neue Format portiert:

print("Decimal:\t{} {} {:06} {} {:0} {:+} {:d}\n", 1, 2, 3, 0, 0, 4, -1);
print("Hexadecimal:\t{:x} {:x} {:X} {:#x}\n", 5, 10, 10, 6);
print("Octal:\t\t{:o} {:#o} {:#o}\n", 10, 10, 4);
print("Binary:\t\t{:b} {:#b} {:#b}\n", 10, 10, 4);

Dieser Code-Schnipsel erzeugt die folgende Ausgabe:

Decimal:        1 2 000003 0 0 +4 -1
Hexadecimal: 5 a A 0x6
Octal: 12 012 04
Binary: 1010 0b1010 0b100

Die ersten beiden Zahlen wurden einfach ausgegeben, ohne irgendwelche Formatangaben zu machen. Die gleiche Ausgabe wird erzeugt, wenn wir eine Dezimalzahl mit der Formatangabe :d verlangen. Die dritte Zahl wird (mindestens) 6 Zeichen breit ausgegeben und mit führendem Nullen aufgefüllt. Der Spezifizierer + ermöglicht uns, die Ausgabe des Vorzeichens für alle Zahlen zu erzwingen. printf erlaubt es, die Ausgabe von Zahlen als unsigned zu spezifizieren. Wenn der Wert negativ ist, wird eine falsche positive Zahl (im entsprechenden Zweierkomplement) ausgeben. Die format-Bibliothek verzichtet auf Nutzerdeklarationen von unsigned-Ausgaben, da diese Information bereits im Typ des entsprechenden Arguments enthalten ist. Falls jemand dennoch das unbändige Bedürfnis verspürt, eine negative Zahl als großen positiven Wert auszugeben, muss er diese explizit konvertieren.

Die zweite Zeile zeigt, dass wir Werte hexadezimal darstellen können – sowohl mit Klein- als auch mit Großbuchstaben für die Ziffern größer als 9. Der Spezifizierer # erzeugt das in hexadezimalen Literalen verwendete Präfix 0x.

Ebenso können wir die Werte oktal und binär ausgeben – optional mit den entsprechenden Literalpräfixen.

Fließkommazahlen

print("Default:\t{} {:g} {:g}\n", 1.5, 1.5, 1e20);
print("Rounding:\t{:f} {:.0f} {:.22f}\n", 1.5, 1.5, 1.3);
print("Padding:\t{:05.2f} {:.2f} {:5.2f}\n", 1.5, 1.5, 1.5);
print("Scientific:\t{:E} {:e}\n", 1.5, 1.5);
print("Hexadecimal:\t{:a} {:A}\n\n", 1.5, 1.3);

Dies führt zu folgender Ausgabe:

Default:        1.5 1.5 1e+20
Rounding: 1.500000 2 1.3000000000000000444089
Padding: 01.50 1.50 1.50
Scientific: 1.500000E+00 1.500000e+00
Hexadecimal:0x1.8p+0 0X1.4CCCCCCCCCCCDP+0

Mit leeren Klammern oder nur mit einem Doppelpunkt erhalten wir die Standardausgabe. Dies entspricht der Formatangabe :g und ergibt die gleiche Ausgabe wie bei Streams ohne Manipulatoren. Die Anzahl der Nachkommastellen kann zwischen einem Punkt und dem Formatbezeichner f angegeben werden. Dann wird der Wert auf diese Genauigkeit gerundet. Wenn die angeforderte Zahl größer ist als das, was durch den Typ des Wertes darstellbar ist, sind die letzten Ziffern nicht sonderlich sinnvoll. Eine Ziffer vor dem Punkt gibt die (minimale) Breite der Ausgabe an. Wie bei ganzen Zahlen können wir führende Nullen verlangen. Gleitkommazahlen können mit den Bezeichnern e und E in der wissenschaftlichen Notation ausgegeben werden, wobei der Exponentialteil entsprechend mit einem Groß- oder Kleinbuchstaben beginnt. Die hexadezimale Ausgabe kann verwendet werden, um eine Variable in einem anderen Programm (ab C++17) mit genau den gleichen Bits zu initialisieren.

Ausgaben umleiten

Die Ausgabe kann zu jedem anderen std::ostream umgeleitet werden. (Mit der fmt-Bibliothek muss dafür ostream.h inkludiert werden.)

print(std::cerr, "System error code = {}\n", 7);
ofstream error_file("error_file.txt");
print(error_file, "System error code = {}\n", 7);

Argumente umordnen und ihnen Namen geben

Die Bibliothek führt auch ein Feature ein, das von keinem der beiden existierenden Ausgabetechniken unterstützt wird, nämlich die Möglichkeit, Argumente umzuordnen:

print("I'd rather be {1} than {0}.\n", "right", "happy");

Wir können auf die Argumente nicht nur nach ihren Positionen verweisen, sondern ihnen auch Namen geben:

print("Hello, {name}! The answer is {number}. Goodbye, {name}.\n",
arg("name", name), arg("number", number));

Oder kürzer:

print("Hello, {name}! The answer is {number}. Goodbye, {name}.\n",
"name"_a=name, "number"_a=number);

Das Beispiel zeigt auch, dass wir ein Argument mehrfach ausgeben können.

Die Umordnung von Argumenten ist in mehrsprachiger Software sehr wichtig, um eine natürliche Ausdrucksweise zu gewährleisten. Im Folgenden wollen wir den Durchschnitt von zwei Werten in fünf Sprachen ausgeben:

void print_average(float v1, float v2, int language)
{
using namespace fmt;
string formats[]= {"The average of {v1} and {v2} is {result}.\n",
"{result:.6f} ist der Durchschnitt von {v1} und {v2}.\n",
"La moyenne de {v1} et {v2} est {result}.\n",
"El promedio de {v1} y {v2} es {result}.\n",
"{result} corrisponde alla media di {v1} e {v2}.\n"};
print (formats[language], "v1"_a= v1, "v2"_a= v2, "result"_a= (v1+v2)/2.0f);
}

Selbstverständlich ist die deutsche Version die pedantischste, die unter allen Umständen sechs Nachkommastellen verlangt.

The average of 3.5 and 7.3 is 5.4.
5.400000 ist der Durchschnitt von 3.5 und 7.3.
La moyenne de 3.5 et 7.3 est 5.4.
El promedio de 3.5 y 7.3 es 5.4.
5.4 corrisponde alla media di 3.5 e 7.3.

Dieses Beispiel hätte zugegebenermaßen auch ohne eine Umstellung der Argumente funktioniert, aber es illustriert sehr schön diese wichtige Möglichkeit, den Text und die Formatierung von den Werten zu separieren.

Um formatierten Text in einen String zu speichern, benötigen wir auch keinen Umweg über einen stringstream mehr, sondern können dies direkt mit der Funktion format erledigen.

Wie geht's weiter?

In seinem nächsten Artikel wird Peter Gottschling seine Vorstellung von std::format abschließen. Peter wird das Formatieren von benutzterdefinierten Datentypen vorstellen.