toml11 v4をリリースした

しました。

かなり大幅な変更を導入し、パーサ関係もtoml::valueもほぼ全部書き換えたのですが、シンプルな使い方をしている場合は多分ほとんど変更なしでコンパイルが通ると思います。

ちょっと進んだ機能を使っていた場合は、色々と変更につき合わせるかと思いますが、その分新機能も多くあるので、お付き合いいただけると幸いです。

そもそもtoml11とは

C++でTOMLファイルを扱うためのライブラリです。 その名の通りC++11以降をサポートしており、11, 14, 17, 20でテストしています。

もとはヘッダオンリーライブラリですが、コンパイル時間が長かったため、v4からはビルドする選択肢を追加しました。依然としてヘッダオンリーで使用できるのでご安心ください。

他のライブラリと比較した場合の特徴としては、

  • エラーメッセージがわかりやすい
    • ファイル内の位置を含めた、わかりやすいエラーメッセージを志向しています。
    • パース時のみならず、値を取り出そうとした際もわかりやすく報告します。
  • 手厚いC++流の型変換サポート
    • TOML配列[1, 3.14, "foo"]std::tuple<int, double, std::string>に一行で変換できます。
    • ユーザー定義型や、他のライブラリの型との相互変換もサポートしています。
  • 最高レベルのTOML規格準拠度
    • 公式のエッジケース集であるtoml-lang/toml-testに合格しています。
  • 細かい制御が可能なシリアライズ(v4以降)
    • あるテーブルは必ずインラインに、ある整数はhexで、などの制御が可能です。
  • boost.multiprecisionなどと連携が可能(v4以降)

などが挙げられます。

「エラーメッセージがわかりやすいかはこちらが決める」という回答こそが正しい態度だと思うので、toml11 v4の出すエラーを少しお見せしておきます。

これは整数のフォーマットに問題があった場合です。

これは定義しようとした値が既に定義されていた場合です。

これはパースが終わった後に読み込もうとした値の型が違っていた場合です。

改めて見てみると、位置情報はソートした方が良さそうですね。

v4での変更点

どういう変更をしたのかについて、その気持ちをメインに書いていこうと思います。

お互いに依存関係があまりないものを並べたつもりなので、興味のないところは適当に読み飛ばしてください。

理由や気持ちをメインに書いていたらかなり長くなってしまったので、手っ取り早く見たい人は公式のドキュメントのchangelogfeaturesを見て下さい。

toml::spec でTOMLバージョンを指定できるようにした

toml::specというものを導入しました。これは、toml::semantic_versionを受け取って構築できる構造体で、TOML言語機能に対応するフラグを持ちます。TOML言語機能はtoml-lang/tomlでのIssueやコミットの粒度で管理されています。

新バージョンを指定することもできますし、使いたい言語機能だけONにすることもできます。

toml::spec spec = toml::spec::v(1,0,0);
// inline table内で改行できる新機能のみ使いたい
spec.v1_1_0_allow_newlnies_in_inline_tables = true;
const auto input = toml::parse("input.toml", spec);

このフラグはtoml::parsetoml::formatで考慮されます。 これらの関数はデフォルトではtoml::spec::default_version()を取ります。 デフォルト値はTOML側からリリースされ次第、最新版に上げていこうと考えています。

まあ、今toml-lang/tomlではよく議論に参加する人たちの意見が真っ二つに割れているトピックがあり、かつ最終決定権を持つメンテナがあまり姿を現さないので、いつになるかは全くわかりませんが……。

(TOMLの元の開発者は0.5.0の頃にはほぼ姿を見せなくなっており、実質的に一人で最終決定することになってしまったメンテナの人も数年前かなりつらそうにしていました。その人も今半年に一度以下しか現れなくなっています。メンテナを増やそうという話も出ましたが、そもそもメンテナが現れないので話が進んでいません。決定がなされないために堂々巡りを繰り返して荒れているIssueもあり、こういうのに対処するのは精神的に厳しそうだなと感じます。手助けしたいところではあるのですが、私には何の権限もないので議論に参加しても再燃させる以外のことはできず、申し訳ないところです)

なぜ?

toml11 v3ではこのような機能はなく、選べるバージョンはTOMLの最新バージョンのみで、すべての新機能はTOMLレポジトリでmasterブランチにマージされた時点で実装し、TOML11_USE_UNRELEASED_FEATURESが定義されていればリリース前から使用できるようにしていました。

このようにしていた理由はいくつかあります。

一つ目は、toml11 v3を開発していたころは、TOML自体のバージョンがv0.4.0からv0.5.0ほどで、v1.0.0になっていなかったからです。 基本的に、v0.x.yの不安定バージョンにはあまり留まるべき理由は感じられず、また新機能に関してはユーザーのフィードバックが多い方がよいので、古いバージョンに留まるための機能はむしろ実装しない方がよいと考えていたためです。

