最強のC++実装TOMLパーサーが完成した

ここ1, 2週間費やしていた作業が完了し、めでたくtoml11のバージョン2.0.0をリリースした。

github.com

このバージョンアップで、

  • コードが凄まじく美しくなり、
  • エラーメッセージが最強になり、
  • TOML v0.5.0 (最新) に対応した。

せっかくなのでこの記事で何をしたのか書いていこうと思う。宣伝ついでに、自分でパーサを書く人(最近はフルスクラッチ自作コンパイラが流行っているので)の一助になれば良いのだが。

TOML 0.5.0への対応

TOML v0.5.0では、それなりの数のアップデートが入った。toml11 v2.0.0ではその全てに対応している。順を追って説明していこう。

dotted keys

まず、以前からネストされたテーブルの名前は.で繋げていた。

[a]
n = 10
[a.b] # <- これ
m = 20
# {"a" : {"n":10, "b":{"m":20}}} (in JSON) と同等

ところで、TOMLではテーブルは特別扱いされてはおらず(特別なシンタックスが用意されてはいるが)、扱いは他の型の値と特に差はない。これはインラインテーブルが存在することからもわかる。つまり、通常の=で区切られたkey-valueペアと、テーブルの名前-コンテンツのペアの間に特に意味上の差異はない。

# 上と同等
a = {n = 10, b = {m = 20}}

テーブルの名前とそのコンテンツ、というペアとkey-valueペアが対応するなら、テーブルの名前と同じ書き方が通常のkey-valueペアのkeyに使えないのは不自然だ。

# 書けるべき
a.n = 10
a.b.m = 20

というわけで、上記の書き方が許されるようになった。

integer prefix

一般の入力ファイルで整数が10進表記しかいらないということはありそうにない。というわけで、TOMLではhex, oct, binaryの3つの文法が追加された。ただし他の言語と同様に0xなどのプレフィックスが必要になる。

bin = 0b01000011
oct = 0o775
hex = 0xDEADBEEF

順当だろう。

special floating point

TOML v0.5.0ではinfnan浮動小数点数の入力として使えるようになった。infはともかくとして、あまりnanが必要そうな状況が思いつかないが、表現できる値を全て入力可能にしたほうがいいという判断からだろうか。

a = inf
b = -inf
c = nan

v0.4.0までTOMLは浮動小数点数の内部表現を定めておらず、処理系定義だった(64-bit precision expectedとしか書かれていない)。だが実質、現代でIEEE 754互換のFPUを持たない処理系はほぼないだろう。あまり意味もなくゆるくし過ぎると、ユーザーが使う際に様々なエッジケースに対応する必要が生まれてしまう(あるいは、実質の標準、のようなあまり健全でない状態が生まれる)。C言語のように。というわけで、TOML v0.5.0ではIEEE 754のbinary64表現を用いる、と定められた。

datetime

元々TOML v0.4.0でも日時が一級市民だったが、日時を入力するには年月日からタイムゾーンまで全て書かなければならなかった。

dob = 1979-05-27T07:32:00-08:00

ローカル時間でいいよ、という場合にこれは面倒だし、日付さえあれば時間帯はどうでもいい、という場合も困る。 というわけで日時オブジェクトが緩和されて、local_date, local_time, local_datetime, offset_datetimeに分離された。日付のみ、時刻のみ、日時のみ、日時+タイムゾーンに対応する。

date = 1979-05-27 # 日付のみ
time = 07:32:00   # 時刻のみ
datetime = 1979-05-27T07:32:00 # 日時

toml11ではそれらを全て別の型として扱い、読み込み時に勝手にタイムゾーンを足したりしない。ただし、自前で日時計算を実装するのは地獄なのでそれは避けて、ただただ数値が入った構造体を格納している。それをstd::chronoの型に変換できるようにして、変換してから使うことを推奨している。変換時に必要ならタイムゾーンが調べられ、足される。

