tomlのパーサを作った

こいついつもTOMLのパーサ書いてんな

C++で使えるheader-onlyなTOMLのパーサを書いた。実はこれは2度目である。何故またそんなことをしたか。順に説明していこう。

最初に書こうと思った一番の理由は、「パーサを書いたことがなかったのでいい経験になると思ったから」だ。他の理由の一つに「既存ライブラリのインターフェースがあまり好みでなかったから」というのがあるが、これは単なる好みの問題だと思うので深く突っ込まない(この好みには一応理由はあるが、労力をつぎ込むには根拠が薄弱すぎる)。

次に、今回書き直した理由は、「C++98のことを忘れてC++11以上のことだけ考えて書けばどうなるか」ということと、「以前の自分の設計とインターフェースに不満がでてきたから」だ。

今回の記事では、使い方の説明をREADMEよりは脱線しつつした後、最後に今回書き直した理由をダラダラと述べようと思う。

github.com

基本的な使い方

まず、このライブラリはヘッダオンリーである。なので、コンパイルの必要はない。レポジトリに置いてあるCMakeListsはテストコードのためである。

#include <toml11/toml.hpp>

int main()
{
    ...
}

パースするのも簡単である。

auto data = toml::parse("example.toml");

これだけだ。

ここで、もしファイルが開けなかったら例外が投げられる。それが嫌な場合は、ファイルストリームを渡してもよい。

std::ifstream ifs("example.toml");
assert(ifs.good());
auto data = toml::parse(ifs);

さて、この data の型は何か。これはtoml::Tableである。私はTOMLのデータは再帰的なTableだと解釈したため、TOMLデータは無名(キーを持たない)Tableである。 toml::Tableは何か。これはstd::unordered_map<toml::key /*(a.k.a. std::string)*/, toml::value>である。これらは単なる typedef なので、findやcountといったstd::unordered_mapのあらゆるメンバが使える

もしメモリ容量などの都合でstd::mapを使いたい場合は、今のところtoml/value.hpp内のtypedefを変更するしかないが、いずれもう少し簡単に変更できるようにしたい。一番簡単なのはインクルード前にマクロ定義することだが、理想的にはtemplateで定義した後デフォルトを追加することだろう。ただ、クラステンプレートはデフォルトがあってもvalue<>のように宣言しなければならず、あまり直感的ではないのが困りどころだ。

では値を取り出そう。toml::get関数が使える。

// toml: answer = 42
const auto answer = toml::get<int>(data.at("answer"));

// toml: pi = 3.14
const auto pi = toml::get<double>(data.at("pi"));

// toml: numbers = [1,2,3]
const auto pi = toml::get<std::vector<std::int64_t>>(data.at("numbers"));

// toml: [tab]
// key = "value"
const auto key = toml::get<toml::Table>(data.at("key"));

toml::getはtemplate引数に取った型に対応する型をtoml::valueから抽出する。ただし、キャストはある程度制限されていて、Integerの値をboolとして取り出そうとしたりすると例外を送出する。ここは結構考えたところで、C++的にキャスト可能ならするようにしようかとも思ったのだが、あまり暗黙のキャストの多用はよくなかろうという気もちと、後で出てくるtoml::from_toml関数の実装時に少し困ったので、こういった形になった。

許可されているキャストは、Booleanからはboolのみ、Integerからはstd::is_integraltrueになる型、Floatからはstd::is_floating_pointtrueになる型、StringとDatetimeはstd::is_convertibleな型である。

ArrayとTableのサポートはより強力である。

先のコードを見たとおり、Arrayは中に入っているtoml::valueごと持ってくることができる。toml::getした後はTOMLのことは忘れることができるのだ。これは何回ネストされていても同じである(が、Arrayが持っているArrayの要素型がそれぞれ異なる場合は型エラーになる。その場合はtoml::Arrayを素直に受け取ろう)。

さらに、toml::getSTLライクなコンテナ型の全てに対応している。

const auto vc  = toml::get<std::vector<int>>(data.at("numbers"));
const auto ls  = toml::get<std::list<int>>(data.at("numbers"));
const auto dq  = toml::get<std::deque<int>>(data.at("numbers"));
const auto ar  = toml::get<std::array<int, 3>>(data.at("numbers"));

std::arrayに対応していることに注目してほしい。std::arrayの要素数がTOML Arrayの要素数より多ければこれは問題なく動く。内部でresizeメンバを持っているかどうかでコンパイル時にディスパッチし、リサイズ不能で要素数不足の場合は例外を投げるようになっている。要素数分だけ取り出して残りは無視してしれっと返そうかとも思ったが、予期しないバグを生みそうだったので素直に例外を投げることにした。

