[C++]std::formatをstd::variantに対応させる。

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::variantstd::formatが対応していない型が含まれていたとしても、その部分だけはvariant(unknown)と出力させるよう分岐することで回避している。
書式文字列を動的に解析するのでパフォーマンス上は不利だが、std::visitを使う場合も上述したようにstd::vformatが必要になるため、大差ないだろうと思う。

参考

Q: fmt::format std::variant · Issue #3735 · fmtlib/fmt · GitHub
formatter - cpprefjp C++日本語リファレンス


  1. std::visitを初めて知ったとき、似たようなものを過去に自前で実装してコンパイラを爆発させたことがあった私は「どうやってこの問題を解消したんだろう?」と首を傾げたものだが、実のところ全く解消していなかったようだ。

[C++][ADAPT]2次元ヒストグラム、3Dの追加。Gnuplotライブラリ更新(2)。

タイトルのとおりである。前回は1次元の一般的なヒストグラムを実装したが、今回は2次元の方を追加した。と同時に、その周辺での色々な設計の整理を行った。

プロット機能の詳細はこちらへ
GitHubリポジトリはこちらへ

2次元ヒストグラム(binscatter)の実装

PlotBinscatterという関数を追加した。これは散布図を描くのと似たようなものだが、その点の密度を色として表示するものである。2個のモードがあり、デフォルトでは一般的なカラーマップによる2次元ヒストグラムになり、もしplot::bs_pointsというオプションを与えると各点に密度に対応する色を割り当てた散布図になる。ただし前者の場合、エントリー数0の空のビンは白色で表示される。空のビンも0に対応する色で塗りつぶしたい場合はplot::bs_no_lowlimをオプションとして与える。
添付図は全く同じデータを、デフォルトのカラーマップ(左)、plot::bs_pointsを与えた色付き散布図(右)でプロットし比較したものである。若干異なるのが分かるだろうか。コードはこちらのexample_binscatterを参照

現状、色は単純にビンで区切ってその中に収まるデータ点の数を与えているだけである。昔は色付き散布図の場合にはカーネル密度推定を用いて大雑把にスムージングしていたこともあったが、使う予定がないので現時点で未実装だ。気が向いたら作ることもあるかもしれない。

CanvasCMの廃止とCanvas2Dへの統合

前回更新でも少し触れたことだが、カラーマップの表示方法をpm3dではなくimageで行うように変更し、これによりCanvasCMの存在意義が消失したためCanvas2Dへと一本化した。使い方は以前と変わっておらず、単にCanvasCMを使っていたところを機械的にCanvas2Dへと置き換えれば動作するはずである。動かなかったらバグ報告を。

ちなみに、pm3dからimageへと変更したことによる副次効果で、PlotColormapに与えるデータのzの値をNaNにするとカラーバーの色を無視して白で表示されるようになった。上のPlotBinscatterで空のビンを白抜きで表示するとき、この挙動を利用している。……もしこれがGnuplotのバグに近い特殊な挙動だったりすると、将来的に使えなくなったりするかもしれないが。

Canvas3Dの追加

上で廃止したCanvasCMだが、丸ごと処分してしまうのはちょっと勿体なかったので、Canvas3Dへと転生させた。今までのCanvasCMは実質、内部でset views mapというコマンドを実行し3Dを真上から見下ろす形にしていただけなので、実質的にはset views mapを削除し、ちょっと機能を足した程度の変更である。

なおCanvas3Dのみの機能としてPlotSurfaceを用意した。Colormapと似ているが、曲面の表面をグリッド線、等高線等を組み合わせて表示するものである。コード例はこちらのexample_surfaceに

余談

学会発表が終わってから一息つく間もなく、学会直線に行った解析で発見された不可解なデータを調べてみようとしたところで、どうしても2次元ヒストグラムが欲しくなってしまい、大急ぎで実装した。とはいえ、基本機能が揃っていたのでそれほど大変な作業ではなかった。

ADAPT本体の方のSIMD対応作業が大分進んで、Join周辺を除けばテストもクリアしている。とはいえADAPTにおいてはJoinこそが最大の難敵なので、これを突破できるかどうかだ。
そんなわけで、本体の更新は緩やかに進行しているもののなかなか公開できる状況ではなく、おまけ程度のつもりでくっつけたグラフ描画機能ばかりが更新されている。SIMDの方は高速化を目的としたものなので現状必須ではなく、どちらかと言えば私の自己満足に近いものであるのに対し、グラフの方は私の研究の中で要求されるため実装せざるを得ないのである。