しかし現在はTOMLのv1.0.0が出て、v1.1.0に導入される機能がおおむね固まって、リリースのための細部を詰めている状態です。 安定バージョンであるv1.0.0がリリースされたため、ライブラリとしてはTOMLバージョンをアップデートせずに以前のバージョンに留まるという選択肢を考慮する必要が出てきました。 v1.1.0では基本的に機能追加しかされていないので、古いバージョンに留まる理由はあまりないのではないかとも思いますが、ライブラリ機能としては重要度が一段階上がった形です。

二つ目の理由は、実装上の問題です。toml11は文法チェック用のスキャナを持っており、パーサはスキャナによって文法を確認してから値を読んでいます。 これによって、数値などの値を読む箇所では文法的に正しいものがくるという前提で書くことができ、エラー処理が簡単になります。 スキャナは簡単なコンビネータを組み合わせて、チェックが簡単なようにABNFの定義と一対一対応するようにしています。

toml11 v3ではこれはテンプレートを使って静的に合成していました。なのでマクロで制御するのが一番容易な選択肢でした。 もちろん、複数パターンを定義して実行時に切り替えれば実装は可能でしたが、そうすると実装がかなり膨らんでしまうので選択肢としては微妙でした。

toml11 v4では、コンパイル時間を短くするということも目標の一つになっていたため、ここを全て実行時に構築するように切り替え、そこで個別に言語機能フラグを見るという形にして解決しました。

細かいフォーマット情報を指定できるようにした

toml11 v4では、toml::valueに格納するそれぞれの型にxxx_format_infoを追加しました。 例えばintegerに対してはinteger_format_infoがあり、integer_format::hexなどのフラグや、widthなどを格納できるようになっています。

パーサはパース時にこれらの情報を収集して保存し、シリアライザはその値を参照してフォーマットします。 これによって、0x00c0_ffeeのようなフォーマットを維持できるようになりました。

また、ユーザーがTOMLファイルを生成する際にも、インラインテーブルを使うと綺麗になるところは必ずインラインテーブルにするとか、長くなるけれど配列を一行にしたい場合に必ず一行にするなどの設定ができます。

なぜ?

toml11 v3では、シリアライズの際に指定できるフォーマット情報はあまり多くありませんでした。 stringのフォーマット""''、それから浮動小数点数の精度くらいで、それ以外は自動で判定していました。とはいえその自動判定もあまり綺麗にはできておらず、シリアライズ関係の機能は充実しているとは言い難い状態でした。

特に、16進数の整数をパースしてシリアライズしたら維持されないことや、フォーマット時に一部のテーブルをインラインテーブルに指定できないことなどはかなり不便な要素で、自分で使っていてもたまに苛立っていました。 それを解決するにはかなり大きな変更が必要だったので尻込みしていましたが、他にも様々な問題が蓄積していたのでこの機に変更しました。

困ったこと

パーサの実装を終えてスタイルがおおむね保たれることを確認した後、困ったことに気付きました。

toml11では、array of tablesは単にテーブルの配列になっています(自明に聞こえますが、実装によっては専用の型があることがあります)。 配列のデフォルトフォーマットをonelineにしていたら、ユーザーが構築したテーブルの配列の配列部分のフォーマット指定がonelineだったので、常にインラインテーブルの配列としてフォーマットされてしまいました。 普通、テーブルの配列を作って出力すると[[array.of.tables]]の形式で出力されると予想されると思います。 しかし、シリアライザの実装は上位のレイヤーにある値のフォーマット指定が優先されるので、配列がonelineだと必ずインラインテーブルの配列になってしまうのでした(しかも無理やり一行になる)。

# インラインテーブルの配列
array-of-inline-tables = [
  {name = "foo"},
  {name = "bar"}
]

# 通常のarray of tables
[[array.of.tables]]
name = "foo"
[[array.of.tables]]
name = "bar"

array of tablesを出力したいというだけのユーザーに、配列部分のフォーマット指定を毎回変えさせるのは流石に不便です。 どちらかというと[[array.of.tables]]の方がデフォルトのフォーマットのはずなので。 この食い違いを避けるため、結局arrayにはtoml::array_format::default_formatを追加して、その場合だけフォーマットを自動的に判別しています。

toml::valueの整数や浮動小数点数型を変更できるようにした

toml11 v3では、コンテナは切り替えられるようにしていましたが、integer_typestd::int64_tで固定、floating_typedoubleで固定でした。 TOML規格では整数は最低でもstd::int64_tの範囲を表現できる型、浮動小数点数は最低でもdoubleの精度を持つ型ということだったので、標準C++の範囲内ではこれがほぼ唯一の選択肢です。

ですが、場合によってはstd::uint64_tを使いたいという人や、さらに大きい整数を使いたいという人がいるようでした。 厳密にはstd::uint64_tを使用するとTOML規格違反なのですが(負の値を読み込めないので)、ユーザーが何をしているか分かった上でやるのならそれを止める理由はありません。なにせC++ですからね。

