[C++]日時、任意の数値型、任意の文字列、任意のrangeに対応させた。Gnuplotライブラリ更新(4)。

github.com

更新内容

  1. これまでプロットに使えるデータ型はdoubleまたはstd::stringのみだったが、これをstd::is_arithmetic_v<T>またはstd::is_convertible_v<T, std::string_view>を満足する任意の数値型、任意の文字列型へと拡張した1
  2. データ配列の形式としてはstd::vectorのみが許されていたが、これを1.の条件を満たすデータ型を返すような任意のrangeへと拡張した。ここで言うrangeとはstd::(ranges::)beginstd::(ranges::)endを呼び出し可能なコンテナ等のことである。
  3. 上の拡張に伴い、C++20のstd::ranges::rangeにも対応させた。ただしADAPT-GPM2の標準要求はC++17なので、これを有効化するにはGPMCanvas.hをincludeする前にマクロADAPT_USE_CPP20を定義しておく必要がある。
  4. この変更に伴い、データ点の数が各軸やその他各変数の間で一致しなくても許容されるようになった。このとき、与えられたrangeのうち最小のものに等しくなるよう末尾のデータが切り詰められる。その代わり、データ点の数の不一致が認められたときは警告文が出力される。この仕様変更はstd::ranges::rangeなどの一部では実際に走査してみないことにはデータ点の数を取得できないことが原因。
  5. 日時を各軸の値に取れるようにした。文字列型でもstd::time_tでもよいが、GPMCanvas::SetFormatGPMCanvas::SetDateTimeを適切に呼ぶことでどの様に出力するかを調整する必要がある。
  6. ついでに、オプションplot::style = Style::boxes or Style::stepsを使うとき、fillcolor、fillsolidなど塗りつぶし系のオプションも使えるようにした。

これらの更新により、例えば次のようなコードも実行可能になった。<ranges>をPlot関数にそのまま与えている。

#define ADAPT_USE_CPP20
#include <ADAPT/GPM2/GPMCanvas.h>
#include <ranges>
#include <random>
#include <ctime>
#include <chrono>

using namespace adapt::gpm2;

std::random_device rd;
std::mt19937_64 mt(rd());

int GetTested(int d)
{
    double a = d * d * 0.05;
    double b = a * std::pow((std::sin(2 * 3.141592 * d / 120) + 1) / 2, 3) + d;
    return b <= 0 ? 0 : std::poisson_distribution<>(b)(mt);
}
int GetPositive(int t)
{
    return t == 0 ? 0 : std::binomial_distribution<>(t, 0.05)(mt);
}

std::pair<int, int> GetDate(int a)
{
    static constexpr std::array<int, 12> x = { 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366 };
    int m = 0;
    int d = 0;
    int p = 0;
    for (int i = 0; i < 12; ++i)
    {
        if (a + 1 <= x[i]) { m = i + 1, d = a + 1 - p; break; };
        p = x[i];
    }
    return { m, d };
}

struct Accumulator
{
    int operator()(int t) const { return sum += t; }
    mutable int sum;
};
int example_datetime(const std::string output_filename = "example_datetime.png", const bool enable_in_memory_data_transfer = false)
{
    std::vector<int> yr(366);
    std::iota(yr.begin(), yr.end(), 0);
    auto x = std::views::all(yr) | std::views::transform([](int a) { auto [m, d] = GetDate(a); return adapt::Format("2020-%d-%d", m, d); });
    auto y = std::views::all(yr) | std::views::transform([](int a) { return GetTested(a); });
    auto y2 = y | std::views::transform([](int t) { return GetPositive(t); });
    auto y3 = y | std::views::transform(Accumulator{});
    auto y4 = y2 | std::views::transform(Accumulator{});
    GPMCanvas2D g(output_filename);
    g.ShowCommands(true);
    g.EnableInMemoryDataTransfer(enable_in_memory_data_transfer);
    g.SetXticsRotate(-45);
    g.SetTitle("example\\_datetime");
    g.SetYLabel("per day");
    g.SetY2Label("total");
    g.SetXDataTime("%Y-%m-%d");
    g.SetFormatX("%02m/%02d");
    g.PlotPoints(x, y, plot::title = "tested", plot::style = Style::steps, plot::fillsolid = 0.5).
        PlotPoints(x, y2, plot::title = "positive", plot::style = Style::steps, plot::fillsolid = 0.5).
        PlotLines(x, y3, plot::title = "tested\\_total", plot::axis = "x1y2", plot::linewidth = 3).
        PlotLines(x, y4, plot::title = "positive\\_total", plot::axis = "x1y2", plot::linewidth = 3);
    return 0;
}

f:id:thayakawa:20210314235033p:plain こんなサンプルになったのは最近こんな感じのグラフが目について仕方がないからであって、別に某感染症について調べているわけではないし、画像の数値は適当に乱数で生成しただけで全く意味はない。感染者数の桁が違うし、感染拡大しているのなら陽性率が一定のはずがない。

これらの更新は3月14日現在まだ十分な動作確認がなされていないので、masterブランチにはマージされておらず、allow_any_containers_and_data_typesというブランチで開発、テスト中である。

動機

今まで本ライブラリが扱えるデータ型はstd::vector<double>std::vector<std::string>のどちらかだった。通常はこれで不便しないのだが、時折困る時がある。非常に大きな桁数の整数などだ。doubleとして出力すると小さな桁が切り捨てられてしまったり、適切にプロットできない場合がある。
これが特に問題になるのがstd::time_t型を使うときだった。あれは実質的には64bit整数なので、doubleに変換すると上の問題が発生する。出力時のフォーマットを調整する、なんてややこしいことをユーザーに強いるわけにはいかないので、型に合わせて自動調整しなければならない。

また配列型がstd::vectorのみ、というのもC++20時代に移ろうとする現代ではふさわしくない。いっそ任意のrangeに対応させ、<ranges>との連携を可能にするべきだろう。

裏話

データ型に関する制約は内部で使っているVariantに由来していた2。GPM2はプロットするデータを一旦Variantに保存する仕様になっているのだが、Variantは事前に格納される可能性のある型をすべて列挙する必要があり、任意の型を保存することはできないという制限がある。GPM2ではキーワード引数等の兼ね合いで与えられたデータを一旦Variant中に保管しなければならなかったので、データ型はどうしても限られてしまった。

ただそういえば、私は最近オブジェクトの型、特性に合わせてAnyへの格納を制限したり処理を呼び分けたりする方法を作ったところだった。std::variantに対するstd::visitほどの利便性はないものの、格納可能なオブジェクトに"数値型の配列"か"文字列型の配列"など制限を設けることが出来るし、その中身やイテレータにrun-timeにアクセスする関数等を用意すれば型消去しつつrangeとして扱うという奇天烈な動作も可能になる。
なので、Variantの代わりにこれを採用し、きちんと実現したようである。<ranges>についてはGCCやClangではすぐに試せないが(手元の環境は古いので<ranges>未実装)、MSVCで動くことは確認した。

それにしても

正直やりすぎた気がしている。AnyRef.hやGenerics.hはC++の仕様上どうしても美しくコーディングできないのだ。動作は美しくなったと思うが、コードはより一層ややこしく……。


  1. ただしカラーマップのxy座標はdoubleにキャストして計算されるので、それ以外の値を与えてもあまり意味はない。

  2. まだstd::variantを使えなかった頃、代わりに自作したもの。