学会前があまりに忙しかったのでしばらく放置気味だったが、その発表も終わって以前の日々が戻ってきたので、今のうちにもう少し開発を進めたいところだ。

[C++][ADAPT]ヒストグラムとラベル。Gnuplotライブラリ更新(1)。

ADAPTにおけるプロット機能の更新情報である。

プロット機能の詳細はこちらへ
GitHubリポジトリはこちらへ

ラベルプロット機能の追加

PlotLabels関数を用意した。指定した座標に文字列を表示する機能である。ラベルはstd::stringや数値型のrangesであればよい。色、回転、フォント、オフセットなどの指定が可能だが、可変サイズはgnuplotの変な仕様のためにちょっと特殊な指定方法が必要になる。

    std::vector<std::string> cities = { "London", "Paris", "Berlin", "Rome", "Madrid", "Amsterdam", "Brussels", "Vienna", "Prague", "Warsaw", "Budapest", "Stockholm", "Copenhagen", "Helsinki", "Oslo", "Dublin", "Lisbon", "Athens", "Istanbul", "Kyiv" };
    std::vector<double> latitudes = { 51.5074, 48.8566, 52.5200, 41.9028, 40.4168, 52.3676, 50.8503, 48.2082, 50.0755, 52.2298, 47.4979, 59.3293, 55.6761, 60.1695, 59.9139, 53.3498, 38.7169, 37.9838, 41.0082, 50.4501 };
    std::vector<double> longitudes = { -0.1278, 2.3522, 13.4050, 12.4964, -3.7038, 4.9041, 4.3517, 16.3738, 14.4378, 21.0122, 19.0402, 18.0686, 12.5683, 24.9354, 10.7522, -6.2603, -9.1399, 23.7275, 28.9784, 30.5234 };
    std::vector<int> populations = { 8982000, 2148000, 3769000, 2873000, 3266000, 872680, 1867000, 1921000, 1335000, 1794000, 1756000, 975551, 805402, 658864, 702543, 554554, 544851, 664046, 15462452, 2963199 };

    //If you want to plot labels with different sizes, label strings should be formatted as "{/=fontsize label}".
    auto dscities = adapt::views::Zip(cities, populations) |
        std::views::transform([](const auto& x) { return std::format("\"{{/={} {}}}\"", std::sqrt(std::get<1>(x) / 10000), std::get<0>(x)); });

    namespace plot = adapt::plot;
    adapt::Canvas2D g(output_filename);
    g.EnableInMemoryDataTransfer(enable_in_memory_data_transfer);
    g.SetXRange(-10, 40);
    g.SetYRange(35, 65);
    g.SetSizeRatio(1);
    g.SetXLabel("longitude");
    g.SetYLabel("latitude");
    g.SetCBLabel("population");
    g.SetLogCB();
    g.SetTitle("Europe major cities");
    // world_10m.txt can be downloaded from https://gnuplotting.org/plotting-the-world-revisited/
    g.PlotPoints("PlotExamples/world_10m.txt", "1", "2", plot::notitle, plot::c_dark_gray, plot::s_lines).
        PlotPoints(longitudes, latitudes, plot::notitle, plot::pt_fbox, plot::c_dark_gray).
        //PlotLabels(longitudes, latitudes, cities, plot::variable_color = populations,
        //          plot::labelfont = "Times New Roman,12", plot::lp_center, plot::notitle, plot::labeloffset = {0.0, 0.7});// labels with the same size
        PlotLabels(longitudes, latitudes, dscities, plot::variable_color = populations,
                   plot::labelfont = "Times New Roman", plot::lp_center, plot::notitle, plot::labeloffset = { 0.0, 0.7 });// labels with different sizes

    std::vector<int> x;
    std::vector<int> y;
    std::vector<int> label;
    adapt::Matrix<double> m(10, 10);
    for (int i = 1; i <= 10; ++i)
    {
        for (int j = 1; j <= 10; ++j)
        {
            int lcm = std::lcm(i, j);
            m[i - 1][j - 1] = lcm;
            x.push_back(i);
            y.push_back(j);
            label.push_back(lcm);
        }
    }

    namespace plot = adapt::plot;
    adapt::Canvas2D g(output_filename);// 以前はCanvasCMを使っていたが、2025/3/23の更新でCanvasCMは廃止、Canvas2Dに統合された。
    g.SetTitle("example\\_labels\\_on\\_colormap");
    g.SetXLabel("m");
    g.SetYLabel("n");
    g.SetSizeRatio(1);
    g.SetXRange(0.5, 10.5);
    g.SetYRange(0.5, 10.5);
    g.PlotColormap(m, { 1, 10 }, { 1, 10 }, plot::notitle).
        PlotLabels(x, y, label, plot::notitle, plot::lp_center, plot::c_white);

