toml11 v3ができてきた

以前「やりたいな〜」と書いた通りの変更をゴリっと入れてみた。まだマージしていないが、v3ブランチがpushされている。今まで通していたテストは全て通った。追加機能のテストも最低限は通った。しばらくテストの拡充をしてからベータ版を出すつもりでいる。

https://github.com/ToruNiina/toml11/tree/v3github.com

(追記:マージしてブランチ消したら上のリンクが切れたのでトップのリンクを貼り直しています) github.com

主な変更点は以下の通りだ。

  • toml::parsetoml::tableではなくtoml::valueを返すようになる
  • toml::valuetoml::basic_value<toml::discard_comments, std::unordered_map, std::vector>エイリアスになる
    • toml::tabletoml::value::table_typeの、toml::arraytoml::value::array_typeエイリアスになる
  • 型名、enum value_tの名前、is_xxxas_xxxの名前がsnake_caseに統一される
  • いくつかの便利関数のtoml::table向けオーバーロードが消え、toml::value版に統一される
  • コメント取得に関わるインターフェースが整理される
  • あまり使い勝手のよくなかった古の隠し機能、froml_tomlinto_tomlが消される(ユーザー定義型変換サポートが入ったので)

シンプルな使い方をしていたなら乗り換えコストは小さい方だと思う。事実、以下のコードは一切の乗り換えコストがない。どちらのバージョンでも動く。

const auto data = toml::parse("example.toml");
const auto num  = toml::find<int>(data, "key");
const auto vals = toml::find<std::vector<double>>(data, "vals");

が、とはいえこれは結構大きな変更なので、どうしてこうなったのか理由を書いておきたい。

toml11の歴史はたった3年だが、私のC++の経験(今年で5年め)と比べるとこの3年は長いのだ。

最初期はUpperCamelCaseを使っていた

これがまずある。だがしばらくしてsnake_caseの方を好むようになってしまって、段階的に移行したいと思うようになった。そのためにバージョン2では型名にエイリアスを貼って両方サポートするようにし、READMEではsnake_caseを推していくことで移行を狙っていた。

これのせいで少し厄介なことが起きていた。TOML公式レポジトリでは浮動小数点数Floatと呼ばれている。キャメルケースを使っているならこれをそのまま使えばよかったのだが、困ったことにfloatはキーワードだ。なのでスネークケースの型名はfloatingになるしかない。すると、is_floatas_floatがややこしい。自分でもどっちだったか忘れることがあるのに、ユーザーが使いやすいわけがない。さらに型情報のenumは(エイリアスが作れないので)キャメルケースのままだった。使いにくいことこの上ない。

なので、何かメジャーアップデートをすることがあればぜひ直したいと思っていた。ユーザーインターフェースの統一感は何より重要だと思っている。この名前を完全に統一するのは良いライブラリの必要条件だ。

というわけで、思い切って型名は全て統一するようにした。こう書くと当たり前のことすぎて「何行ってんだこいつ」だが……。v2で始まった型名の移行を完了する、と表現した方が妥当かも知れない。

最初期はtoml::valueはあまり重要視していなかった

バージョン1では、toml::valueは本当に単純なenum + unionという存在で、ほとんど機能を持っていなかった。これは、私が何かを提供するよりも、慣れ親しんだSTLコンテナに即変換できる型を使うことで学習コストが減るのではないかと期待してのことだった。

だが使ってみると、少なくとも私にとっては、ユーザー(私だが)定義関数を使って呼ぶのは少し面倒だし見た目も少し不恰好な気がして、大半の用途ならそれだけで事足りるような便利関数をいくつか用意してもいいかもなと思うようになった。

というわけで実装していったのだが、バージョン2で新機能を一つ追加するたびにtoml::valueの重要性が増していく。そのままの流れで、toml::valueはこのライブラリの中で重要な位置を占めるようになり、逆にそれ単体での機能の弱さや、他の関数で中心に据えられていないのがアンバランスになった。

toml::value中心に据えられていない一番アンバランスな箇所は、toml::parseの戻り値だ。これはtoml::tableを返す。確かにTOMLのファイルはテーブルと一対一対応するような構造になっているが、toml::tabletoml::valueが持つような追加の情報を一切持てない上、このせいで関数のオーバーロードを多めに持たないといけなかったり、型変換のせいでオーバーロード解決が曖昧になったりして目の上のたんこぶだった。

とはいえtoml::parseの戻り値を変更するのは凄まじいBreaking Changeだろう。なので勇気が出ていなかったが、逆に変更するなら今しかないので、これも思い切って入れた。

toml::valueをカスタマイズできるようにした

実を言うと上の二つは大きな要因ではあるがバージョンアップを決意した直接の要因ではない。勇気が出た理由はこれだ。

何ができるようになったかと言うと、テーブルや配列型として使えるコンテナクラスが交換可能になり、さらにコメントを保持するかどうかも決められるようになった。

これは「コメント保持して変更してシリアライズできない?」と言うIssueに対して考えた策を発展させたものだ。コメントを持つとなると、追加の文字列か何かを持つ必要がある。だが文字列は無視できるほど軽いものではない。なので、コメントなんかいらねーよ、と言うユーザーには負担を掛けずにコメントを使う理由のあるユーザーのサポートをするには、toml::valueを分裂させる他なかった。

