Die Formatierungsbibliothek in C++20: Formatieren benutzerdefinierter Datentypen

Neben den Basistypen und std::string lassen sich in C++20 auch benutzerdefinierte Typen formatieren.

In Pocket speichern vorlesen Druckansicht

(Bild: Sinart Creative / Shutterstock.com)

Lesezeit: 4 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis
Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

In meinen vorangegangenen Artikeln habe ich Basistypen und std::string formatiert:

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 einen std::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 Wert val und den Formatkontext context. format, formatiert den Wert val und schreibt ihn entsprechend dem geparsten Format in context.out(). Der Rückgabewert von context.out() kann direkt in std::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.

// 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.

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.

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.

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.

In meinem nächsten Beitrag werde ich einen Formatter für einen benutzerdefinierten Datentyp mit mehr als einem Wert implementieren. (map)