toml11を支える技術

先週、toml11のv4で入れた変更についての話をしました。 今回は、v3の頃から変わっていない機能も含めて、どういう理由でどのようにしているのかということについてつらつら書いていこうと思います。 とはいえ、メインはv4での実装のことになるとは思いますが、前回とはあまり被らないようにしようと思います。例えばsingle_includeの実装なんかは前回もう書いたので今回は書きません。

以下で話す決定の理由は、いくつかは合理的判断として多くの人が同意するでしょうが、いくつかは完全に私の好みであり、それらは常に成立するような理由ではないでしょう。 ですがそういったことでも、少なくとも私がそれを好ましいと感じた理由を言語化するつもりなので、そういう考え方もあるんだなくらいに捉えながら読んでください。

また、コードは少しは出しますが、どれも単純化したり端折ったりしているので、気になった機能の実際の実装を見る場合はレポジトリを見に行ってみてください。 v4でメインのブランチはmainに移しましたが、masterブランチにv3の最新版が残っています。

toml::value

TOMLには複数の型があり、すべての箇所でどの値でも使うことができます。 特に、ArrayTableは、型を混ぜて持つことが可能です。 ですので、toml::valueは複数の型から一つを選んで格納できなければなりません。

不完全型への対処

格納すべき型の個数と種類が決まっているので、C++17以降だとこれはまさにstd::variantの役割です。 とはいえ、ArrayTableの実際の型がstd::vector<toml::value>std::unordered_map<std::string, toml::value>なので、再帰的な型になってしまう点には注意が必要です。 クラスの内部ではまだそのクラス自体は不完全型です。std::vectorC++17で不完全型をサポートするようになりましたが、std::map, std::unordered_mapはまだです。

class value
{
    // ここでvalueはまだ不完全型
    std::vector<value> v; // C++17以降はvectorに不完全型を入れられる
    std::map<std::string, value> // mapは無理
};

不完全型はまだサイズがわかっていないので、クラスのメンバに不完全型を使うとそのクラスのサイズがわからなくなります。 サイズがわからないものを入れたクラスのサイズはわからなくなるので、これは困ります。 C++は定義がファイルに書かれている順に確定していかないといけないので、一度全部読んで戻ってきたらわかる……という場合でもエラーはエラーになります。

これを避ける方法は、ポインタを使うことです。ポインタ自体のサイズは64bit CPUだと64bit1なので、指す先の型のサイズが何であったとしても64bit整数一つにサイズが決まります。 これによって再帰でのサイズの依存関係が断ち切れて、型のサイズが定まるというわけです。

class value
{
    // ポインタなら指す型のサイズは関係ない
    std::map<std::string, value*> ptr_map;
};

実際、std::vectorに不完全型を入れられるようになったのは、std::vectorのメンバが以下のようになっているからでしょう。

template<typename T>
class vector
{
    T* ptr_;
    std::size_t size_;
    std::size_t capacity_;
};

まあ、アロケータ関係でもう少しややこしいことが起きたりしていそうですが、大まかに言うとそういうことです。

というわけなのですが、std::vectorが不完全型を許すようになったのはC++17以降です。C++11時点でサポートするには、std::vectorの方もポインタにして逃がさなければなりません。 まあstd::unordered_mapをポインタにして逃がさないといけない以上、これはそこまで問題にはなりません。

むしろ問題は、ポインタにした値のその後の扱いです。 C++なのでこのポインタはスマートポインタにしておきたいところですが、std::unique_ptrにするとtoml::valueがnoncopyableになってしまいますし、std::shared_ptrにするとシャローコピーの面倒さが顔を出してくるので、あまりC++らしい形になりません。 C++ならばシャローコピーはユーザーがスマートポインタなどを使って実現するべきで、デフォルトの挙動ではtoml::valueをコピーしたら中身もコピーされるべきでしょう。