あとはどのようにして定義するかです。一つ目の方法は、シンプルにtemplate引数を増やすことです。

template<typename Comment,
         template<typename ...> class Table = std::unordered_map,
         template<typename ...> class Array = std::vector,
         typename Integer = std::int64_t,
         typename Floating = double
         >
class basic_value;

これにはいくつか微妙なところがあります。

まず、ユーザーは順番を知っていないとパラメータを決められません。

この順序の決め方は結構難しいところです。デフォルト引数を与えている以上、頻繁に変更すると考えられるものを先に置きたいところです。しかしそれはどの変数でしょうか? こればかりはよくわかりません。検索すれば使用例が出てくるかもしれませんが、使いにくいから使っていないだけで実はArrayを一番最初に置いたら全員boost::container::vectorに乗り換えるかもしれません。

まあそれは流石にないとして、少なくともIntegerを変えたいという人と、Tableordered_mapに変えたいという人は実際にいました。 なので頻出順ならそのどちらかを先頭にして、もう片方を二番目にすればいいのですが……その順序だとかなり覚えにくくなりそうです。

TOML、というか大まかにほとんどの言語で、ざっくりとした印象として型の複雑さに関する順序のような感覚が共有されているように思います。 booleanintegerfloatingarraytableの順に複雑さが上がっていくように思えませんか。多くの場合、説明もこの順になっている気がします。 そういう感覚的な順序がある以上、これの昇順か降順で並べるのが記憶には一番よいでしょう。つまり、まあ特別なComment型は先頭に置くとして、こうか、

template<typename Comment,
         typename Integer = std::int64_t,
         typename Floating = double,
         template<typename ...> class Array = std::vector,
         template<typename ...> class Table = std::unordered_map>
class basic_value;

あるいはこう

template<typename Comment,
         template<typename ...> class Table = std::unordered_map,
         template<typename ...> class Array = std::vector,
         typename Floating = double,
         typename Integer = std::int64_t>
class basic_value;

するのが覚えやすそうです。少なくとも、勝手に想像した使用頻度順

template<typename Comment,
         template<typename ...> class Table = std::unordered_map,
         typename Integer = std::int64_t,
         template<typename ...> class Array = std::vector,
         typename Floating = double>
class basic_value;

よりは覚えやすそうです。これはtemplate template引数が分散していて見た目にも悪いですしね。

ところが、昇順と降順のどちらを選んだとしても、TableまたはIntegerが先頭と末尾に分散してしまいます。 なので片方の用途ではかなり面倒な設定をしなければならないし、順序を毎回確認しないといけません。 どちらに転んでも使いにくそうだということと、私自身がこの長ったらしいbasic_valueの定義を開発中ずっと書き続けるというのが嫌で(結局このライブラリの開発は私の手の速さに完全に依存しているので、私にとっての楽さはかなり優先順位が高いです)、これらのパラメータを閉じ込めたstructを作ってそれにパラメータを持たせようと考えました。

template<typename TypeConfig>
class basic_value
{
    using integer_type = typename TypeConfig::integer_type;
};

また、指定するにしてもほとんどの型はデフォルトのままでしょうし、そうなるとほとんどがコピペで済むはずなので、そこまでの手間ではないはず、と考えました。 どちらかというとtoml::parsetoml::basic_valueでパラメータを指定する回数の方が多そうなので、そこのパラメータ数を減らした方がマシに思えたというのもあります。 エディタの補間で書くのが早くなっても、長いコードは読むだけでそこそこ疲れますからね。

このやり方にはもう一つの利点があります。 Integerに通常の実装でないもの、例えば多倍長整数などが来たときにはパーサが無関係ではいられません。 integer_typefloating_typeをパースするときにはstringstreamを使っているのですが、operator>>を実装していない型は受け入れられないことになります。 別のライブラリを組み合わせて使用しようとしたら何らかの理由でstream対応がなかった、あるいはより高速な方法が与えられていた場合のために、何か抜け道を用意したいところです。

また、toml11では拡張機能として浮動小数点数の16進数フォーマットに対応するようにしたのですが、一部の標準ライブラリ実装だとstd::hexfloatが対応していないことがあるので、std::sscanfを使用しています。 そのため、floatdouble以外にすると16進数フォーマットを使用できません。浮動小数点数をカスタマイズして、かつ16進数フォーマットを使いたいという場合は、自前で実装してもらうしかありません。

そういった場合を考えて、staticメンバ関数parse_intparse_floatTypeConfigに持たせることにして、パーサが_などの余計な部分を取り除いた文字列から実際の数値にする部分の処理をその関数に転送するようにしました。 これで、型をカスタマイズしたときに読み込み方もカスタマイズできるようになりました。

