2018年にしたこと

ブログ記事を書こうとして、年が変わって一発目だったことに気づき、総括的なことをするべきかどうか考えている。3月の年度末に今年度でやればいいのでは(引き伸ばし癖)という気持ちが芽生えているからだ。

実は、10月くらいに一度総括をしようとしていて、微妙に書いた記事が下書きに残っている。何でそんな時期にしようと思ったのかというと、GitHubの緑化活動が完了したからだ。今更言うまでもなく、GitHubのユーザーページにはカレンダー的なものがあり、活動すればするほどその日の色が濃くなる。その色が緑なため、草と俗に言われている。今となっては随分前の話だが、1ヶ月程度コミットし続けたことがあり、その時「このまま1年いけるのでは?」と思ってしまったのが始まりだ。

f:id:tniina:20190107151519p:plain 図1. 10月末での状態。ハロウィンなので黄色になっているが、普段は緑だ。

1年走り切った時の記事が下書きに死蔵されている。出さないのももったいないが、長過ぎるので削らねばなるまい。まあせっかくなのでざっと思い出してみよう。

1月

未完成のGUIライブラリ(非公開)を作り始めている。いきなり放置しているレポジトリのことを思い出してしまい罪悪感が募る。

もうひとつ、収束加速法のレポジトリを作っている。外挿によって数列の極限を高速に計算するアルゴリズムだ。だが一番簡単なやつしか実装していない。よく使われているものが地味に複雑だったからだ。

GitHub - ToruNiina/kasok: converge faster.

2月

2月は主にSATySFiを触っていた。ギリシャ文字が使いたくてPullReqを送っている。またvimで自分用のシンタックスハイライトを手探りで作ったが、少し後に慣れた人がより強いものを作ってきたのであっさり乗り換えてしまった。

3月

3月はアライメント指定operator newの解説をcpprefjpに送り、マージされてcpprefjpのWorking Teamに入っている。

解説が以下の記事にある。

C++17: 動的メモリ確保とアライメント - in neuro

他に、自作ライブラリにPybind11を使ってPythonバインディングを足したりしているようだ。Pybind11はとても使いやすい。

GitHub - ToruNiina/libasd: C++11/Python3 library to read/write High Speed AFM data file

4月

4月は、2回もTOMLパーサを書いた経験をフルにつぎ込んでBoost.tomlを作り始めたようだ。TOML v0.5.0準拠でパースもシリアライズもできてC++98ですら動くTOMLライブラリは、現時点でも唯一だ。そもそもTOML0.5.0に対応しているのがこの他には拙作のtoml11と、恐らく最古参のcpptomlしかない。

GitHub - ToruNiina/Boost.toml: header-only C++(98|11|14|17) TOML v0.5.0 parser/encoder depending on Boost

5月

この月は新しいレポジトリは作っていない。主にバグフィックスや新機能に時間を割いていたようだ。

6月

6月は、非常に単純なファイル形式の画像を読み書きするライブラリを作っている。

GitHub - ToruNiina/pnm: pbm, pgm, ppm image IO for modern C++ (single header only library)

フォーマットの説明が以下の記事にある。

一番簡単な画像フォーマット - in neuro

他に、レイトレーシングのプログラムを作ろうとして放棄してしまっている(非公開)。思うにこれは、あまりよく知らないレイトレーシングという手法について最初っから綺麗な設計にしようとしたことによる失敗だと思う。想定できていなかったことがある度に設計から全部ひっくり返すことになるので、労力がかさむのだ。

多分最善手は、不格好でもいいから一度動くまで書き上げて、いくつか違うアルゴリズムを書いて、共通部分とそうでない部分を見極めてから、もう一回初めから(設計を変えて)書くことだろう。知らないものの設計は綺麗には出来ない。綺麗な設計になるかどうかは、一度動くものができてから書き直す余力があるかどうかに尽きる。

7月

この月は、SIMDを酷使する低次元ベクトル・行列ライブラリを作っている。一般のSIMDでなくベクトルと行列だけに絞ることで労力を減らし、低次元に限ることでさらに減らした。それでも作業量がすごかった。

GitHub - ToruNiina/mave: SIMD-oriented matrix and vector library for small dimension

目的は、SIMDの練習もそうなのだが、高レベルなAPIだけでSIMDレーンを使い切りたいという欲望があった。つまり、floatなら8要素同士で足し算ができるのに、3次元ベクトル同士の和では半分も使えないのはもったいない、3次元ベクトルの足し算を2回やるなら、それを一度に行えるようにしたい、というアイデアを実現している。だがそのための労力がヤバい。

