先週、toml11のv4で入れた変更についての話をしました。
今回は、v3の頃から変わっていない機能も含めて、どういう理由でどのようにしているのかということについてつらつら書いていこうと思います。
とはいえ、メインはv4での実装のことになるとは思いますが、前回とはあまり被らないようにしようと思います。例えばsingle_include
の実装なんかは前回もう書いたので今回は書きません。
以下で話す決定の理由は、いくつかは合理的判断として多くの人が同意するでしょうが、いくつかは完全に私の好みであり、それらは常に成立するような理由ではないでしょう。 ですがそういったことでも、少なくとも私がそれを好ましいと感じた理由を言語化するつもりなので、そういう考え方もあるんだなくらいに捉えながら読んでください。
また、コードは少しは出しますが、どれも単純化したり端折ったりしているので、気になった機能の実際の実装を見る場合はレポジトリを見に行ってみてください。
v4でメインのブランチはmain
に移しましたが、master
ブランチにv3の最新版が残っています。
toml::value
TOMLには複数の型があり、すべての箇所でどの値でも使うことができます。
特に、Array
やTable
は、型を混ぜて持つことが可能です。
ですので、toml::value
は複数の型から一つを選んで格納できなければなりません。
不完全型への対処
格納すべき型の個数と種類が決まっているので、C++17以降だとこれはまさにstd::variant
の役割です。
とはいえ、Array
とTable
の実際の型がstd::vector<toml::value>
とstd::unordered_map<std::string, toml::value>
なので、再帰的な型になってしまう点には注意が必要です。
クラスの内部ではまだそのクラス自体は不完全型です。std::vector
はC++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::value
はArray
とTable
をこの中に格納することで、再帰を断ち切っています。
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::string
やstd::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)); }
他の実装
ここまで、あたかもunion
かstd::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
が選ばれました。
union
はC++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
の中でlocation
とregion
が定義されていました。この構成が自分でもわかりにくかったので、v4ではlocation.hpp
とregion.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::value
はregion
を持つため、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
はイテレータ――今の実装はオフセットですが――のペアとして実装されていますが、もう一つのあり得る解決策として、行番号と行そのものをコピーして持つというのがあり得ます。
region
はtoml::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言語のバージョンを変えられるようにし、機能ごとにフラグを制御できるようにしました。
これはかなり愚直に分岐で実装されています。syntax
はspec
を受け取って、フラグを見て文法を変更しています。
例えば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_seconds
をfalse
に設定しています。
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_unique
はC++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_view
が201606L
として定義されます。
これを確認することで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::get
とtoml::find
はウリになる機能ではありますが、実装はSFINAE使ってオーバーロードしてるだけですからね。
大半がパーサを描く際にしか使えないような情報だったのであまり皆さんの役に立つかはわかりませんが、これが皆さんの開発の参考になればうれしいです。
- 通常のシステムでは、有効な桁数はもう少し小さいですが。↩