toml11 v4.3.0をリリースした

新年なので初リリースです。

追加

toml::find<std::optional<T>>(...)をサポート

今まで無かったのかよという感じですが、面倒だったので後回しにしていました。ローカルに置いてあるROADMAP.mdを見るともうちょっと早めに実装する予定だったことがうかがえます。 こういうのって始めるとガーっと書いてしまうけど、始めるまでが長いんですよね……これは仕事でも何でもないですし。

今回は自分が半分趣味で書いているソフトウェアで使えなかったのが困ったので実装しました。基本的に新機能は私が困る順番で実装されます。

さて、シンプルに考えるとこれは以下のような実装でよいだろう、と考える人が多いと思います。 (toml11はもっと色々ややこしいことをしていますが、ここでは単純化した実装で紹介します)

template<typename T>
std::enable_if_t<is_optional_v<T>, T>
find(const toml::value& v, const std::string& k)
{
    try
    {
        return std::find<typename T::value_type>(v, k);
    }
    catch(std::exception&)
    {
        return std::nullopt;
    }
}

これだけのことに何をもったいぶってるんだ、と思っている方もいるとは思いますが、今回の実装はこうではありません。

いや、例外をキャッチしたくないからとかではなくて、もっと本質的な問題があります。

上記のコードは、以下の場合にnulloptを返します。

a = "foo"
const auto a = toml::find<std::optional<int>>(input, "a");

本来、C++optional<T>を使用するのは「あってもよいが、なくてもよい」という場合です。 TOMLのような設定ファイルでそのような型を使用するのは、「設定してもよいが、ない場合はデフォルトを使う」場合でしょう。

しかし、この場合はどうでしょうか? 起きていることは、「コードを書いている人はintであることを期待しているところで、設定を書いた人がstringを入れた」という状況です。 これは「値がないのでデフォルト設定にする」で済ませて良いケースでしょうか? 私は違うと思います。この場合は、intを期待したがstringが入っていた、というエラーを出すべき場面でしょう。

このような場合にちゃんとエラーを出せるように、toml::find<std::optional<T>>の実装は毎回ちゃんと値が存在するかをチェックしています。toml::findnulloptを返すのは、値が存在しなかった場合だけです。

toml::visitで複数引数をサポート

std::visitstd::variantに対してのパターンマッチのような機能で、実質的にvariantであるtoml::visitも同様のtoml::visitをサポートしています。

std::visitは単一のビジターに対して複数のvariantを渡すことができます。対してtoml::valueは一つしかtoml::valueを受け取れませんでした。

もともとはstd::visitをイメージしていたので複数引数をサポートしようとしていたのですが、結構ややこしかったので後回しにしていました。

この前思い出してちょっと書いてみたらすんなり動作したので、じゃあ……と実装したわけです。

まあ、この機能を使ってる人がいるかどうかはわかりませんが。

一行が長いファイルでパースが遅くなる問題の修正

自分では気づかなかったのですが、toml11の実装は長い行のカラム数に対してO(n2)の計算量になってしまっていました。 (行数に対しては線形なので、巨大なファイルでも長い配列で細かく改行を入れている場合は高速に読めるが、長い配列を改行なしで書いていると遅い)

言い訳すると、自分で6万行とかの長いファイルを扱う際は、長い行は作らず豊富に改行を入れており、するとまあ言うほど待たずにパース出来るので(そしてそういう場合は計算自体が長いので読み込みはほぼ無視できる)、特に高速化しようという気持ちがなく、よってそういった広範な状況に対するベンチマークを取っていなかったわけです。

パースが行数に対しては線形なのにカラムに対して二乗というのはかなり不思議な話ですね。とはいえ、パースの計算量が変に大きいという問題はv3の時に既に調査したことがありました。

toml11はエラーメッセージにそれなりの努力を投下しており、パース中、あるいは読み込み中に出たエラーの位置をトラッキングできるような仕組みを作っています。 v3ではバイト位置しか追跡していなかったため、行カウントはエラーメッセージ作成時に前からカウントするような実装になっていて、パーサーがトライして巻き戻すごとにその処理が呼ばれてしまって遅くなっていました。 これを思い出して調査してみると、今回も私は「カラム数はそこまで大きくならない(80~100程度)だろう」と思って毎回カラム数をカウントしていたので、それが原因だろうとあたりをつけました。

しかし、それだけだと筋が通りません。というのも、v4では前回の反省を踏まえて、極力エラーメッセージを生成しないという方針に切り替えていたからです。 v4には、型をあてずっぽうでパースして失敗したらやり直す、というシーケンスがありません。 これは変だなと思って調べていたところ、整数と浮動小数点数のパースでは毎回エラーメッセージ用の情報を作っている、つまりカラム番号をカウントしていることに気付きました。

TOML11 v4では、整数と浮動小数点数型はユーザー定義型に変更できます。これは例えば、boost::multiprecision::cpp_intのような任意精度整数を使うためのものです。 このような機能をサポートする場合、ユーザーはパースするための関数も設定できなければなりません。 そのためにoperator>>を要求することも考えましたが、std::istreamに縛るよりは文字列からの変換関数を定義するほうが良かろうと考えました。 というのも、そのような関数が吐き得るエラーはユーザー定義型に特有のものもあるはずで、istreamの状態だけで伝えきれるものではない可能性があるからです。 そう考えると、エラーの場合はユーザー定義の変換関数にメッセージを返してもらうべきでしょう。 となると、その関数にはいざというときにエラーメッセージを構築するための情報を渡さなければなりません。 これは、ユーザー定義のエラーメッセージを出力する際に使うものと同じものでなければなりません。 そこで渡す構造体、toml::source_locationは、当然ながらカラム番号を知っていなければなりません。というわけで、毎回カラム番号をカウントしていたのでした。

ここまでわかると解決は簡単で、パース中に行番号とカラム番号の両方をカウントするようにして、カラム番号の取得を定数時間にしました。 リセットするタイミングがあるので若干面倒ではありましたが、そもそも行番号をカウントアップする際に似たことをしていたので、その処理に便乗した形です。

問題はバックトラックで、パース位置を戻す際に行をまたぐとカラム番号が不明になり、カウントしなおす必要が生じるケースがあります。 もともとはバックトラックは無限にできるような仕様にしていましたが、パーサの実装後は最大で1文字しか巻き戻さないことがわかっていたので、メンバ関数から巻き戻す文字数の引数を削除し、巻き戻しは1文字だけに縛ることにしました。 このおかげで実装はかなり簡単になりました。

というわけで、今は全ての変数がinline tableに登録された長大な行を持つ改行なしのTOMLファイルでも特に問題なく読み込める、はずです。

その他

あとは、限定的なケースで発生する問題の修正がいくつか届いていたのを取り込みました。(運悪く出くわした場合には)ちょっと大きめの問題もありましたが……。

こういった修正が届くのも有難いですね。自分では出くわさないことも多いので。

それから、MSVCの2017でもコンパイルできるようにし、appveyorの設定もしました。MSVC 2017でだけ動かない(2019, 2022では動く、gcc/clangは動く)SFINAEメタ関数があり、まあ結構古いバージョンだしいいか……と思ってサイレントにサポートを切っていたんですが、Issueが何度も立ったので修正しました。今は動きます。