しばらく前だが、アップデートした。最新バージョンはv3.1.0
だ。
Added
TOML11_USE_UNRELEASED_TOML_FEATURES
TOML言語仕様はまだちょいちょいバージョンアップされている。なので「TOMLとしてリリースされていないがtoml-lang/toml:masterにmergeされた、おそらく次のリリースで入るであろう機能」というものがある。
このうち、便利そうなもの2つは先行して実装した。その2つとは、「basic stringで生のタブ文字を許容する」と「浮動小数点数の指数部で0-prefixを許容する」だ。
ただ、これらの機能はまだTOML側ではリリースされていない。基本的にリリースされていない仕様に依存するのはあまりよくないことなので、TOML11_USE_UNRELEASED_TOML_FEATURES
というフラグによって制御することにした。toml11をincludeする前にこれを定義するか、コンパイルオプションで定義しておくことによって、上記2つの機能がアクティブになる。
前者は、特に複数行文字列を書いているときに便利だ。以下のような文章で、パラグラフのはじめにタブを使いたいときがある。これまでは、文字列内でのタブ文字は許されていなかったので、以下のTOMLは文法違反だった(\t
のところにタブ文字がある場合)。
long_string = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. (\t)Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. """
これが許可された。
後者は、数値の入力で圧倒的に便利だ。多くの言語で使われている1.0e+01
のようなフォーマットで、e
の後に来る指数部分での0-prefixを許容する。詳細は以前書いた。
TOMLで浮動小数点数の指数部分でleading zeroが許可される - in neuro
TOML側で次のリリースが来次第(そしてこれらの機能がrevertされない限り)、このマクロは削除され、デフォルトの挙動にする予定だ。
toml::value::at
toml::value v
に対して、v.as_table()["hoge"]
ではなくv["hoge"]
としたいという要望があり、色々考えた末toml::value::at
を追加した。
以下のような書き方ができる。
const toml::value v = toml::parse("hoge.toml"); const toml::value& t = v.at("table");
文字列が来たらtable
だと思ってキャストしてみて、失敗したらtype_error
、成功したらキーを探す。なければout_of_range
。
これは、C++プログラマにとってat
という名前から予想される範囲の実装だと思う。STLのat
は範囲チェックをして、失敗したら例外を投げる。toml11のat
は型チェックと範囲チェックをして、失敗したら例外を投げる。
だが実際には、要望が合ったのはoperator[]
だ。std::map
などのoperator[]
は値の変更・追加ができるので、
toml::value v = toml::table{}; v["key"] = 42;
のような書き方ができるようになる。なるほど便利だ。
これに関してはとても悩んだが、今回は見送った。
理由は、一貫性のある実装ができないからだ。より正確に言うと、期待される振る舞いに一貫性がない。
C++プログラマにとってSTLはあまりにも馴染み深く、野良ライブラリのAPIがSTLと少しでも違ったら怒り狂ったC++プログラマに逆さに吊られ、未定義動作によって呼び出された悪魔への生贄に捧げられる[要出典][この記述には問題があります]。なのでライブラリ作者としてはとりあえずSTLから予想される挙動と合わせておくのが吉だ。
と考えると、テーブルにoperator[]
でアクセスした場合は、値があった場合はそれへの参照が、なかった場合は値が作られてそれへの参照が来るべきだ。これはstd::map
と同じ振る舞いだ。また、配列にoperator[]
でアクセスした場合は、境界チェックをせず、値がなかった場合は潔く未定義動作にするべきだ。これはstd::vector
と同じ振る舞いだ。配列に対するoperator[]
は極限まで高速であるべきで、境界チェックが入ったり、例外が飛ぶことは期待されていない。(注:規格としては特にnoexcept
であるとは触れられていないので、境界を踏み越えたときに未定義動作として例外が飛ぶのはありかも知れない。だが、operator[]
他いくつかの関数は規格で償却定数時間で終了すると定められているので、勝手にresize
するのは規格違反だろう)
これをこの通り実装するのは簡単だが、その後のことを考えてみよう。以下のコードはわかりやすいだろうか?
toml::value v = toml::table{}; v["key"] = 42; // OK. key = 42を定義。 v["table"] = toml::table{}; // OK. 新しいテーブルを定義。 v["table"]["key"] = 42; // OK. table.key = 42を定義。 v["array"] = {1,2,3,4,5}; // OK. arrayを定義 v["array"][5] = 6; // Undefined! 範囲エラー // 配列に値を追加しようとした
最後の行は罠になる。ではどういう実装になるべきか? チェックしてout_of_range
例外を吐く? push_back()
してback()
を返す? どういう実装になっても、std::vector::operator[]
とは食い違ってしまう。実質的に選択肢がないのだ。
ここを正しくやれるアイデアが出なかった(どれかで妥協もできなかった)ので、これは実装しなかった。
Fixed
extended conversion
バグが一つと、ドキュメントのミスが一つ見つかった。
toml11は、ユーザー定義クラスとtoml::value
の間の変換をサポートしている。例えば、あなたがext
名前空間内にfoo
という構造体を持っているとして、それにfrom_toml
という関数を実装すると、toml::find<ext::foo>(data, "foo")
がと書けるようになる。
namespace ext { struct foo { int a; double b; std::string c; void from_toml(const toml::value& v) { this->a = toml::find<int >(v, "a"); this->b = toml::find<double >(v, "b"); this->c = toml::find<std::string>(v, "c"); return; } }; } // ext const auto data = toml::parse("example.toml"); const foo f = toml::find<ext::foo>(data, "foo");
だがいつでも変換用のメンバ関数を追加できるわけではない。例えば、デフォルトコンストラクト不能な型の場合、from_toml
を呼ぶためのインスタンスを作れない。他に、サードパーティライブラリを使っていてその初期化にTOMLを使いたい場合、そのライブラリの中で定義されているクラスに勝手にメンバ関数を実装することはできない。
なので、外からも変換関数を定義できるようにしていた。
namespace toml { template<> struct from<ext::foo> { static ext::foo from_toml(const value& v) { ext::foo f; f.a = find<int >(v, "a"); f.b = find<double >(v, "b"); f.c = find<std::string>(v, "c"); return f; } }; } // toml
TOMLへのシリアライズやtoml::value
への直接代入を許すために、逆の変換も同じ要領でできるようになっている。
ここで、私はtoml::get
側のSFINAEをミスっており、双方向の変換がないと変換できないことになっていた。これはバグで、from<T>
さえあればtoml::get<T>
は動くべきだ。さらに、READMEでtoml::from<T>::from_toml
をstatic
にするのを忘れていた。@htrefil氏の報告によって、ベッドの中でGmailを開いてコードを見た私と恐らく起きていたであろう@jcmoyer氏の両名がREADMEのミスと中にあったバグに気づき、私が(明日直そう)と思って就寝している間に@jcmoyer氏がPRを投げていた。
というわけで直った。
move semantics
前のバージョンではmove関係のロジックが若干おかしくなっており、toml::value
をmove
してtoml::get
に渡すことでコピーが生じる可能性があった。これを消そうとしたらuse-after-moveを大量に引き起こしてしまい(サニタイザーのお世話になった)、色々直した。
正直言って今move
で渡しさえすればどんな状況でもコピーが本当に一切起きないかと言われるとあまり自信がない。が、とりあえずUBsanとAsanはテストケースの範囲内で文句はないそうなので、まあ使う分には大丈夫だと思う。少なくとも、普通の参照かconst参照で渡している限りはこの手の問題は発生しない。
サニタイザをCIで流せるようにするべきだと思いながらずっとやっていない。
今後の方向性
かなり大きめのやりたいこととして、以下のようなものがある。だがこれに割けるほどの時間はあまりありそうにない。
error recovery mode
デフォルトにする気はないが、ファイルを読んでいる時は文法エラーを警告にしておいて、1ファイルに複数の文法ミスがあった際に全てを報告できるようにしたいという気持ちがある。パースして直して再度読み込むのは面倒なので、一度に複数直せるようにエラーは複数表示して欲しい。
だがこれは若干めんどいので(壊れている配列などはどこで「配列が終わった」と判定すればいいか? など)、結構な変更が必要そうで尻込みしている。
これを実装すれば、ついでに「壊れている値は未初期化のままとりあえず全部読み込む」ということも可能になると思われる(ファイル内の全エラーを見つけるためには、パーサはファイル終端まで届かなければならないので)。そういうことを必要とする人がいるかどうかは置いといて、おまけもついてくると思うと少しやりたさはあるのだが……。
noexcept mode
完全に無例外保証というのは不可能だが(std::vector
がメモリ確保に失敗したらbad_alloc
が飛んでくる)、文法エラーを例外ではなくresult
でやりたいという気持ちはある。そのために実は内部ではできるだけresult<T, E>
を使っているのだが、処理が複雑になっているところを置き換えるのが難しいという理由で放置している。意を決して見てみたら意外とできるのではと思いたいが……。
単純に失敗したらその場で全てを放り出して例外を投げて終わりにしてしまうというちゃぶ台返し方式が便利すぎるのが悪い。中でキャッチとかし始めると死ぬが。
コンパイル高速化
toml11はコンパイルが遅い。これに関しては、ユーザーが使いそうな関数の宣言だけ取り出したヘッダを作り、先にそれ以外をコンパイルしておくことによって解決しないだろうかと思いつつほぼ何もしていない。
以前、toml::basic_value
のうちいくつかのパターンと、toml::parse
とserialize
関数をextern template
で先にコンパイルしておくというのをやってみたが、あまり劇的には短縮されなかった。GCCのプロファイルを見る限り、コード生成部分よりももっと別のところが時間を食っていて、結局そのブランチはマージしていない。多分ヘッダをもっと分けないと効果がないのだろう。
おわり
そんな感じです。