実装の話はこの記事にある。

maveの中身について - in neuro

8月

この月も基本機能拡張とメンテで過ぎていった。schemeの勉強をほんの少し再開したが、あまり進んでいない。

9月

9月は外部向けの活動が多かった。まず、GitHubでのTOMLのシンタックスハイライトがv0.5.0に追従していなかったので、PullReqを送った(マージされた)。 その辺りの話はこの記事にある。

GitHubのシンタックスハイライトを直した話 - in neuro

他に、Boost.testにコンパイラが警告を出すのを修正してほしいというIssueを出した。文字コードとしてASCIIを仮定して構わないならこうやって直せる、という提案もしている。こちらは「次のリリースで直すよ」という返信を貰った。

10月

機能拡張とメンテ。

11月

この辺りから、masterのHEADを壊れた状態にしておきたくないという理由で、自分が所有しているレポジトリでも積極的にfeature branchから未来の自分にPullReqを送るようになった。また、多分振り返り記事の影響でGUIライブラリに大きく手を入れている。ライブラリと言える状態にまだなっていない(機能がなさすぎる)が……。

12月

12月には、1週間強かけてtoml11のメジャーアップデートをした。エラーメッセージが超格好良くなり、TOML v0.5.0に対応した。

GitHub - ToruNiina/toml11: TOML for Modern C++

具体的にどうなったかは以下の記事に書いてある。

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

他に、Rustの練習として、書捨てのスクリプトではなくジェネリクスを使ったライブラリを作ろうとしている。非常に簡単な関数しかまだない。

RustとC++のジェネリクスの性格の差 - in neuro

所感

草を継続的に生やし始めたころは、割といけるだろうと思っていた。というのも、新しいレポジトリを作って何かを書き散らしておけば草は生えるからだ。書く題材とモチベーションさえあれば1日1コミットはそう難しいわけではない。バグフィックスとか、簡単な関数を1つ追加するくらいの粒度でコミットしてもよいのだから。

だが、最近徐々に草を生やすのが難しくなってきたように感じる。例えば、作ったライブラリに少しでもユーザーがつくと、その更新作業は難しくなる。流石にmasterブランチにポンポンコミットできないので、feature branchを作ってしっかりテストをしたい。すると、その間草が生えない! masterへマージするまでcontributionにカウントされないのだ(マージ後遡ってカウントされる)。マージする気で作っても、上手く行かなくて破棄したら草が生えず、荒れ地が残ってしまう。

今のところは開発段階のレポジトリがそれなりの数あるので草を保てているが、それらが全て同様に重くなってくると、継続が難しくなるだろう。そうなると、もっとユーザーが多いプロジェクトをもっと多く抱えてそれでも草を生やし続けている人々は本当にすごいな、という気持ちが湧いてくる。これからも可能な限り継続していきたいが、はてさて。

RustとC++のジェネリクスの性格の差

今までRustは使いまわすことのない適当なスクリプト的にしか使ってこなかったので、実際のところ本質的に難しいことは何もしてこなかった。その間は非常に楽で、言われるほど難しくないのではと思っていた。が、最近コードを使いまわそうと思って書き始めたところ、即死してしまった。

例えば、ジェネリックな構造体を作ろう。名前と座標を持つ質点ということにしよう。すると、名前は文字列でいいとして、座標にはf64f32のどちらも使い得るし、そもそも座標の値は特定の型に結びついたものではない。なのでジェネリクスを使うのが妥当だ。なので以下のようなコードを書き始める。

#[derive(Debug)]
pub struct Particle<T> {
    pub name : std::string::String,
    pub pos  : nalgebra::Vector3<T>,
}

fn main() {
    let p = Particle::<f64> {
        name: "C".to_string(),
        pos: nalgebra::Vector3::new(1.0, 1.0, 1.0)
    };
    println!("{}", p);
}

これでもうだめだ。C++のtemplate的な思考で行くと、Tf64が入り、その結果Particle<f64>が実体化され、結果、型の代入が成功するので問題なく動く。と予想される。

だがRustはそれを許さない。例えば以下のようなエラーがでる。

error[E0277]: the trait bound `T: std::marker::Copy` is not satisfied                                                                                                                          
 --> src/main.rs:3:5                                                                                                                                                                           
  |                                                                                                                                                                                            
