toml11をマイナーアップデートした

しばらく前だが、アップデートした。最新バージョンは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という名前から予想される範囲の実装だと思う。STLatは範囲チェックをして、失敗したら例外を投げる。toml11のatは型チェックと範囲チェックをして、失敗したら例外を投げる。

だが実際には、要望が合ったのはoperator[]だ。std::mapなどのoperator[]は値の変更・追加ができるので、

toml::value  v = toml::table{};
v["key"] = 42;

のような書き方ができるようになる。なるほど便利だ。

これに関してはとても悩んだが、今回は見送った。

理由は、一貫性のある実装ができないからだ。より正確に言うと、期待される振る舞いに一貫性がない。

C++プログラマにとってSTLはあまりにも馴染み深く、野良ライブラリのAPISTLと少しでも違ったら怒り狂った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_tomlstaticにするのを忘れていた。@htrefil氏の報告によって、ベッドの中でGmailを開いてコードを見た私と恐らく起きていたであろう@jcmoyer氏の両名がREADMEのミスと中にあったバグに気づき、私が(明日直そう)と思って就寝している間に@jcmoyer氏がPRを投げていた。

というわけで直った。

move semantics

前のバージョンではmove関係のロジックが若干おかしくなっており、toml::valuemoveして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::parseserialize関数をextern templateで先にコンパイルしておくというのをやってみたが、あまり劇的には短縮されなかった。GCCのプロファイルを見る限り、コード生成部分よりももっと別のところが時間を食っていて、結局そのブランチはマージしていない。多分ヘッダをもっと分けないと効果がないのだろう。

おわり

そんな感じです。