というわけで、std::unique_ptrを持ちつつコピーするとポインタが指す実体をコピーするstorage型を定義しました。実装は単にコピーコンストラクタで引数が指す値をコピーして新しくmake_uniqueするだけです。 これによって、コピーすると実際にコピーされ、ムーブも可能、std::unique_ptrなので特別なことをせずともデストラクトされる、という、通常の値と同等の扱いが可能になりました。 toml::valueArrayTableをこの中に格納することで、再帰を断ち切っています。

union

C++17ではstd::variantがあるのでもうこれでいいのですが、C++11にはありません。 ではどうするのかというと、unionを使います。

unionはちょうどC++11から機能が強化され、クラスも入れられるようになりました。もちろん少し頑張る必要がありますが、意外と簡単に使えると思います。

全て書くとややこしいので、少し簡略化して話します。多くの場合、unionをクラス内で使う場合は無名unionが使用されているようです。toml11もそうしています。 toml11では値が空になることもあるので(デフォルト構築時など)、charをデフォルト役として入れています。これは常に\0で初期化されますが、どうせどこからも参照されないので別に他の値でも構いません。

class value
{
    value_t type_;
    union
    {
        char empty_;
        bool boolean_;
        std::int64_t integer_;
        std::string strring_;
    };
};

構築するときは一つのメンバについて普通に構築すればいいのですが、破棄する際は(特にstd::stringstd::vectorのようなtrivially destructibleでない型がある場合は)少し面倒になります。 unionはデフォルトだと破棄時に特になにもしないので、デストラクタの中でアクティブなメンバのデストラクタを呼んでやる必要があります。

~value()
{
    switch(type_)
    {
        case value_t::string {string_.~std::string(); break;}
    }
}

普段デストラクタを直接呼ぶ機会がそうそうないので、少し変な感じがしますね。

全ての型がtrivially destructibleであれば何も呼ぶ必要はありません。なので、unionにユーザー指定の型を入れられるライブラリだとそのための特殊化をしている場合もあります。

代入の際にはplacement newを使います。newにポインタを渡してその場所に初期化する機能です。ポインタが指す先に既に領域があるという前提で初期化が行われるので、メモリの動的確保は走りません。 toml::valueでの代入では型変換をすることもあるので、変換して構築する関数を定義してそれを使っています。

template<typename T, typename U>
static void assigner(T& dst, U&& v)
{
    ::new(std::addressof(dst)) T(std::forward<U>(v));
}

他の実装

ここまで、あたかもunionstd::variantしか選択肢がないかのように話していましたが、もちろんそんなことはありません。

value_baseを定義してそこにas_integer()などを定義しデフォルトではthrow type_errorするようにしておいて、value_impl<integer>で継承したときだけ値を返すとか。 あるいは、value_baseから直接value_impl<integer>dynamic_castするとか。そういう形での実装も可能だと思います。

ただ、私はこれらを採用する気はありませんでした。これはまあ好みです。まず、値を取り出すという超頻出の操作でdynamic_castをするというのは微妙に思えます。 また、boolの1バイトを入れるために動的確保をして、参照するときにポインタを一つ追加で辿るというのは、実行中にTOMLを読むのは高々一回きりなのでパフォーマンス上何の問題もないとしても、どうにも好ましく感じられませんでした。 もちろん、unionは使い方が少し複雑で、少しミスするとリークを発生させたりするという点で危険なので、それを避けるために安全側に倒して継承を使うという選択はありだとは思います。

toml11では私の好みによってunionが選ばれました。 unionC++11から非自明なデストラクタを持つ型を格納できるようになって実用的になった言語機能であり、速度面での懸念もなく、高揚する程度の危険さがあります。 そういう言語機能は使ってみたくなるものでしょう。

parserの実装と source_location

locationとregion

toml11には、ファイル中のある一文字を指すlocationと、ある連続した領域を指すregionがあります。