このとき、local_timeだけは「時点」ではなく「時間」なので、これだけはsystem_clock::time_pointに変換できない。特定の日付なしで「時刻」を時間軸上の一点に定める方法はないからだ。なので、こちらはstd::chrono::durationに変換できるようにした。日付の0時0分に足すとその時刻になるような時間幅に変換される。ただし、ユーザーがstd::chrono::hoursに変換した場合はstd::chrono::minutesの分の情報は切り捨てられるように、変換時に指定されたdurationより細かい情報は消えてしまう。なのでユーザーは自分に必要な精度を正しく指定して変換する必要がある。

エラーメッセージを最強にする

自分でTOMLを使って設定ファイルを書いていると、ちょいちょいミスをしてしまう。例えば、よくわからない記号(;など)を足してしまったりする。そういうとき、パーサが無言で落ちるとつらい。以前は行数と何が失敗したかだけ出すようにしていたが、もっとRustのコンパイラみたいなカッコいいエラーメッセージを出したかった。

ちなみにRustはエラーメッセージのフォーマットまでRFCにしている(rfcs/1644-default-and-expanded-rustc-errors.md at master · rust-lang/rfcs · GitHub)。なので狙いは逸れない。これを真似ればいい。

というわけで出来上がったのが以下のようなメッセージだ。

[error] toml::parse_table: invalid line format
 --> example.toml
 1 | a = "value";
   |            ^- expected newline, but got ';'.

「指している場所で改行が入ることを期待していたのに、;とかいうよくわからない記号が出てきた」というエラーメッセージだ。文法ミスを的確に指摘してくれる。行数も付いているし、ファイル名もある。最高ではないか。

また、ファイルが長くなってくると同じ名前のものを間違って定義してしまうこともあるだろう。そういうとき、パーサに2つ同じものが出てきたと言われると、「もう一個はどこだよ! 知ってるんだろ! 吐けよ!」となるのではないだろうか。

toml11は知っている。そして親切に教えてくれる。

[error] toml::insert_value: value ("a") already exists.
 --> duplicate-value.toml
 1 | a = 3.14
   |     ~~~~ value already exists here
 ...
 2 | a = 42
   |     ~~ value inserted twice

テーブルを2回定義してしまっても大丈夫だ。両方表示してくれる。

[error] toml::insert_value: table ("table") already exists.
 --> duplicate-table.toml
 1 | [table]
   | ~~~~~~~ table already exists here
 ...
 3 | [table]
   | ~~~~~~~ table defined twice

値を書き忘れた場合も、値があるべきところになかったというエラーが返ってくる。

[error] toml::parse_key_value_pair: missing value after key-value separator '='
 --> missing-value.toml
 2 | a = 
   |     ^ expected value, but got nothing

型が異なるArrayを作った? 個々の値のフォーマットが間違っていなくても、それは文法違反です。

[error] toml::parse_array: type of elements should be the same each other.
 --> inhomogeneous-array.toml
 1 | a = [42, "value"]
   |     ~~~~~~~~~~~~ inhomogenous types

親切なのは読み込み時だけではない。TOMLは読んで終わりではなく、読んだ値を元にアプリケーションの設定をするのだ。なので、テーブルに何かの値が入っていることを期待する。つまり、値の取り出しが発生する。その時、型を間違えたり、存在しないフィールドを読もうとしたり、様々なエラーがあり得るだろう。

toml11のtoml::get<T>を使うと、ある型Tとしてフィールド上の値を取り出せる。例えば、以下の例はtitleというフィールドが実際は文字列が入っているのに整数だと思って取り出そうとする例だ。

const auto data = toml::parse("example.toml");
const auto title = toml::get<int>(data.at("title"));

titleは実際には文字列なので、エラーが出る。そのエラーは以下のようになる。

[error] toml::value bad_cast to integer
 --> example.toml
 3 | title = "TOML Example"
   |         ~~~~~~~~~~~~~~ the actual type is string

また、toml::find<T>を使ってテーブルから値を探した時、

const auto data = toml::parse("example.toml");
const double a = toml::find<double>(data.at("table"), "alpha");

フィールドが存在しなければstd::out_of_rangeが投げられ、そのwhat()には以下が入っている。

[error] key "alpha" not found
 --> example.toml
 1 | [table]
   | ~~~~~~~ in this table