また、operator>>が定義されている場合はデフォルト実装で問題ないので、デフォルト実装を簡単に呼び出せるように分離してあります。 ほとんどの場合はこのデフォルト実装を使用すればいいはずなので、まあ、手間としても許容範囲なのではないでしょうか。

複数のエラーを報告可能に

toml11 v3では、シンタックスエラーを一つ見つけると即throwして終了していました。 ですが、ファイルが長くなるとシンタックスエラーは二つ三つと混ざってくるものです。 gccなどは複数のエラーを報告できるので、パーサの実装を頑張ることによって複数のエラーを報告できることはわかっています。

というわけで頑張ってみました。 まずはパースエラー用の構造体を作って、エラー情報を格納するためのコンテナをパーサーが持って回るようにします。 そしてパースエラーが出てきた際は、そのエラー情報を格納した後、次の(正しいと期待できる)値が出現するところまでスキップします。

そうやってある程度はできるようになったのですが、結構これは難しいですね。

整数や浮動小数点数などの一行で確実に終わるものを読んでいるときにエラーになった場合は、次の改行までスキップすれば問題はありません。 それでさらにエラーが出たとしても、それも普通にエラーとして報告していいはずです。

ですが、範囲を明示する値はより複雑です。配列やテーブル、そして文字列のことです。 文法エラーで予期しない位置に閉じ括弧があるのか、閉じ括弧の種類を間違えているのか、シンプルに忘れているのかはすぐにはわかりません。 しっかり調べても判断がつかないケースが多いでしょう。

例えば"で始まった文字列が最後の行まで"を持っていなかった場合を考えます。 以下は正しいTOMLファイルで、文字列であるmultiline_str以外の値は定義されていません。

multiline_str = """
k = "value" # this is just a string.
"""

これを勘違いして単一行文字列の"で囲ってしまった場合、どうなるでしょう。

invalid_multiline_str = "
k = "value" # this is just a string.
"

ここでシンプルに閉じクオートがないと判断して次の行に行くと、次の行を普通にkey-value pairとして読んでしまい、突如現れた"にまたよくわからないエラーを吐くことになります。 実際、quoted keyというものがあるので、間違ったquoted keyがある、というエラーになるでしょう。

まあここまで異常なファイルはあまりないでしょうが、似たようなことはいろいろと考えられます。 []{}も、シンプルに対応する括弧までスキップしようとするとそもそも括弧がないとか、改行までスキップして再開したらまだ配列の中(と書いた人が思っている場所)だったとか、色々あります。 なので、そもそもどこまでスキップするべきかが全くわからないのです。

これは、最終的にどこまでここに時間(とコード量)を投下するかという問題になるでしょう。 正しいTOMLファイルは文法が定まりますが、正しくないTOMLファイルのパターンは無限です。 拾えるエラーを増やすには、地道にケースを足していくしかありません。

なので今回はエラーからの復帰にはあまり力を入れず、最初のエラーをちゃんと報告することの方に力を入れました。 簡単なエラー、一行で終わるはずの値に関してはちゃんと復帰できるはずですが、配列やテーブルの内部で発生したエラーからの復帰は失敗して少し変なエラーが出ることがあります。 これは今後少しずつ綺麗にしていくつもりですが、一気に解決できるような問題ではないので、気長にお待ちください。

try_parseの追加

toml11 v3では、パーサはtoml::parseオーバーロードしか提供しておらず、これはエラーに出くわすと即toml::syntax_errorを送出して終了するという実装でした。

私の使い方では、TOMLは起動時に読み込むコンフィグファイルなので、これが失敗するとそもそも起動する必要がなくなるため投げっぱなしで終了してまったく構わないものでした。 ですが、世間ではもっと細かい制御のために後で読み込んだりする場合があるようで、この例外を受け取って色々している人が大勢いたようです。

ま、これはよく考えると当たり前で、GUIを起動した後にファイルを読み込んでロード、という場合なんかは、読み込んだらウィンドウごと落としていいようなものではありません。 そうなると、try-catchでエラー制御をするのは結構面倒ですし、もっとEither<L, R>Result<T, E>のようなものを提供したいところです。 元々toml11 v3の頃からresult<T, E>を実装していたので、実装するとするとこれを使うのがいいだろうと考えました。

また、例外を投げられない状況で使いたい人もいたそうで、そのために例外なしバージョンがしばしば要求されていました。 標準ライブラリを使用しているために完全に投げないようにするのは無理ですが(メモリアロケーションに失敗したりするとstd::bad_allocが送出されます)、少なくともtoml11から投げることはないようにすることはできます。

とはいえ、toml11 v3の実装ではこれが技術的に難しい部分がありました。 ところどころで、複数の可能性がある場合に(valueの型など)一つ目の選択肢でパースして、失敗したら二つ目を試すというようなことをしていたせいで、それが真に問題のあるパースなのか、単にこちらが選択を間違えただけなのかがわかりませんでした。 特に配列の奥で発生したエラーを真にエラーであると伝えるのが当時の実装上難しく、仕方ないのでその場でthrowしていました。これは色々やってみましたがv3では解決を少し諦めたような状態でした。