ヒストグラムのプロット機能の追加

従来ヒストグラムを描こうとすると自前でビンごとの数を計算する必要があったが、そのあたりを勝手にやってくれるPlotHistogramを用意した。ビンの左端、右端の座標、数を指定する必要がある。plot::he_poissonplot::he_normalを与えるとビンごとに統計誤差を求めてエラーバーを表示する。統計誤差は前者だとポアソン信頼区間、後者は単純な平方根で算出、付与される。he_poissonなどを与えた場合はgnuplotにおけるxyerrorbars、与えなければhistepsで描画される。その他の仕様はPlotPoints関数に準ずる。

    std::string norm = std::to_string(250. / std::sqrt(2 * 3.1415926535));
    std::string equation = norm + "*exp(-x*x/2)";

    std::mt19937_64 mt(0);
    std::normal_distribution<> nd(0., 1.);
    std::vector<double> data;
    for (int i = 0; i < 1000; ++i)
    {
        double x = nd(mt);
        if (x < -4.0 || x >= 4.0) continue;
        data.push_back(x);
    }

    namespace plot = adapt::plot;
    adapt::Canvas2D g("output_filename.png");
    //g.ShowCommands(true);
    g.SetTitle("example\\_histogram");
    g.SetXRange(-4.0, 4.0);
    g.SetXLabel("x");
    g.SetYLabel("y");
    g.PlotPoints(equation, plot::title = "mu = 0, sigma = 1",
                 plot::s_lines).
        PlotHistogram(data, -4, 4, 32, plot::he_poisson,
                      plot::title = "data", plot::c_black, plot::pt_fcir, plot::ps_med_small, plot::lw_med_thick);

その他

大幅な設計変更を行った。用意していたが結局無駄になった拡張性などを削除し、C++20のコンセプトを中心とする形に変えた。特にこのあたりの記事で書いた動的ジェネリクスに関するコードを丸ごと処分したことで、それなりにスッキリとはしたと思う。

またgnuplotの不具合なのか、フォントをArialにした場合に文字の一部が切れてしまう問題が発生していたので、フォントをsansに戻した。sansであってもフォント切れは起こるが、Arialよりは幾分マシだった。いずれはフォントの詳細設定などもできるようにしたいが、ターミナルごとに設定方法が細かく異なり統一的なインターフェイスを設けることが難しいため未実装である。

今後

今回実装したヒストグラムは一次元だが、二次元ヒストグラムも頻繁に使うので遠からず実装する。いわゆるbinscatterのようなものになるだろう。

gnuplotでカラーマップを作る場合、昔はpm3dとset view mapを組み合わせるのが定番だったが、最近はそれ以外の方法も出てきているようで、遠からずそちらに変更したいと思っている。pm3dは処理が2Dの場合と異なり色々特殊なためにCanvas2DとCanvasCMにクラスを分ける必要があったのだが、これを統合したい。
現状Canvas3Dを作る予定はないが、仮にCanvasCMが消滅した場合、Canvas2Dという名前が宙ぶらりんで珍妙なことになってしまうのが若干の悩みどころ。いや別に、set view mapを消せば実質3Dで動作するはずではあるので、名前だけCMから3Dに変更して残しておいてもいいとは思うのだが。

というわけで、喫緊の機能を実装したのでしばしOpenADAPT本体のメンテナンスに戻りたい。あちらのsimd機能がまだビルドすら通らない状態で放置されている。