どこで定義されていて実際の型が何と判定されたか、どのテーブルのフィールドを見たのか、およそ必要な全てがそこにある。これで何が間違っているかわからない人間はいない。

ちなみにコンソールに出力されるとは限らないので色はついていない。色付きでファイルに出力した場合、凄まじく汚くなってしまうので、避けた。あとPOSIXWindowsでやり方を分けるのが面倒だった。

実装

どうやって上のようなメッセージを作るのか? 作るために必要な情報はわかっている。パーサーによるエラーの説明と理由、ファイル名、行数、落ちた位置あるいは間違っている領域だ。それをパース中、あるいはパースが終わってからも(toml::getのために)覚えていればよいのだ。そして、toml::getには通常toml::valueが渡されることを考えると、値が定義されている場所などの情報はtoml::valueの中に保存しておけるようなものである必要がある。

パーサによるエラーの説明は、その場でパーサが生成できる。 落ちた位置または問題のある領域は、パーサが作ることもできるが、後でtoml::getからも出力したいことを考えると、覚えておくための簡便な方法を用意しなければならない。ファイル名と行数は通常保存しておかないので、これらも覚えておかなければならない。

幸い、toml::parseはファイル名を受け取るので、これを覚えればよい。ストリームが渡された場合は、仕方ない。情報が無いのに取り出すことはできない。必要ならユーザーに渡してもらえばいい。ユーザーがエラーメッセージにファイル名は必要ないと判断したなら、それはそれで仕方ない。

toml11では、これまでイテレータを受け取っていたパーサが、イテレータをラップした構造体を受け取るようになった。その構造体は、ファイルのバイト列へのshared_ptrとファイル名、そして現在の位置(これまで生で受け取っていたイテレータと同等)を覚えている。ファイルのバイト列を覚えているので、必要になれば行数をカウントできるし、前後の改行文字を探せば現在いる行全体を取得できる。ファイル名もある。そして当然現在の位置がある。これで大体情報は揃う。

// イメージ図
struct location
{
    std::shared_ptr<std::vectpr<char>> source_;
    std::string name_;
    std::vector<char>::const_iterator iter_;
};

これを受け取って場所を進めながらパースする。各toml::valueは値に対応する文字列を覚える必要があるので、これとほぼ同じregionという構造体も用意し、パース開始時のlocationと終了時のlocationからregionを作ってtoml::valueに埋め込むことにした。

// イメージ図
struct region
{
    std::shared_ptr<std::vectpr<char>> source_;
    std::string name_;
    std::vector<char>::const_iterator first_, last_;
};

あとは、これらとエラーの説明から先のエラーメッセージを生成する関数を作り、それを使ってエラーを投げればよい。フォーマット自体は決まっているので簡単だ。前後の改行文字を探したり、先頭から現在地までの改行の回数をカウントしたり、幅を揃えたりするのが面倒なだけだ。

コードを綺麗にする

前提として、TOMLはまだバージョン1になっていない。これからも文法がちょいちょい変わるだろう。折角素晴らしい機能を実装しても、その際コードが汚すぎてバージョンアップに追従できなかった場合、詰む。

というわけで、主にパーサとtoml::valueの実装を綺麗にした。

パーサの実装

パーサは、これまで値が正しいフォーマットになっているかどうかを確認しないまま読み進め、変換処理とフォーマットのチェックを同時並行でやっていた。この場合、やっている処理の意味が入り乱れてしまう。さっきまでフォーマットのチェックをやっていたのに次の行ではデータの変換の準備をしていて、その次の行でまたチェックをしている、となると大変にわかりにくい。フォーマットが間違っていたらエラーで返せばよいし、フォーマットが正しければよっぽど(64bit整数の範囲を超えるとか)でないとエラー処理は必要ない。なのでこの2つは分離した方が、個々のコードがやっていることの意味がはっきりして、結果読みやすくなるはずだ。

というわけでparserをlexerparserに分けた。lexerは成功すればエラーメッセージの話に出てきたregionを返し、そうでなければ「何を期待していて、何が現れたか」を返す。parserは正しいフォーマットのものしか来ないと思ってサクサク処理ができる。言語処理系の歴史を一人で再現してしまっている感じがする。これによって、例えばbooleanのパーサーは以下のようになった。