locationはパーサがどこを読んでいるかを示す型です。 パーサはlocationを進めながら文法に従っているかどうかを確認し、従っていればその分進めて、従っていなければ失敗として進め始める前の位置まで戻します。

regionはファイル内の領域を指す型です。location二つから構築するようになっており、これを使って後述するtoml::source_locationを作ることができます。 これは主に、toml::valueが定義された箇所を保存するために使われるクラスです。文法チェックの際に、開始地点と文法にマッチし終えた地点とのペアから構築されます。

少し予想外かもしれませんが、この二つはregionの方が先に作られました。 v2の書き始め、regionを最初に実装した瞬間は、locationにあたるものはファイルの内容を持つstd::vector<char>イテレータで、regionイテレータのペアとして実装されていました。 その後で、v2リリース前に実装を整理するためにlocationが追加されました。 そのような経緯のため、v3まではregion.hppの中でlocationregionが定義されていました。この構成が自分でもわかりにくかったので、v4ではlocation.hppregion.hppに分離しましたが。

toml11はプロトタイプのそれ以前からずーーっとドッグフーディングしているのですが、v1の頃、自分で使っていると「目的の値が見つからない」あるいは「目的の値の型が違う」という場合にどこで間違えたのかがすぐにはわからないことの不満が溜まっていきました。 パース時はファイルのどこかを読んでいるわけなので、シンタックスエラーには行数などを簡単に出力できます。しかし、パースを一度終えてしまうと、その値がどこで定義されていたのかの情報は捨ててしまっていました。 なので、パース結果のテーブルには位置情報がなく、探しているキーが見つからなかったとき、探したテーブルがどこで定義されているかなどはわからなかったわけです。

v2を実装する際、メインの機能として、この不満を解決しようと考えました。 パース結果から位置情報を得たいので、toml::valueに位置情報を保存するしかありません。そうなると、できるだけ軽い方がいいでしょう。 かつ、リッチなエラーメッセージを出そうとすると、定義された行を文字列で表示できた方が便利です。よって、ファイル中の領域を示すイテレータのペアを持つようにしようという結論に至りました。 そういう流れでtoml::regionを追加しました。

ここで考えるべきことがあります。もしtoml::regionが単にstd::vector<char>::const_iteratorのペアなのだとしたら、それが指す先はどうするべきでしょうか。 もし指しているstd::vector<char>をパース完了とともに開放したら、当然ですがすべてのイテレータがダングリングイテレータになります。 なので、少なくともすべてのtoml::valueが破棄されるまで、toml::regionが指すstd::vectorも破棄されずに残る必要があります。 もう解決策は一つしかありませんね。toml::regionによって、std::vectorへのstd::shared_ptrが共有されるべきでしょう。

というわけで、regionイテレータのペアに加えてイテレータが指すコンテナへのshared_ptrを追加で持つようになり、同時にlocationにもイテレータとそれが指すコンテナへのshared_ptrが追加されました。 locationは同じコンテナを指すshared_ptrを持つものとそうでないものを簡単に識別できるようになり、regionは同じコンテナを指すlocationだけからしか作れなくなりました。 toml::valueregionを持つため、toml::valueが生きている間はstd::vector<char>が生存するようになり、toml::valueが定義された位置が取り出せるようになりました。 読み込みの際のユーザー定義型への変換が終わって全てのtoml::valueが破棄されれば、メモリ領域も解放されます。

このため、v2ではtoml::parseが返すものがtoml::tableからtoml::valueに変化しています。regionを持たせるためですね。

さらに、イテレータを直接使っている場合、大元が生きているかチェックなしでアクセスしてしまうミスが考えられます。 toml::valueはユーザーが構築することもでき、その場合toml::regionは空になるので、アクセスはできません。 このチェックを強制するため、イテレータを直接持つのではなく先頭からの文字数のペアを持つようにしました。 このせいで内容にアクセスするにはstd::shared_ptrにアクセスせざるを得ず、チェックを忘れないというわけです。