ここから下される結論は、using string = basic_string<char>;のように、テンプレート化されたクラスのデフォルト型を使うというものにしかならないと思う。あ、いや継承してもいいのか。まあそれはさておき。コメントを入れられるコンテナを二つ用意しておいて、片方は普通に一行ずつコメントが入っているコンテナ、もう片方は永遠に空のままで無視できるサイズしかないコンテナにする。これを使い分ければ、どちらにも必要最低限の負担しかかからない。

以下のようなインターフェースにすればわかりやすかろう。

const auto data =
    toml::parse<toml::preserve_comments>("sample.toml");

こうすると今までと違ってコメント専用コンテナが(保持することを選んだ場合のみ)toml::valueに追加されることになるので、あとで変更できるし、シリアライズもできる。実際、シリアライズするときにコメントが保持されているかのテストも行なっている。結構使い道の幅が広がるのではないだろうか。

だがこれは大きな変化だ。そしてこれを入れるなら、ついでに他の型も変えられるようにするといいだろう。と言うわけでテーブルの実装をstd::unordered_mapstd::mapを選べるように(他はテストしていないがSTLコンテナとインターフェースが揃っているものなら、e.g. Boost.Container、使えるんじゃないかと思う)、アレイの実装をstd::vectorstd::dequeを選べるように(他はテストしていないが(略))した。

こうなってくると、新バージョンの目玉機能ができることになるので、整理だけのためにユーザーに不便を強いることにはならない。そう考えるとここでいっちょ整理してもいいのではないかと思ったのだ。

そしてこれが、toml::parseの戻り値型を変えたいと思った最後のひと押しになった。toml::tableはただのunordered_mapなので、追加の情報は持てない。だが実際には、「ファイルの先頭のコメント」のようなそこにしか入れられない情報というものはある。toml::valueを返すようにすれば、ファイルのルートにもこのような情報を持たせることができるようになる。この後押しがあったので、続く一連のインターフェースの整理を始める気になった。

整理のメリット

まず、これまではtoml::tabletoml::valueの暗黙変換を許していた。これは、何度も葛藤があったのだが、やはりexplicitにすると様々なところで「えっこれできないの!?」となってしまったので、渋々implicitにしていた。これはv3でも変わらない。

これのせいで、いくつかの便利関数のオーバーロードが複雑になっていた。例えば、toml::findtoml::valuetoml::tableも受け取る。これもtoml::parsetoml::tableを返すためだ。対してtoml::findは型を指定しなかった場合はtoml::valueを返すので、以下のようなコードを実現するにはそうせざるを得なかった。

const auto data   = toml::parse("example.toml");
const auto table1 = toml::find(data, "table1");
const auto table2 = toml::find(table1, "table2");

だがこれのせいで、「toml::table -> toml::valueの暗黙型変換をすればマッチするようなオーバーロード」がオーバーロード解決に参戦してくる。これが非常に厄介だった。結構頑張ったが、避けられない部分は出てきて、実際にIssueも立っている。もしtoml::parsetoml::valueを返すようになっていたらtoml::tableのサポートは最低限で済んだのに、と何回も思った。toml::tablestd::unordered_mapなので、理論上はユーザーは(私が提供できるような)どんな便利関数も自分で実装できる。だがtoml::valueは私が定義した型なので、それを外部から使うにはまともなAPIによる十分なサポートが不可欠だ。

まあでもこの問題は、全ての箇所でtoml::valueを優先することによって一応の解決を見た。逆にtoml::tableのサポートはほぼdropされる。とはいえ上に書いたコード片は(全ての箇所でautoを使っているので)そのまま動く。なので聞いた印象よりは、実際の影響範囲は少ないと思う。

その他雑多なこと

あとやらないとなーと思っていることとして、以下のようなのがある。

ビルド済みライブラリを作れるようにする

メタ関数を結構使っている上にSFINAEでオーバーロードを操作したりしているので、コンパイル時間が結構長い。なのでコンパイル時間を短縮するためにライブラリをビルドできるようにしてもいいかもしれない。

とはいえヘッダオンリーの利点を捨てるわけにも行かないので、extern templateみたいなのをマクロで出し分けて、必要な人だけリンクするようにすればいいのではないかなあと思っている。

まあこれは新機能なので別にv3を出してから追加しても構わないだろうと思っている。

Boost.Testのバージョンアップに追従する

以前やろうとして、CIがうまく行かなくてやめていたのだが、CI力が付いてきているので多分今ならいける。

ただ結構テストを書いてしまっているので、いちいち関数を変更していくのがタルい。

自分のライブラリはメジャーアップデートしようとしておきながら中で古いバージョンのライブラリを使い続けてるのちょっとダメな気がするので、これは(ユーザーには何の違いももたらさない割には)実は自分の中では比較的優先度が高い。

まとめ

  • 数ヶ月中を目標にv3ででかい変更が来るよ
  • 新機能としてtoml::valueがかなりカスタマイズできるようになるよ
  • コメントを取得していじり回した後にシリアライズできるようになるよ
  • インターフェースが整理されて、統一感が上がってついでに既知のバグも解決したよ
  • 実はちょっと前にv2系のマイナーアップデートを出してて、バグ修正とか、エラーメッセージにヒントを足したり、v3で廃止される関数のいくつかがdeprecatedされたりしてるよ

まあ多分、今回みたいな変更は向こう数年は入らないと思う。だいたい不満だったことは解決してしまったので、TOML側がすごい変更を入れて来たりするか、私が過激派になってビルドにC++20を要求するようになったりしない限り、内部実装を除けばこれで概ね色々解決しただろうと思う。細かいアップデートはまた入ると思うけど。使ってくれてる人は、これからもちょいちょいレポジトリを見に行ってやってください。