こいついつもTOMLのパーサ書いてんな
C++で使えるheader-onlyなTOMLのパーサを書いた。実はこれは2度目である。何故またそんなことをしたか。順に説明していこう。
最初に書こうと思った一番の理由は、「パーサを書いたことがなかったのでいい経験になると思ったから」だ。他の理由の一つに「既存ライブラリのインターフェースがあまり好みでなかったから」というのがあるが、これは単なる好みの問題だと思うので深く突っ込まない(この好みには一応理由はあるが、労力をつぎ込むには根拠が薄弱すぎる)。
次に、今回書き直した理由は、「C++98のことを忘れてC++11以上のことだけ考えて書けばどうなるか」ということと、「以前の自分の設計とインターフェースに不満がでてきたから」だ。
今回の記事では、使い方の説明をREADMEよりは脱線しつつした後、最後に今回書き直した理由をダラダラと述べようと思う。
基本的な使い方
まず、このライブラリはヘッダオンリーである。なので、コンパイルの必要はない。レポジトリに置いてある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_integral
がtrue
になる型、Floatからはstd::is_floating_point
がtrue
になる型、StringとDatetimeはstd::is_convertible
な型である。
ArrayとTableのサポートはより強力である。
先のコードを見たとおり、Arrayは中に入っているtoml::value
ごと持ってくることができる。toml::get
した後はTOMLのことは忘れることができるのだ。これは何回ネストされていても同じである(が、Arrayが持っているArrayの要素型がそれぞれ異なる場合は型エラーになる。その場合はtoml::Arrayを素直に受け取ろう)。
さらに、toml::get
はSTLライクなコンテナ型の全てに対応している。
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_visitor
やstd::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なのでご容赦)。