C++20で導入されたstd::format
は、既に多くの人が活用していることだろうと思う。printf系関数や<iomanip>
に比べて本当に便利になった。昔私はstd::stringをprintfのようにフォーマットする関数という記事を書いたことがあるが、C++20環境に移行してからは一切使っていない。
ただ、std::format
を容易に使えない状況もあった。具体的には、std::variant
に格納された数値、文字列などを出力したいが、コンパイル時には型情報を得られない場合だ。が、容易にできないのであって不可能ではない。
方法は、私の思いつく限りは2通りある。std::visit
で愚直に分岐するか、std::formatter
を実装するかのどちらかである。前者はstd::visit
の仕組みさえ知っていれば簡単だが、タイトルのように"std::format
を対応させる"わけではないし、実用上の難点が多い。後者はやや難しいが、std::format
を素直に使えるようになるし、多くの状況に対応できるようになる。
1. std::visit
による単純な分岐
よく使われる方法はこちらだと思うが、問題山積みである。
#include <format> #include <variant> #include <iostream> #include <string> int main() { std::variant<int, double, std::string> v0 = 42; std::variant<int, bool, double, std::string> v1 = false; std::variant<int, bool, double, std::string> v2 = 3.14; std::variant<int, bool, double, std::string> v3 = "Hello, World!"; std::visit([]<class ...V>(const V& ...v) { std::cout << std::vformat("{:>3} {} {:>.2f} {}\n", std::make_format_args(v...)); }, v0, v1, v2, v3); return 0; }
この方法は引数の数やstd::variant
に格納される型が十分に少ない場合は有効だが、5個10個と引数の数を増やし型の組み合わせが尋常でなく増大するとたいていメモリ不足でコンパイラが死ぬ1。テンプレートを使い倒しているC++erなら一度や二度は類似のトラブルをやらかしているのではないかと思う。
またstd::variant
が一つでもstd::formattable
でない型を含んだ場合もコンパイルエラーだ。実用性を妨げる問題ばかりで嫌になる。
勿論、std::format
の呼び出しを複数回に分割するという回避方法はある。しかし書式文字列をstd::variant
一つ一つに対して分割して与えなければならず実用性皆無であるため、今回は考えない。
なおstd::vformat
を使っているのは、全ての型に対してコンパイルし実行時に分岐処理するstd::visit
からコンパイル時書式解析を行うstd::format
に書式文字列を与えると、"{} {} {} {}\n"
のようにあらゆる型に対して汎用的に使える形式でもない限りコンパイルエラーを起こすためである。
2. std::formatter
の作成
というわけで1.の方法が私にとって😱😭🤮であったので、std::variant
のためのstd::formatter
を作ってみることにした。
簡単に説明しておくと、std::formatter
とはstd::format
を様々な型に対応させるためのクラスである。書式文字列の解析と、それを用いたフォーマットを担う。std::formatter
の自作クラスでの特殊化を実装することで、std::format
の引数に自作クラスを与えられるようになる。
ただ、実は結構難しい。std::formatter
では、書式文字列をコンパイル時に解析するstd::formatter::parse
関数と、その結果に基づいて出力するstd::formatter::format
関数とを別々に実装する必要がある。が、上述したようにstd::variant
は静的型情報が消去されているため、parse
関数中でstd::variant
の型情報を得ることができず、事実上解析が不可能である。つまり、parse
関数では実際にはパースを行わず、format
関数で処理を代替するように設計しなければならない。
あちこち調べつつ試行錯誤して出来上がったのが以下のものである。
#include <format> #include <variant> #include <iostream> #include <string> #include <string_view> // C++23ならstd::formattableでよいはず。 template <class T> concept formattable = requires(T t, std::format_context& ctx) { { std::formatter<T>{}.format(t, ctx) }; }; template <class ...Ts> struct std::formatter<std::variant<Ts...>> { std::string_view m_fmt; constexpr auto parse(std::format_parse_context& ctx) { // parse関数はほとんど何も行わない。フォーマット文字列の該当範囲を保存するだけ。 auto it = ctx.begin(); auto end = ctx.end(); m_fmt = std::string_view(&(*it), static_cast<size_t>(end - it)); while (it != end && *it != '}') ++it; return it; } auto format(const std::variant<Ts...>& var, std::format_context& ctx) const { auto visitor = [this, &ctx](const auto& v) { if constexpr (::formattable<std::decay_t<decltype(v)>>) { // 本来parse関数で行う書式文字列の解析をここで行う。 std::format_parse_context pc(m_fmt); std::formatter<std::decay_t<decltype(v)>> formatter; formatter.parse(pc); return formatter.format(v, ctx); } else { // std::formatterが定義されていない未知のクラスを持っていてもコンパイルエラーにならないよう、 // formattableでない場合はこちらに分岐し、単にvariant(unknown)と出力する。 return std::formatter<const char*>{}.format("variant(unknown)", ctx); } }; return std::visit(visitor, var); } }; struct MyClass{}; int main() { std::variant<int, double, std::string, MyClass> v0 = 42; std::variant<int, bool, double, std::string, MyClass> v1 = false; std::variant<int, bool, double, std::string, MyClass> v2 = 3.141592653589; std::variant<int, bool, double, std::string, MyClass> v3 = "Hello, World!"; std::variant<int, bool, double, std::string, MyClass> v4 = MyClass{}; std::cout << std::format("{:>3} {} {:>.2f} {} {} {:>5} {} {:>.5f} {:>20} {}\n", v0, v1, v2, v3, v4, v0, v1, v2, v3, v4); // 42 false 3.14 Hello, World! variant(unknown) 42 false 3.14159 Hello, World! variant(unknown) return 0; }
std::visit
を使う方法ではコンパイラが音を上げてしまうような多数のstd::variant
型引数を与えたとしても、こちらは問題なくコンパイル、実行できる。
またstd::variant
に実際に格納されている型情報を得てから書式文字列を解析しているので、"{:>3} {:>.2f}"
のような書式をちゃんと使用できる(当たり前だが格納されている型が対応しない書式を与えてしまえば例外が飛んでくる)。
さらに、std::variant
にstd::format
が対応していない型が含まれていたとしても、その部分だけはvariant(unknown)
と出力させるよう分岐することで回避している。
書式文字列を動的に解析するのでパフォーマンス上は不利だが、std::visit
を使う場合も上述したようにstd::vformat
が必要になるため、大差ないだろうと思う。
参考
Q: fmt::format std::variant · Issue #3735 · fmtlib/fmt · GitHub
formatter - cpprefjp C++日本語リファレンス