v4ではパーサを書き直すことにしたので、この問題の原因になる要素を排しました。 正直に言うと、複数のエラーを報告するために色々やっていたら自然とできたので、何がクリティカルだったのかあまり思い出せません。

名前は少し悩みましたが、try {v = toml::parse()} catch(...) {} に対応するということで、toml::try_parseにしました。 例外を投げる方の名前を変えるとtry {try_parse()}になってちょっと変ですし、同じ名前の関数の返り値の型が変わるのは(メジャーアップデートとはいえ)微妙ですしね。

std::initializer_listのサポートを削除

toml11 v3では、 toml::valuestd::initializer_list を取るオーバーロードが用意されていました。

これにより、 toml::value を配列やテーブルで初期化する際により直観的な書き方が可能でした。

// toml11 v3
toml::value v{1,2,3,4,5};
toml::value v{ {"a", 42}, {"b", "foo"} };

しかし、これは同時に以下のような問題を引き起こしました。

一つ目は、1要素の配列と通常の値の区別がつかず、常に配列になってしまうことです。

// toml11 v3
toml::value v{1}; // 1 ではなく [1,] になってしまう

統一初期化記法が普及した現在、これは非常に不便です。

二つ目の問題は、値が全て文字列のテーブルと、ネストされた配列の区別がつかないことです。

// toml11 v3
toml::value v{ {"a", "foo"}, {"b", "bar"} };
// {a = "foo", b = "bar"}
// [["a", "foo"], ["b", "bar"]]
// のどちらでもあり得る

これらの問題は言語仕様上解決が困難です。

toml11 v4では、混乱を避けるため、std::initializer_listサポートを削除しました。

toml::value を配列で初期化する際はexplicitに toml::array を、 テーブルで初期化する際はexplicitに toml::table を指定する必要があります。

// toml11 v4
toml::value v(toml::array{1,2,3,4,5});
toml::value v(toml::table{ {"a", 42}, {"b", "foo"} });

toml::value v{toml::array{1}}; // [1,]
toml::value v{1}               // 1

toml::value v{toml::table{{"a", "foo"}, {"b", "bar"}}};
toml::value v{toml::array{toml::array{"a", "foo"}, toml::array{"b", "bar"}}};

これにより toml::value をテーブルや配列で初期化する際に少し不便になりますが、 explicitに型情報を記述することにより予測不能な値になることは避けることができます。

正直これはほかに何か方法がないかと色々考えたのですが、あまりいい解決策が思いつきませんでした。

toml11が強く影響を受けているnlohmann/jsonでも同様の問題は報告されていますが、根本的解決には至っていません。

https://json.nlohmann.me/home/faq/#known-bugs

それを見てこれ以上悩んでも解決しないかなと思ったのと、個人的には「ライブラリがよしなにする」よりは「必要なことはすべて書く」という方向に倒した方が好ましいため、今回は思い切って削除しました。

CMakeListsの改善

toml11 v3では、CMakeListsはかなり適当になっていました。

そもそもtoml11 v3ではインストール方法全般がかなり適当で、「git submoduleか何かで取ってきてインクルードパスを通せ!」という野蛮なスタイルを採用しており、なんとなくそれっぽいCMakeLists.txtになっていたのは私よりはコントリビュータの方々の尽力のおかげでした。

これは私が自分で好きにできるプロジェクトでは「野良ライブラリはコミットハッシュまで指定してプロジェクトごとに複製を持った方が何かと楽だからインストールはしない」という脳筋スタイルを採用しているからで(今まで自分と他人で使っているライブラリのバージョンが違うせいで起きた様々なエラーに苦しめられたためです。今はストレージは有り余ってますし……)、やはり自分であまり使わない機能というのはおざなりにしてしまうものだなと思います。

とはいえ、ちゃんとしたプロジェクトから使用する際にはCMakeadd_subdirectory(toml11)というようにしたいし、常に使えるようにインストールしたい、find_package(toml11::toml11)で使えるようにしたいというのは至極まっとうな話だなあと反省したので、今回真面目にCMakeを書くことにしました。

mesonとかへの移行も考えてみたのですが、しばらく調べた後、慣れていないものを使うとなると結構時間がかかるし、v4を出した後に考えようと思って今は何もしていません。 meson熱が再燃したら考えると思います。

コンパイル済みライブラリを選択可能に

toml11の一部の関数はtemplateではなく、単にinlineにすることでヘッダオンリーを達成しています。

これらの関数は事前にビルドすることが可能で、実際そのような手段を取っているライブラリは多くあります。

もともと私はかなりコンパイルに時間のかかるコードを書きがちで、そのためtoml11のコンパイル時間は気になっていませんでした。 しかし最近は速いビルド、いいよね……となってきて(自明)、toml11のコンパイルの遅さが気になり始めたため、templateなしで実装できそうな部分を見つけてはinlineにし、かつinlineかビルドかを切り替えられるようにしました。