他の実装

toml::regionイテレータ――今の実装はオフセットですが――のペアとして実装されていますが、もう一つのあり得る解決策として、行番号と行そのものをコピーして持つというのがあり得ます。

regiontoml::valueが定義された位置を持つための構造体でした。ということは、エラーメッセージで表示される部分だけを持っていれば十分なわけです。 そしてエラーメッセージはほとんどの場合一行しか表示しません。なら、その行だけをstd::stringで持った方が、ファイル全体をstd::vector<char>として持つより効率がいいのではないか? というのはもっともな疑問だと思います。しかもその方が間違えにくそうです。

それが成立する状況は多そうですが、逆にその実装が不利になる状況も考えることができます。 私が懸念したのは、配列やインラインテーブルなどの形式によっては、行を指すstd::stringをコピーすることで実際のファイルよりも多くのメモリ領域を消費してしまう可能性があるということでした。

以下のようなファイルを考えてください。

a = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3]

配列のそれぞれの要素がtoml::valueです。これらはtoml::regionを持ちます。 もし一つ一つが行全体をコピーしていたら、この行は合計16回コピーされることになります。 16要素なのでまだマシですが、これがもっともっと長くて100要素くらいあった場合、100要素分の長い行が100回コピーされることになります。 この時点でファイル全体の量を超えかねません。

もちろん、10要素程度ならともかく、千要素や一万要素などの長い配列を改行せずにベタに一行で書く人は少ないとは思います。 ですが、それがTOMLとして何も問題ないファイルである以上、他の書き方と比べて特別遅く非効率になってしまうというのはあまり良くないでしょう。 ファイルの内容全てを持っている場合、常にファイルと同じサイズの領域が必要ですが、ファイルよりも巨大な領域を要求することはありません。 良し悪しはありますが、ファイルの形式によって効率が良くも悪くもなる実装よりは安定している実装の方が安牌だろうと考え、toml11の実装にはファイル全体を持つ方法を選択しました。

カンのいい人は気付いているかもしれませんが、折衷案として行単位でstd::shared_ptrにするというのがあり得ます。 そうすると、同じ行を大量にディープコピーするのは防ぐことができ、かつファイル全体よりも小さい領域しか要求しません。 その行に対応するtoml::valueが破棄された時点で行も解放されるので、より細かくメモリを戻していくことができます。 行ごとにばらばらにアロケートされるためキャッシュヒット率は下がるでしょうが、エラー処理の際にしか使わないものにそんなパフォーマンスは要求されません。 実装が面倒になること以外はパーフェクトな選択肢でしょう。

これを採用しなかった理由は、この選択肢に気付く前にかなり書き進めていたので書き換えるのが面倒だったからです。 locationに何行目かをshared_ptr化する関数を生やして、行番号からすでに作ったshared_ptrを引けるように管理すれば、重複しない実装はできそうですね。 あまり大きなmapを持つとlocationが重くなりそうですが、そのmap自体もshared_ptrにしてしまったらいいかもしれません。

皆さんが何かのパーサを書く際にはぜひ参考にしてください。

toml::source_location

さて、toml::regionで位置情報を持つのはいいとして、これはshared_ptr<const std::vector<char>>というかなり生々しい上に意図もよくわからないものを持っています。 エラーメッセージをユーザーが作る機能のことを考えると、そういうものを露出するのは少し微妙です。 できれば、単に行に対応するstd::stringのような、わかりやすく安全な(意図しない場所へのアクセス権を持たない)ものを見せたいところです。

そのために、toml::source_locationというものを作って、位置情報が必要になったユーザーにはこれを見せるようにしています。 これはtoml::regionから作ることができ、それが指す領域をstd::stringに変換して格納します。 先ほど言及した、「行ごとコピーして持つ実装」が指すものと同じです。 ですが、これはエラーメッセージを作る際にはじめて構築されるので、パース中またはtoml::valueを扱っている際に重くなることはありません。