もちろん、Array of Tableはどのようにフォーマットされていても単なるArray of Tableだ。以下のように取り出せる。

array_of_inline_table = [{k = "v"}, {k = "v"}]
[[array_of_table]]
k = "v"
[[array_of_table]]
k = "v"
auto aot1 = toml::get<std::vector<toml::Table>>(data.at("array_of_inline_table"));
auto aot2 = toml::get<std::vector<toml::Table>>(data.at("array_of_table"));

もちろん、ここでもstd::vector以外のコンテナが使える。

ただし、これらは内部型からgetで指定された型への変換時に要素のコピーを伴う。大きなデータにはこれはかなり厳しい。あとでmoveを上手くサポートすることにする。 一応、toml::valueにはcastメンバ関数があり、v.cast<toml::value_t::Integer>()のようにして内部の値へのアクセスはできる。ユーザーが使うことはあまり想定していないが。

TOMLファイルでの型がわからない場合

入っている型は常には予測可能でないだろう。文字列でも数字でも登録可能であるとか、そういう状況があり得るに違いない。その場合のため、enumが用意されている。

const auto t = data.at("something").type();
if(t == toml::value_t::Integer) std::cout << "Integer!" << std::endl;

しかし、enumを使ってswitchするなどということはC++erからは求められていない。少なくとも私は嫌だ。なので、from_toml関数が用意されている。

int i = 0;
double d = 0.;
std::string s;

toml::from_toml(std::tie(i, d, s), data.at("something"));

if(i != 0) return i;

これはstd::tieで渡された値を順に見て行き、マッチする型があればそれに値を格納して終了する。他の値は手付かずで残されるので、後でそれとわかるように適当に初期化するべきであるが、それはユーザーの責任である。boost::apply_visitorstd::visitのようなものも作ろうと思ったのだが、そこまで手が回っていない。

何にもマッチしなかった場合例外が投げられるか全て空で返すかは少々迷った。予想していた型が来ないならそう伝えるべきな気もするが、恐らくほとんどのユーザーは全てマッチしなかった場合の処理を自分で書くだろう。以下のようにだ。

if(i != 0)
// ...
else if(d != 0)
// ...
else if(!s.empty())
// ...
else
    throw std::runtime_error("no match");

なのでそのままにすることにした。しかしここは注意するべき点だ。当てはまる型がなくても何事もなかったかのように沈黙するのだから。

おまけ(書き直した理由)

前回の実装では大きな縛りがあった。それは、C++98とC++11の両方をサポートすることだ。C++11ではboostに依存せず、C++98ではC++11とのコードの互換性をできるだけ保ちつつboostでできることをする。しかしこれは意外と厳しい。 例えば、もしboostを使うのなら、boost.variantをなんとしても使いたい。このためにあるようなものだ(当時は知らなかったが)。しかしC++17まで標準には入らない。よってvariantは使えない。当時はあまり気にしていなかったが、この縛りが思っていたよりも厳しいことに最近気づき、これを撤廃したいと感じるようになった。

また、以前私は動的な型を格納するために継承ベースのやり方をしており、単にbool値一つ格納するためにもヒープアロケーションが発生していた。さらに、取り出すときにはdynamic_castをしていた。要するに出来損ないのAnyのようなものだ。これは速度的にも設計的にもあまりよろしくないだろう。

C++98ならboost::variantが使える。C++11ならvariantは使えないもののunionの制限解除が入った。これらによってヒープアロケーションをかなり抑えることができる。前回の記事から再帰的な型、つまりArrayとTableではヒープアロケーションが発生するが、これは仕方ないと考えたい。しかも、まだちゃんと調べていないが、boost::containerを使えばそれすら消しされるだろう。内部的にboost::containerを使っていたとしても、toml::getを使って取り出すときは標準vectorに中身を移動できる。ユーザー側には何も問題はない(moveが使えないなら、要素コピーのオーバーヘッドはかなりのものだろうが)。

それと、パーサの実装そのものを以前のようなゴリ押しでなくもうすこしシステマティックに書けるような気がした(実際はそこまで上手くいかなかった)のも要因だ。少しはマシになったが、それでもまだやはり汚い。Boost.Spiritなどを使ってみたりして学ぶべきなのだろう。

とりあえず今回はこんなところだ。これを読んだ奇特な人がいたら、ぜひ使ってほしい。そして改善要求をしてほしい。手が回るかはわからないが(as isなのでご容赦)。