具体的には、inlineを全てTOML11_INLINEマクロで置き換え、ビルドする際に消せるようにします。 その後inline関数の宣言と実装を切り離して、ヘッダオンリーライブラリの場合はヘッダでinclude、ビルドする場合はsrc/*.cppでインクルードするという風にします。

toml11ではそれぞれを_fwd.hpp_impl.hppにしました。 _implの実装は_fwdの宣言やtemplate関数などを必要とするので、implからfwdincludeできる必要があります。 fwdを最初は分けずにいたのですが、そうするとヘッダオンリーの際にfwdimplincludeし、implfwdincludeするせいでclangdなどがエラーを出すことがあり、これを避けるために結局fwdも分離することにしました。 なので結局、分離したファイルの見た目は以下のようになっています。

#ifndef TOML11_COLOR_HPP
#define TOML11_COLOR_HPP

#include "fwd/color_fwd.hpp" // IWYU pragma: export

#if ! defined(TOML11_COMPILE_SOURCES)
#include "impl/color_impl.hpp" // IWYU pragma: export
#endif

#endif // TOML11_COLOR_HPP

実は、ここで#pragma onceを使っていればclangdはループしないようにしてくれるのですが、#pragma onceは(非常によく普及しているとはいえ)非標準の機能なので使わず、このような形で実装しています。 少し調べた限り、同等の機能を持つライブラリの多くは#pragma onceを使うことで分離するファイルを_impl.hppだけにしているようです。

また、templateな関数・クラスにはかなり巨大なものもあるため(toml::parsetoml::basic_valueなど)、これらも事前ビルドするためにextern templateを使うようにしました。

さらに、template関数の実装などをインクルードするだけで行数がかさむので、toml_fwd.hppを追加して、クラスへの参照などだけ(const toml::value&を受け取る関数を宣言するhppファイルなど)ならtoml.hppincludeする必要がないようにしました。

一応、単純な読んで書くだけのコードだと、手元のマシンでg++-13 -std=c++17 -O2の場合、ヘッダオンリー版は10秒程度かかるところ、事前ビルドをすると1.5秒になったので、結構早くなったなと思っています。

single_include/toml.hppの追加

これは本当に必要か? とずっと思っていたのですが、意外に需要が大きいのがわかってきたので実装しました。やはり手軽さは正義のようですね。

結構toml11 v3ではインストール方法をないがしろにしていたな〜という自覚はあったので、色々サポートしよう、と揺り戻しが来たのもあります。

こういうsingle_includeを作る作業はAmalgamationと呼ばれていて、SQLiteはまさにその名前のツールを持っているようです。 GitHubで検索するとSQLiteのAmalgamationのミラーや独自実装がいくつか見つかります。それから、QuomというPythonによる実装もありました。

どれもおおよそ、includeしたファイルを探してその場に貼りつけ、複数回includeしないようにそのファイルを覚えておく、という実装になっています。 違いとしては、インクルードガードを消すかどうか、標準ライブラリなどの可能性が高いinclude <>を展開するか、などが挙げられます。

いくつか試してみたのですが、どれもtoml11の構造上若干扱いづらいところがありました。

toml11はコンパイル済みライブラリとして実装するために、fwd/impl/に分離しているファイルがいくつかあります。 これらをincludeするかどうかはifdef TOML11_COMPILE_SOURCESで選択されているのですが、単純にincludeを展開していくと、常にincludeするべきファイルがifdefの中に展開されてしまったりします。

fwdimplに分離されている例:

#ifndef TOML11_COLOR_HPP
#define TOML11_COLOR_HPP

#include "fwd/color_fwd.hpp" // IWYU pragma: export

#if ! defined(TOML11_COMPILE_SOURCES)
#include "impl/color_impl.hpp" // IWYU pragma: export
#endif

#endif // TOML11_COLOR_HPP

ここでimpl/color_impl.hppincludeしている普通のファイルが#if !defined(TOML11_COMPILE_SOURCES)の中に展開されてしまうとかなり困ります。 そのようなファイルはTOML11_COMPILE_SOURCESが定義されているときもインクルードされるべきものかもしれないからです。 そして、Amalgamationの際に一度展開されてしまうとファイルパスが記録されて二度とインクルードされないので、マクロの外で再度展開されることもありません。 そういったことを細かく制御する方法はどの実装も持っていなかったので、仕方がないので自分で実装しました。

実装は単純で、基本的な発想はファイルをその場に展開するのではなく正しい順番を構築してcatする、というものです。 まずtoml.hppincludeしているファイルを探し、読み込みます。 このとき、それらのファイル内のincludeは展開せず、ファイルがincludeするファイルの一覧を作ってそこに追加します。

続いて、fwd/impl/に分割されているファイルはまずそれらの二つを(メモリ上で)展開します。このとき再帰的な展開はしないようにします。 この時点で、上の例のcolor.hppfwd/color_fwd.hppimpl/color_impl.hppを内部に展開することになるので、無関係なファイルをifdefでくるんでしまう問題は解決します。

その後、それらのファイルからincludeを探し、ファイル同士のinclude関係を有向グラフにします。 fwdimplは既に展開されているので、fwd/{name}_fwd.hppをインクルードしている場合は{name}.hppをインクルードしていると解釈します。 includeには循環がないようにしてあるので、グラフができたらトポロジカルソートで直列化し、その順に書き出していくだけです。グラフのエッジを作るときに使ったincludeは出力時は無視します。

このようにすることで、必要なファイルを必要な順に並べただけの最も単純なsingle_includeが得られます。

この実装はtools/expandに置かれ、CIで変更が検知されたら上書きしてpushするという風になっています。

リファレンスドキュメントの追加

これまではライブラリ機能の紹介はREADMEに例を羅列するだけでしたが、これは充実しているとは言えない状態でした。 特に、関数やクラスがどういう定義になっているか、またどういうときにどういうエラーを投げるのか、というようなことを詳細に書く場所はやはり必要ではあります。

toml11 v4ではドキュメントを真面目に書き、リファレンスを追加しました。 かつ、ドキュメントには英語と日本語の両方を掲載しています。

なぜ?

ドキュメントは書いたほうがいいので、書きました。

いやそれならなんで今まで書いていなかったんだよという話なんですが、これは単に怠惰が原因で、今回書いたのは節目だったからというのと、翻訳コストが大幅に下がったためです。

そもそも、英語で長大なドキュメントを書くのは日本語話者にとって骨です。 よって最初から英語で書いていたのですが、そもそも自然な英語で文章を書くのがまず大変というのがあります。 ところが、機械翻訳の精度が上がったことに加えてLLMの登場によって文脈を加味した翻訳が可能になり、これらと自前の言語野を組み合わせることで非常に高速に翻訳・確認・修正のループを回せるようになったという部分がかなり大きいです。 LLMに書いてもらった部分にはたまに直しは発生しましたが、良いモデルを使うとほぼ直しがいらないようなものをどんどん出してくれたので、非常に助かりました。

翻訳の過程で一番おもしろかったのは、トークン数をケチるために長いサンプルコードを削ったドキュメントを渡したところ、細部は違いながらもほとんど同じサンプルコードを同じ位置に出してきたことです。変にケチらずに全部渡した方が良さそうですねこれ。


日本語でドキュメントを書いてそれを翻訳する、という流れにしたのは、私が楽だったからというのもありますが、そもそも日本語のドキュメントを提供したいという思いもありました。

英語と系統関係にない日本語の話者にとって、英語の習得にかかる努力は相当なものです。なので英語の文章を読むのは普通に面倒です。 私自身がそのことをよく知っているのに日本語版の提供はしない、というのはやはりあまりよくない、少なくとも英語のドキュメントに「英語だけか〜」と愚痴る資格がなくなってしまう、罪悪感なしに英語のドキュメントに愚痴ろう!と思ったわけです。

いやそうじゃなくて、なぜdoxygenを使わなかったの?

あまり要求にマッチしなかったからです。

日本語版と英語版を両方提供したい場合、コメント内で@if japaneseのようにして切り替えればいいのですが、これはつまり日本語と英語両方のドキュメントをコメント内に書かなければならないことを意味しており、コードコメントとしてはかなり長くなってしまいます。

また、doxygenは何もしないとテンプレート引数やSFINAEをそのまま表示してしまうので、@fnで見せたい形を再定義しないとまったく読むことのできないドキュメントが合成されます。 これは困るのでかなり多くの関数に@fnをつけて手動で定義を書いていくことになるのですが、これはインターフェースを自動で抽出するという利点をほぼ失うことになります。

さらに、デフォルトではヘッダオンリーライブラリの内容は全てオープンになるので、見せるものと見せないものを設定しないといけません。名前空間は丸ごと消せばいいのですが、マクロなどは面倒です。

これらの理由から、個別に大量の設定を書くのならそこまでメリットがないように思えてきて、もういちから書けばよいのではないかという結論に至りました。

テストライブラリを変更

これまでは Boost.Test を使っていたのですが、doctest に切り替えました。

boostをやめた理由は、テスト周辺が少し複雑になってしまっていたからです。 Boost.Test はコンパイル済みライブラリをリンクする場合とヘッダオンリーで使用する場合をマクロで分けられるようになっていたのですが、ビルドでひと悶着した結果これに対応するためのヘッダができてしまったりして面倒でした。 まあ、これは「かならずビルドして使用する!」と決められれば良かったのですが、なまじ選択肢を増やした方がよいと考えてしまったせいで生じた問題なので、自分で作り出した問題という向きはありますが。

あと、GitHub Actionsの古いosxでCIを実行するとbrew install boostの前にbrewのアップデートが走ってかなり時間がかかる、というような問題もありました。 これはおそらく適切なキャッシュによって解決できそうですが、これはあまり正面から解決するべき問題には思えなかったので、そこに手間をかけるよりもインストールの手間自体を減らしたいと考えるようになりました。 というわけで、git submoduleで持ってくれば即使えるというようなライブラリを探すことにしました。

C++で有力なテストライブラリはおおよそこのあたりでしょうか。 それぞれについての感想は以下のような感じです。

名称 対応 C++ standard コンパイル速度 備考
Boost.Test C++03 速い(コンパイル済み) toml11で使っていた。他のプロジェクトでも何度も使った
googletest C++11 速い(コンパイル済み) 一度も使ったことがない
Catch2 C++11 (v3以降はC++14) かなり遅い 趣味プロジェクトで何度か使った
doctest C++11 普通 趣味プロジェクトで何度か使った
Snitch C++20 普通 一度も使ったことがない。Catch2互換の新興ライブラリ
boost-ext/ut C++20 速い 趣味プロジェクトで何度か使った

toml11で使う以上、C++11に対応していないライブラリは自動的に弾くことになってしまいます。

そうなると、Catch2 v3がC++11を切り捨てた以上、Boost.Test以外に採用できるものはgoogletestかdoctestだけになり、触ったことがあるdoctestを選びました。

テストの書きやすさなどは特に変化は感じませんでした。もともとシンプルなテストしか書いていなかったのもありますが。 ですが、少なくともこれでCIの設定はかなり簡素化されました。

とはいえ、テストコードの移植はそこそこ地獄でした。sedでざっくり変更したあとに細かい部分を合わせていくところは純粋な虚無だったので、原作を読んだことのあるアニメを横目に見ながらやっていました。

サマータイムレンダ、原作の出来は言うまでもないですが、アニメの出来も結構よかった気がしますね。私はbloodborneがかなり好きなので、あの作品もかなり好きです。 原作を「なんか影響受けてそうだな~作者はゲーム好きらしいしな~」と思いながら読んでたらインタビューの写真に思いっきりアメンドーズが映ってたので草生えました。

これら全体でどれくらいかかった?

思い立ったのはおおよそ丸一年前で、そのときはうまく行くかわからなかったのでprivate repositoryで実験的にtoml::valuetoml::parseを書いていきました。 毎日書いたわけではないですが、週の半分くらいは家に帰ってから2時間ほど、土日の片方は4,5時間くらい没頭したりして、色々試行錯誤しつつだいたい1ヶ月くらいかけて概ね今の機能が動くものを作りました。 そこで完成度を上げるためにtoml-lang/toml-testにかけるとそこそこの量の微妙なバグが出て、直さないとな〜と思いつつも一度やる気が切れました。

そこから半年以上放置していましたが、色々別のことをしていたらやる気が再燃したので、3ヶ月くらいかけてちびちびとパーサのデバッグシリアライズ、その他の実装の詳細を詰めて、固まった機能から順にドキュメントを起こしていきました。 毎日やってたわけではないとはいえ、ドキュメントだけで2週間以上かけた気がします。 なので休止期間も含めると着手してから数えて1年ほどかけていることになりますか。他にも色々やっていたとはいえ、ちょっと手が遅すぎますね……。

おわりに

これまでもtoml11はC++用のTOMLライブラリとして非常に高度な機能(高いTOML規格準拠度、わかりやすいエラーメッセージ、複雑な型変換サポート等)を持っていたという自負はありますが、それでも色々と荒削りなところがありました。

コンパイルが遅いこと、single_includeがないこと、CMakeLists.txtの記述が雑なことなどのC++のコードというよりライブラリの使用に関する部分はかなり適当でした。

また、C++部分の機能でも、integer_typefloating_typeを切り替える方法がないこと、例外を投げないパーサの欠如、シリアライズの柔軟さの欠如などから機能性の面でも片手落ちでした。

toml11 v4はこれらの問題を解決し、自分で言うのもなんですが、ライブラリとして完成度が高まり隙がなくなったと思っています。

まだリリース直後なので、実際に使ってみると使いにくい部分があったりバグが見つかるかもしれませんが、今の状態ならより変更も入れやすくなっていると思うので、直していけると考えています(実際既にちょっとバグを直しています。素早く試してくれる人が多くて助かります)。

また、完成度が上がったとはいえまだ実装できそうな機能はあるので(例えばフォーマット情報はかなり最低限なので足していけるだろうと思います)、まだしばらくは高い頻度でマイナーバージョンアップを出せると思います。

TOMLファイルを読み込むプログラムを書く必要のある方は、ぜひ使ってみてください。

ちょっと思いの外長くなったので明日は無理ですが、来週くらいにより技術面にフォーカスした話をしようと思うので、暇なときにまた見に来てください。