scanner, syntax, parser

toml11はyacc/bisonといった自動生成ツールを使わず、パーサを自分で書いています。 これは、まあもっともらしい理由は色々考えられるでしょうが、正直にいうと書きたかったからです。

とはいえ巨大な関数群に埋もれると、文法ミスのバグを起こした時にどこで間違えたのかよくわかりません。 そのため、シンタックスをチェックする部分とセマンティクスを解釈する部分は、たしかv2の頃から分けてありました。

シンタックスの部分はかなり機械的にチェックできます。というのも、TOML公式がABNFを公開しているからです。 toml11ではパーサコンビネータのようなものを自作し、それによってまずシンタックスチェックを行い、それにパスしたものを実際の値に解釈する、という流れでパーサが実装されています。

よくある実装ではレキサートークン列に変換し、パーサーはトークン列を処理するという流れになっていますが、トークン列を構築するのはメモリ負荷が高そうだなと考えたことと、トークンを定義するのが面倒だったので、トークンには変換せず、スキャンした領域の文字列を丸ごと解釈するようにしています。TOMLはそこまで複雑な言語ではないので、このやり方で十分実装できます。

また、コンビネータは正誤判定のみを行わせ、intへの変換などは実装しませんでした。 TOMLでは_をスペーサーとして使うことが許されていたり、v4での話ですが幅などのスタイル情報を取る部分が必要だったりと、組み込んでいくと一つ一つのパーサ構造体がそれなりに巨大になりそうだったからです。 解釈部分は完全に分離した方が楽そうだったので、コンビネータは文法チェックだけ、パーサは解釈だけという設計を採用しました。

このコンビネータは、v3の頃はテンプレートでコンパイル時に生成されていました。これは正直いうとv4での関数で動的に作る実装より短くて見渡しやすかったのですが、慣れていない人には読みづらいことと、コンパイル時間に対する負荷が大きそうに見えることから、v4では実行時に構築するようにしました。

また、v3ではこのコンビネータの名前がlexerになっていたのですが、これらはスキャンして正誤判定をするだけで別にtoken列に変換するわけではないので、v4ではscannerとsyntaxという名前に変更しました。

というわけで、toml11ではコンビネータを実装したscanner.hppとTOMLバージョンに従って文法スキャナを構築するsyntax.hpp、それらを使って内容を解釈するparser.hppが存在しています。

toml::spec

v4ではTOML言語のバージョンを変えられるようにし、機能ごとにフラグを制御できるようにしました。

これはかなり愚直に分岐で実装されています。syntaxspecを受け取って、フラグを見て文法を変更しています。

例えばv4では時刻で秒数を省略できる(18:30:00ではなく18:30でよい)ようになったので、そのフラグが立っていたら以下のように後に続く:dd(.d+)?maybeにしています。

TOML11_INLINE sequence local_time(const spec& s)
{
    auto time = sequence(
            repeat_exact(2, digit(s)),
            character(':'),
            repeat_exact(2, digit(s))
        );

    if(s.v1_1_0_make_seconds_optional)
    {
        time.push_back(maybe(sequence(
                character(':'),
                repeat_exact(2, digit(s)),
                maybe(sequence(character('.'), repeat_at_least(1, digit(s))))
            )));
    }
    else
    {
        time.push_back(character(':'));
        time.push_back(repeat_exact(2, digit(s)));
        time.push_back(
            maybe(sequence(character('.'), repeat_at_least(1, digit(s))))
        );
    }

    return time;
}

フラグの名前はtoml-lang/tomlのissueタイトルかコミットメッセージから取ってきました。ないとは思いますが一応、似た名前になったときのためにバージョン番号も前につけています。