// 実際のコード
template<typename Container>
result<std::pair<boolean, region<Container>>, std::string>
parse_boolean(location<Container>& loc)
{
    const auto first = loc.iter();
    if(const auto token = lex_boolean::invoke(loc))
    {
        const auto reg = token.unwrap();
        if     (reg.str() == "true")  {return ok(std::make_pair(true,  reg));}
        else if(reg.str() == "false") {return ok(std::make_pair(false, reg));}
        else // internal error.
        {
            throw toml::internal_error(format_underline(
                "[error] toml::parse_boolean: internal error", reg,
                "invalid token"));
        }
    }
    loc.iter() = first; //rollback
    return err(format_underline("[error] toml::parse_boolean", loc,
            "token is not boolean", {"boolean is `true` or `false`"}));
}

返り値はresult型で、RustのResultと同じだ。成功値(std::pair<boolean, region>)かエラー値(std::string)のどちらかが入っている。

している処理は、lex_booleanが成功すればtruefalseかを調べる。それ以外の値でlex_booleanが成功していればそれはバグなので、パース失敗のエラーを返すなどと悠長なことはせず、即座にinternal_errorを送出して落とす。そもそもlex_booleanが失敗していれば、エラー終了としてメッセージを返す。この関数はparse_valueのような関数から呼ばれるので、これに失敗しても今度は別の型、例えば数値や文字列として解釈できるか調べることになる。それに備えるため、位置をロールバックしておく。

やっていることはそれなりにあるが、それにしてはかなり自明なコードだ。パーサーはこのような自明なコードが大量に組み合わさって動く。たまに挟まる非自明なこと(a.b.c = 42のようなときにトップレベルからa.bというテーブルを再帰的に探し、なければ挿入し、conflictの際のエラーメッセージを生成するなど)は関数を分けて、さらに随所にコメントを入れた。

ではフォーマットチェックの仕事を押し付けられたlexerはどうか。そこが結局難しくなっては仕方がないので、これも簡単にする方法を考えた。TOMLはabnfによる表現が提供されていて(それが規格になるわけではない、と注意を促されてはいるが)、許されるパターンがわかりやすい。これが付け目だ。正規表現的なパターンとしてフォーマットを書けるなら、それぞれの単位パターン(「この内のどれか」や「N回繰り返し」など)を受理するルーチンを書いて、合成すればよい。ただし汎用の正規表現エンジンである必要はない。TOMLに使う最低限度だけでよい。

というわけで、combinator.hppにそのようなものを作った。すると、個々のlexerは以下のようになる。例えば、以下は10進表記の整数のパターンだ。

// digit | nonzero 1*(digit | _ digit)
using lex_unsigned_dec_int = either<sequence<lex_nonzero, repeat<
    either<lex_digit, sequence<lex_underscore, lex_digit>>, at_least<1>>>,
    lex_digit>;
// (+|-)? unsigned_dec_int
using lex_dec_int = sequence<maybe<lex_sign>, lex_unsigned_dec_int>;

パターンをコンパイル時に構築しておいて高速化するという目論見があるので、全てテンプレートになっている。つまり上のコードのusingは全てエイリアスで、typedefに等しい。それに、動的に追加していると実行パスを追わないとパターンがわからないのでつらいということもある。パターン文字列から自動生成するのも一瞬考えたがそれはやり過ぎで、この部分はユーザーに見せる気はないので便利である必要がない。内部実装なので、私にとっての実装の簡単さが優先される。そもそも文字列のパースのための機能を文字列をパースして生成するというのは意味がわからない。無限後退か?

それぞれの型がコメントに書いてあるパターンと対応する。さらに、新しく定義したパターンも全く同様のコンビネータなので、さらに繋げることができ、再利用もできる。これで許されるパターンが変わった時も、これを少しいじれば済む。正規表現より読みやすいかは人の好みがあるだろうが、正規表現パターンのアップデートと似たような難易度にしかならない。