3 |     pub pos  : nalgebra::Vector3<T>,                                                                                                                                                       
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::marker::Copy` is not implemented for `T`                                                                                               
  |   

これは、nalgebra::Vector3Tに対してstd::marker::Copyトレイトを実装していることを要求していることに起因している(同期曰く「Rustでは構造体の型パラメータにトレイト境界を入れるのは非推奨のはずだが……」と言うことらしいのでライブラリ側が微妙な実装をしているだけと言う可能性はある)。ここでTCopyトレイトを実装していない型が代入される可能性があるということをRustコンパイラは指摘している。

使っているのはf64で、当然Copyトレイトを持っているだろう? と思うC++脳の人たちには、これがRustであると言うことを思い出してもらわなければならない。C++のテンプレートはコードジェネレータなので、実際にジェネレートされたコードが正しければ何の問題もない。だが、Rustのジェネリクスは、今のところ代入されていない型に対してもエラーを出し得る。「あとで誰かがTにコピー不能型を入れたらどうなる? Tnalgebra::Vector3に入れられ、そこでCopyが要求されているので失敗することになる。つまり、このコードは壊れているということだ!」

そんなもん金輪際入れねえよ、という心の声は飲み込んで、std::marker::Copyをトレイト境界に追加してやらねばならない。確かに、人は最初の設計を忘れる。いずれ、当初は想像もしていなかった謎の何かを型パラメータにぶち込むだろう。それは使い方を知らない他人かもしれないし、未来の自分かもしれない。そうなってからでは遅い、というのがRustの言い分だ。今のうちに考えられるバグの芽は全て潰しておくのがRust流ということだ。

確かにC++だと、あとで奇妙な型を入れた時に、型パラメータの代入途中で出たあらゆるエラーが報告され、エラーメッセージが膨れ上がって大変なことになる。Rustはそういうことが起きる前に先にエラーメッセージを大変なことにしておいてくれるというわけだ。Rustの方がエラーがわかりやすい分(トレイト境界について知っていて、いくつかの頻出トレイトについての知識があり、落ち着いて考えてあるいは経験から上のような話に考えが至ればだが)多少ましという意見は間違いなくある。が、まあとりあえず動くという状態に持っていきにくいので学習に鉄の意志が必要なのも確かだ。

ちなみにこのコードでは、Copyトレイトを追加しても指摘は終わらない。最終的に、nalgebra::Vector3が要求するトレイト境界(nalgebra::base::Scalar)をそのまま持ってくる必要があった。ライブラリで定義されているジェネリクス構造体を中に持つジェネリクス構造体を作る時は、先にライブラリ側のトレイトを確認するのが一番の近道っぽい。

最強の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のようなわかりやすく見目麗しいエラーメッセージ、分離され構造化されたコード、と私の思う理想のライブラリ(に近いもの。当然だがいくつかの妥協が含まれるし、テストから漏れたミスも多分残っていると思う)ができた。気分が盛り上がっているので手前味噌でも褒める手は止めない。全員これに乗り換えてくれねえかな

脳を破壊するFortranコード

今日、後輩と色々試していて面白いことに気付いた。Fortranのキーワードは、予約語ではない。Fortrandoとかifとかといったキーワードを持っているが、これらは予約語ではない。

ググると規格書のセクション番号込で情報が出てくる。

Keywords in Fortran Wiki

ISO Fortran90 standard § 2.5.2 "Keyword"では、以下のような記述がある。

These keywords are not reserved words; that is, names with the same spellings are allowed.

「これらのキーワードは予約語ではない。つまり、同じ綴りの名前は許される」らしい。

規格書のドラフトはここで見ることができる。

https://wg5-fortran.org/

質問している人もいるが、「キーワードを変数名として使うのはバッドプラクティスだ」という(当たり前の)答えが返ってきている。

software.intel.com

どういうことかというと、以下のコードが合法だということだ。

program main
    integer :: do, end=0
    do do=1, 10
        end = end + do
    end do
    write(*,*) end
end program main

ついでに、以下のようなコードも合法である。

program main
    logical::if=.true.
    if (if) if = then()
    write(*,*) if
contains
    function then()
        logical then
        then = .true.
    end function then
end program main

こんなコードが出てきた日には発狂すること請け合いであるが、Fortranコードのメンテをさせられて発狂寸前の人はこのような爆弾をコードに仕込んでみても面白いのかも知れない。

ifを関数にすることもできて、以下のコードはokを出力する。

program main
    if(if(if(if(.true.)))) write(*, *) 'ok'
contains
    function if(x)
        logical::x
        logical::if
        if = x
    end function if
end program main

何一つokなところがない。

これだとif関数が何もしていないからまだいいが、もっと悪意の強い関数にもできる。

program main
    if(if(.true.)) then
        write(*, *) 'ok'
    else
        write(*, *) 'not ok'
    end if
contains
    function if(x)
        logical::x
        logical::if
        if = .not. x
    end function if
end program main

このコードはnot okを出力する。確かにnot okだ。if関数が定義されているばかりか、その関数がlogicalな値を受け取ってそれを反転するので。

ちなみにgfortran-5.5.0-Wall-Wextraつきでコンパイルしても、上記のコードに警告は出ない(規格上予約語ではないとしても、警告くらいは出していいと思うのだが)。というわけで静かに爆弾を仕込むことができるので、使ってみてはいかがだろうか。

GitHubのシンタックスハイライトを直した話

GitHubは自動でシンタックスハイライトがされるが、言語仕様が今も変わっていくような言語(大体の使われている言語はそうだ。Goのように意図的に固定している例外を除いて)ではたまにそれが古いままで、公式ページのハイライトがおかしいことがある。

TOMLがその良い例で、TOML v0.5.0で追加された一部の機能が真っ赤にハイライトされていた。例えば、dotted keys(キーを繋いで階層構造を作る)や整数プレフィックス0x0bなど)、特殊な浮動小数点数infnan)などだ。

ところで私は既にv0.5.0対応のTOMLライブラリを一つ書いており(toml11は書き換え中)、それを使ったツールのサンプルが真っ赤になるのは悲しかった。

github.com

なので直したいと思い立ち、調べてみることにした。有名エンジニアである犬さんのブログが真っ先に引っかかる。

rhysd.hatenablog.com

これによると、GitHubシンタックスハイライトは以下のレポジトリによって行われている。

github.com

このレポジトリはエディタ用の文法定義を再利用するようになっている。TextMateAtomSublimeや……といったエディタの設定ファイルをメンテしているレポジトリを覚えておいて、数週間かおきにpullしてきてアップデートしつつ使うというようになっている。それぞれのシンタックス定義は別レポジトリになっているので、linguist自体にPRが山ほど飛んでくるという事態を避けることができる。

このディレクトリにそのモジュールの一覧がある。

linguist/vendor at master · github/linguist · GitHub

ここから直したいレポジトリに飛んで、そこにPRを投げればいいわけだ。今回はTOMLなので、TextMateなるエディタの設定ファイルを編集することになる。

github.com

ところで私はTextMateというものを使ったことがない。ちらりとTOML用のレポジトリの中身を見てみたが、正規表現でマッチした部分に名前を付け、名前によってハイライトしていくものであるようだ。XMLで書かれているので読めないこともないが、つらい。

動作確認のこともあり、TextMateが必要だろうと思った。まず、ググるmacOS用のエディタだということがわかる。私はWindowsマシンは持っていないがmacは持っているので、インストールできた。

TextMate: Text editor for macOS

上のメニューからバンドルを開いて追加すればよいと書かれているのでやってみる。ちなみにやったのが数週間前(後述するがPRが閉じられるまでそれくらいかかった)なのでよく覚えていない。

インストール後、TextMateの Bundles > Edit Bundles > TOML > Language Grammers > TOML と開いていくと、別ウインドウでシンタックスの定義ファイルを編集しながら結果を見ることができるではないか。しかもXMLではなくJSONっぽい(けど少し違う)形式を使っているので、より目にやさしい。Ctrl-Sを押すごとにハイライトが更新されるので、保存のタイミングでこの形式からXMLに変換しているのだろう。実際、Applications/みたいなディレクトリにXML形式のファイルがある。

さて、書き方はわかったが、次は何を編集したらどういう影響が出るのか調べねばならない。以下は試しながらの憶測だったが、まあ多少の編集を経たとはいえマージされたので大外しはしていなかったのだろう。上記のようにして開くと以下のような設定が見える。以下のコードは(TextMateによる変換を経たとはいえ)https://github.com/textmate/toml.tmbundleからの引用になる。

patterns = (
    {   begin = ‘([A-Za-z0-9_-]+)\s*(=)\s*‘;
        end = ‘(?<=\S)(?<!=)|$’;
        captures = {
            1 = { name = ‘variable.other.key.toml’; };
            2 = { name = ‘punctuation.separator.key-value.toml’; };
        };
        patterns = ( { include = ‘#primatives’; } );
    })

ここでbeginend正規表現がある。これらによって囲まれた領域にマッチするものと思われる。基本重要な部分は大抵beginに書いてあったのでそこをいじった。で、そのグループの登場順序によって番号が振られ、capturesの中の番号に対応する名前を書いておくと、その名前としてハイライトされるようである。多分、カラースキームのようなものが別にあって、実際の色はそちらで構文要素の名前から決められるのだろう。この例では、bare-keyに対応する1つめのグループ([A-Za-z0-9_-]+)variable.other.key.tomlという名前が割り振られ、次のグループ=punctuation.separator.key-value.tomlなる名前がついている。

つまり、正規表現を拡張して新機能にマッチするようにして、グループ毎の名前を適切につければよいわけだ。そのような方針で編集して動くことを確認し、内容をPRにまとめて送りつけた。以下がそれだ。

allow dotted keys by ToruNiina · Pull Request #9 · textmate/toml.tmbundle · GitHub

allow nan, inf, hex, oct, bin values by ToruNiina · Pull Request #10 · textmate/toml.tmbundle · GitHub

数週間ほど放置されていて少しやきもきしたが、今日晴れてマージされた。いや正確にはマージされたというより、いくらかの編集を経てコミットが追加され、クローズされた。

追加された変更は、例えば数値にマッチする正規表現が長すぎたので分割する(最初は整数と浮動小数点数が同じくくりでマッチされていたので、そういう方針なのかと思って尊重していたのだが、単に短いから構わないという扱いだったのだろうか)とか、ハイライト時の分類を直すとか(ここは純粋にどうするべきかわかっていなかった)、いくつかのエッジケースを直す(ありがたい)などだった。

というわけで、しばらくすればGitHubのハイライトも修正されるだろう。今までもそれなりに使ってもらえるツールは作っていたが、これまでとは比べ物にならない規模の人数が目撃するソフトウェアの端っこに自分の小さな爪痕を残したというのは、結構気分がいいものだ。

これでtoml.tmbundleのレポジトリのコントリビュータは3人になった。プライマリ作者と、TOMLそのものの作者Tom Preston-Werner(@mojombo)と、私である。ヤバい人々でビビるが、それ故に嬉しさもひとしおというところだ。

みなさんもGitHubのハイライトに不満があれば、PRを投げつけてみてはいかがだろうか。

CとC++のIdent

今日、妙な話を聞いた。CかC++かでコードを書いていたところ、変数名を長くすると32文字だかそこらでtruncateされてしまい、前半が同じ名前の変数を使っているとコンパイルエラーになってしまったというのだ。私は(少なくともC++で)変数名に文字数制限があるとは思っていなかったので、かなり驚いた。むしろコンパイラを疑った。

というわけで規格書にあたってみることにした。C規格としてN1256(C99)を、C++規格はN3337(C++11)を参照する。

まず、Cでは§6.4.2.1の Language Lexical elements > Identifiers > General > Implementation limits に、

an implementation may limit the number of significant initial characters in an identifier ... Any identifiers that differ in a significant character are different identifiers. If two identifiers differ only in nonsignificant characters, the behavior is undefined.

との記述がある。要するに、処理系は識別子の最初の何文字か以降は無視してしまって構わないということだ。そしてもし無視されない前半部分が重複していて無視される後半部分のみが違う識別子の組が登場した場合、その挙動は未定義となる。

これは驚きだ。識別子として使える(意味を持つ)文字数は処理系定義なのか。ここしか見ていないのでわからないが、もしかしてこの文字数は1文字でも規格準拠なのだろうか……標準ライブラリのほとんど全てが使えないが。

ちなみにGCCでは文字数に制限はない(以下参照)。まあそりゃそうだ。

Using the GNU Compiler Collection (GCC): Identifiers implementation


ではC++ではどうだろう。§2.11のLexical convensions > Identifiersには、

An identifier is an arbitrarily long sequence of letters and digits.

と書かれている。任意の長さが許されるようだ。話が終わってしまった。まあ、安心するべきことではある。

まとめると、Cを書いている時に長い変数名を使って妙なことが起きた場合(そしてそれが純粋に変数名が長いことによる問題だった場合)、コンパイラが悪いとは言えない。しかしもしC++で識別子の後半が無視された場合は、コンパイラが悪い。プログラマは自信を持ってIssueを報告して構わないということだ。