パーサ内では、このsyntaxを読んだ後、そこで秒数が続かない場合はフラグをチェックして終了、という流れになっています。 また、時刻構造体の方の定義は変わらないため秒数はゼロで初期化することになるので、フォーマット情報のhas_secondsfalseに設定しています。

    if(str.size() == 5 && spec.v1_1_0_make_seconds_optional)
    {
        fmt.has_seconds = false;
        fmt.subsecond_precision = 0;
        return ok(std::make_tuple(local_time(hour, minute, 0), std::move(fmt), std::move(reg)));
    }
    assert(str.at(5) == ':');

こういうのがいたるところに入るので、パーサの実装は割と大変なことになりました。うーんここまでする必要があったのか……。

compatibility

toml11は複数のC++バージョンをサポートしていますが、標準ライブラリ機能や言語機能はC++のバージョンによって使用可能なものが異なります。この差を吸収する方法は何通りか考えられます。

  • どの場合も使用しない
  • 標準ライブラリ互換の機能を実装して、常にそれを使う
  • 標準ライブラリ互換の機能を実装して、標準ライブラリが使用できる場合は切り替える

一つ目の場合、互換性に関する問題は発生しませんが、本来の目的を達成するのが面倒になります。

例えば、std::optionalのことを考えてみましょう。std::optionalを使用することで話が簡単になるケースが多いのは納得できると思います。 それはそれとして使用せずとも本来の目的を実装する方法はあるということも、まあ納得できるかとは思います。 boolをペアにして返すとか、外に構築しておいたもののポインタを渡して、成功時には埋めてtrueを返し、そうでないときはfalseを返すとか。 あるいはnewしたものを返すことにして失敗時にはnullptrにするとかですね。 とはいえそれらの選択肢は、コードが冗長になったり、パフォーマンス上のデメリットがあったりとあまり積極的に取りたくはない選択肢です。

二つ目は、同等の機能を持つものを自前で実装して常にそちらを使うことです。 こうすることによりバージョンチェックを回避できるほか、標準ライブラリにはない機能を実装したりできるので、便利な点もあるでしょう。 不利な点は、実装コストが高いこととユーザーになじみのないインターフェースを見せてしまうことくらいでしょうか。

三つ目は、機能が標準ライブラリのsubsetであるようなものを自前で実装し、標準ライブラリ機能が使用できない場合はそちらを使用することです。 これは二つ目の案の縮小版といった風情で、バージョンチェックが必要である代わりに実装コストは少し下げられます。

今回は基本的に三つ目の作戦をとることにしました。

基本的方針

基本的には標準ライブラリ機能が使用できる場合はそれを、できない場合は自前実装を使うようにします。

このような自前実装は、toml11 v3のころはtoml::detail名前空間に入れていたのですが、v4ではtoml::cxxに切り替えました。 頻繁に使う割にdetailは長いのと、他の実装詳細の中で使う場合とtoml名前空間で使う場合で切り替えないといけないのが面倒だったからです。

こういった機能を入れる名前空間として、他のライブラリは例えばnostdなどをよく使うようですが、私はstdと同じ文字数にしたかったのでcxxにしました。標準機能っぽさが出ますし。

今はdetailにはtoml11専用の内部実装詳細が隠されていて、標準ライブラリ互換機能はcxxに入れるという棲み分けが行われています。

標準ライブラリが使用可能かどうかを確かめる

おおもとの機能が先んじて導入されて、その後にユーティリティ関数などが追加された場合は、ユーティリティ関数を追加したいところです。

例えばstd::make_uniqueC++14から導入されましたが、以下のようにしています。

#if TOML11_CPLUSPLUS_STANDARD_VERSION >= TOML11_CXX14_VALUE
#  if defined(__cpp_lib_make_unique)
#    if __cpp_lib_make_unique >= 201304L
#      define TOML11_HAS_STD_MAKE_UNIQUE 1
#    endif
#  endif
#endif