ただし、再帰的なものを作ってしまうと無限に長くなって詰む。つまり、例えばテーブルの値としてテーブルが許されるので、テーブルのパターンは自分自身を内部に含む。そのようなパターンは今回のような素直な実装では無限に長いパターンになってしまい、単純にコンパイルに失敗するか、コンパイラがスタックオーバーフローする。なのでlexerにはテーブルのパターンはなく、テーブルのパーサは他と違ってparse_key_value_pairを呼びつつコメントと改行をスキップしていく、という流れで実装されている。

というわけでparserとlexerを分けることでコードが最高に読みやすくなった。エラー処理もしやすい。変更にも対応しやすいロバストなコードになった。

valueの実装

valueは、数あるTOML型のどれか一つになる。そういうものは、モダンにはstd::variant、古狩人はunionを使う。toml11はC++17を要求しないので、std::variantは使えず、古い方法を使わなければならない。unionはテンプレートで個数を変えたりできない点と、どの値がactiveかを示すフラグが自動ではついてこないことが問題だ。だが、今回は値の数は固定だし、フラグは自分で足せばいい。なので普通に使える。

unionの使い方は面倒なので言わない。そもそもそこは前までの実装と変わっていない。今回変更した点は、以前までテンプレートを使って無理やり行数を減らしていたのをやめて、むしろコードが3倍くらいになってもいいから、奇妙なトリックを使わないようにしたことだ。ある程度はテンプレートとSFINAEも使っているが(整数型かどうかの判定をSFINAEでなくintlongunsignedに、と一つ一つ手で作った温かみが伝わってくるオーバーロードでやるのは気が狂っている)。なので、様々な型からtoml::valueを構築するためのコンストラクタと代入演算子がそれぞれ定義されている。大半が同じコードだが変換の部分が少しずつ違っている。8割は同じコードだから無理やり一般化しようとした当時の私の気持ちもわかるが、ここは多分全部書いていた方が結果的にわかりやすいし、後で追加もしやすかろう。

便利関数たち

そういえば便利関数も足した。toml::get<T>という便利関数があり、Tに好きな型を入れると、toml::valueに変換してくれるというものだ。

auto answer  = toml::get<std::int64_t    >(data.at("answer"));
auto pi      = toml::get<double          >(data.at("pi"));
auto numbers = toml::get<std::vector<int>>(data.at("numbers"));

これの機能を少し強化して、TOMLのテーブルに入っているフィールドの型が全て同じ(全部文字列、とか)場合はmapごと一気に置き換えられるようにしたり、

// [tab]
// key1 = "foo" # all the values are
// key2 = "bar" # toml String
const auto tab = toml::get<
    std::map<std::string, std::string>>(data.at("tab"));

TOMLでは許されているArray of arraysの各Arrayで型が違う値をstd::vectorのペアで取り出せるようにしたり、

// aofa = [[1,2,3], ["foo", "bar", "baz"]] # toml allows this
const auto aofa = toml::get<
    std::pair<std::vector<int>, std::vector<std::string>>
    >(data.at("aofa"));

フィールドが見つからない場合のエラーメッセージを強化したtoml::findを追加したり、

// 期待しているexample.toml
// [table]
// num = 42

const auto data = toml::parse("example.toml");
const auto num  = toml::find<int>(data.at("table"), "num");

/* 値がなかったら以下のエラー
terminate called after throwing an instance of 'std::out_of_range'
  what():  [error] key "num" not found
 --> example.toml
 3 | [table]
   | ~~~~~~~ in this table
*/

Rust流のresultを返すtoml:: expectを追加したりした。

const auto value = toml::expect<int>(data.at("number"))
    .map(// function that receives expected type (here, int)
    [](const int number) -> double {
        return number * 1.5 + 1.0;
    }).unwrap_or(/*default value =*/ 3.14);

あとがき

というわけで最強のライブラリができた。C++らしい、裏側が透けて見えるシンプルさと様々な用途をサポートする強力さが両立したインターフェース、Rustのようなわかりやすく見目麗しいエラーメッセージ、分離され構造化されたコード、と私の思う理想のライブラリ(に近いもの。当然だがいくつかの妥協が含まれるし、テストから漏れたミスも多分残っていると思う)ができた。気分が盛り上がっているので手前味噌でも褒める手は止めない。全員これに乗り換えてくれねえかな