namespace toml {
namespace cxx {

#if defined(TOML11_HAS_STD_MAKE_UNIQUE)
// 持っていれば、それを`using`する
using std::make_unique;
#else
// 自作
template<typename T, typename ... Ts>
std::unique_ptr<T> make_unique(Ts&& ... args)
{
    return std::unique_ptr<T>(new T(std::forward<Ts>(args)...));
}
#endif // TOML11_HAS_STD_MAKE_UNIQUE
} // cxx
} // toml

最初はifdefの中に直接書いていましたが、チェックする値が多い場合にifを分割したらelseの扱いがややこしくなったので、機能テストマクロを核にしたらTOML11_HAS_STD_SOMETHINGマクロを定義していちどifdefを終え、外で再度ifdefすることにしました。

標準ライブラリヘッダを探す

標準ライブラリ自体にも機能テストマクロがあります。例えば、<string_view>に対しては__cpp_lib_string_view201606Lとして定義されます。

これを確認することでstring_viewを使うかどうかが決められるわけですが、このマクロ自体が<string_view>で定義されているので、結局__has_include(<string_view>)で確認しなければなりません。

// C++20以前は`<string_view>`をincludeする前は__cpp_lib_string_viewが
// 定義されていないので、常に失敗する
#if defined(__cpp_lib_string_view)
#  if __cpp_lib_string_view > 201606
#    include <string_view>
#    define TOML11_HAS_STRING_VIEW
#  endif
#endif

// 正解
#if __has_include(<string_view>)
#  include <string_view>
#  if defined(__cpp_lib_string_view)
#    if __cpp_lib_string_view > 201606
#      define TOML11_HAS_STRING_VIEW
#    endif
#  endif
#endif

これは不便なので、<version>というヘッダがC++20で追加され、機能テストマクロはそちらに移動しました(各ライブラリヘッダ(<string_view>など)も引き続き同じマクロを提供します)。 なのでC++20以降で同様のことをするのなら、<version>を最初にincludeすればいいわけですね。

// C++20以降ならこれでよい
#include <version>
#if defined(__cpp_lib_string_view)
#  if __cpp_lib_string_view > 201606
#    include <string_view>
#    define TOML11_HAS_STRING_VIEW
#  endif
#endif

出てきた問題

こういった型をユーザーに見せることもあります。

標準ライブラリ機能が使用できる場合(C++17で使用しているときなど)にはユーザーにも標準ライブラリ機能を返せるのですが、そうでない場合には型が変わってしまうので、コンパイルしたライブラリをリンクする場合に問題が生じました。 C++17でコンパイルしたライブラリがインストールされているときに、それがC++11としてコンパイルしたユーザーコードにリンクされると型が変わってしまうのでリンクに失敗します。

ですがこれはあまりいい解決策が思いつかなかったので、コンパイル済みライブラリをインストールした場合もヘッダオンリーとして使用できるよう全ヘッダをインストールするようにしています。

基本的に、直接帰ってくる型には影響はないはずですが、SFINAE条件式の中で使っていたりするので、インストールの際にビルドしたときと関数のシグネチャが合わないというようなエラーは起きそうな予感がします。これは避けようがない問題なので、C++バージョンをあまり変えないようにするか、ビルドはプロジェクトごとにするなど、気をつけて使ってください。

あ、ヘッダオンリーで使う場合は何も気にしなくて大丈夫です。

おわりに

他にもこまごまとした工夫はたくさんありますが、主なノウハウは以上の通りです。 なんとなくもっとたくさん書くべきことがあるような気がしていましたが、見直してみるとこんなもんでした。ビルド済みライブラリとヘッダオンリーライブラリを同時に提供する方法や、single_includeの作り方は前回書いてしまいましたし。 toml::gettoml::findはウリになる機能ではありますが、実装はSFINAE使ってオーバーロードしてるだけですからね。

大半がパーサを描く際にしか使えないような情報だったのであまり皆さんの役に立つかはわかりませんが、これが皆さんの開発の参考になればうれしいです。


  1. 通常のシステムでは、有効な桁数はもう少し小さいですが。