Rustでtraitのassociated typeに対してtrait boundaryを課す

RustのTraitは内部に型を持つことがある。関連型(associated type)だ

trait Hoge {
    type Value;
}

impl Hoge for Piyo {
    type Value = Fuga;
}

このValue型についてトレイト境界を付けたい。 つまり、ある型Tがあり、型<T as Hoge>::ValueOtherTraitを満たす場合のみ使える関数を使いたい。

以下のように書けたらよいのだが。

fn foo<T: Hoge<Value: OtherTrait>>() -> ()
{
    // ...
}

残念ながらこれは通らない。

error: expected one of `!`, `(`, `+`, `,`, `::`, `<`, or `>`, found `:`
  --> src/main.rs:11:21
   |
11 | fn foo<T: Hoge<Value: OtherTrait>>() -> () {
   |                     ^ expected one of 7 possible tokens here

ではどうやって書くかというと、実は割と素直に書くと通る。

fn foo<T>(x: T) -> ()
where
    T: Hoge, // ここではまだ何も言わない
    <T as Hoge>::Value: OtherTrait // これを足す
{
    println!("hello! {}", x.value().other());
}

トレイト境界の左辺にそれ書けるんかい! と思った。

動くサンプルを置いておく。 play.rust-lang.org

さて、似たような、だが少し違うケースがある。今日実際に困っていたのはこちらだ。

まず、自分のクレートに既にcrate::error::Errorを定義してあり、よくある型についてFrom for Errorを実装しているとしよう。

pub struct Error {/* ... */};
type Result<T> = std::result::Result<T, Error>;

impl From<std::num::ParseIntError> for Error {
    fn from(error :std::num::ParseIntError) -> Error {
        // ...
    }
}

で、何らかのフィールドを持つジェネリクス構造体があるとする。それを読み込めるようにしたい。

pub struct Hoge<T> {/* ... */};

impl<T> std::str::FromStr for Hoge<T> {
    type Err = Error;
    fn from_str(line: &str) -> Result<Self> {
        // ...
    }
}

このfrom_strHoge<T>のフィールド(T型)を読むためにparseを使いたい。そのためには、まずTFromStrを実装している必要がある。

impl<T> std::str::FromStr for Hoge<T>
where
    T: std::str::FromStr
{
    type Err = Error;
    fn from_str(line: &str) -> Result<Self> {
        // ...
    }
}

それだけではない。rustcはすぐに、<T as FromStr>::Errcrate::error::Errorに変換可能でないとこの関数はコンパイルできなくなることを見抜く。なので、ここまで話してきたような、関連型に対するトレイト境界が必要になるのだ。

ところで、今回はFrom</*...*/> for Errorを実装していたのだった。とすると、これはErrorに対して実装されているので、先程までと同じような書き方はできない。 Errorがトレイト境界From<T as FromStr>::Errを満たしている必要があるからだ。 これも、そのまんま書くと通る。

impl<T> std::str::FromStr for Hoge<T>
where
    T: std::str::FromStr,
    Error: From<<T as std::str::FromStr>::Err> // これ
{
    type Err = Error;
    fn from_str(line: &str) -> Result<Self> {
        // ...
    }
}

それ左辺に書けるの!?(驚愕)

Errorは普通に定義されたただのstructだったので、それに対してトレイト境界を設けられるとは思っていなかった。トレイト境界はジェネリクスの型引数に対して課すものだと思っていたからだ。

まあでも、わかってしまえば結構そのまんまだった。試してみるものだ。


追記(1/22)

当初std::ops::Addの実装をよくわかっておらず、ジェネリクストレイトの型パラメータと関連型を取り違えていた(該当箇所は混乱を避けるため消した)。

std::ops::Addの実装は以下のようになっている。

pub trait Add<RHS = Self /* 型パラメータ */ > {
    type Output; // 関連型
    fn add(self, rhs: RHS) -> Self::Output;
}

RHSはあくまで型パラメータであり、Outputが関連型だ。RHSを書くべき場所に関連型を書けないのは当たり前だし、型パラメータにさらなる型パラメータとそのトレイト境界を書けないのも当然だ。

Rustでgenericなenumを作る

パッと出てこなかったので。

例えば、位置か速度か力かわからないベクトルがあるとする。すると、Rustならenumにするだろう。

enum CoordKind {
    Position{x:f64, y:f64, z:f64},
    Velocity{x:f64, y:f64, z:f64},
}

let pos = CoordKind::Position{x: 1.0, y: 2.0, z: 3.0}

これをf64でもf32でも使いたいとなると、ジェネリクスを使うことになる。

enum CoordKind<T> {
    Position{x:T, y:T, z:T},
    Velocity{x:T, y:T, z:T},
}

let pos = CoordKind::Position::<f32>{x: 1.0, y: 2.0, z: 3.0}

このとき、明示的に型指定する場合はCoordKind::<T>::PositionではなくCoordKind::Position::<T>になる。これでちょっと驚いた。

これに対しても普通にmatchif letが使える。その際は、普通にrustc型推論をするので普通のenumと同様に使える。

let pos = CoordKind::Position::<f32>{x: 1.0, y: 2.0, z: 3.0}
match pos {
    CoordKind::Position{x, y, z} => println!("{} {} {}", x, y, z),
    CoordKind::Velocity{x, y, z} => println!("{} {} {}", x, y, z),
}

もし必要なら、CoordKind::Position::<T>{x, y, z}のように書けば良い。

match pos {
    CoordKind::Position::<f32>{x, y, z} => println!("{} {} {}", x, y, z),
    CoordKind::Velocity::<f32>{x, y, z} => println!("{} {} {}", x, y, z),
}

間違った型を指定すると以下のように教えてくれる。

error[E0308]: mismatched types
  --> src/main.rs:15:9
   |
15 |         CoordKind::Velocity::<f64>{x, y, z} => println!("{} {} {}", x, y, z),
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected f32, found f64
   |
   = note: expected type `CoordKind<f32>`
              found type `CoordKind<f64>`

この型指定方法の微妙なところは、以下のようなものを定義するとよくわかる。

enum Foo<T, U> {
    Hoge{x: T},
    Piyo{x: T},
}

これを明示的に型指定する場合、以下のようにする必要がある。

let x = Foo::Hoge::<i32, f64>{x: 42};

しかも、この場合i32はともかくf64は推論しようがないので(他の行でxFoo::Piyo<i32, f64>として使うとかしていない限り)明示的に書く必要がある。

これ、やはり以下の方が直感的だったのではないだろうか。

let x = Foo::<i32, f64>::Hoge{x: 42};

まあ、文法規則は直感的かどうかよりも面倒な縛りがたくさんあるのだろうということは想像はつくが。

GitBookでドキュメントを作りCircleCIでGitHub-pagesにpushする

今更感

github-pagesに直接何か生成して置くのもCircleCIを使うのも始めてでNode.jsを真面目に使うこともなかった人間がいきなりこれをやったらどうなるかの覚書

GitBookがクラウドサービスに注力してCLIの開発は基本的にやめるよとか言ってるタイミングで自力でやるとか何考えてるんだ?

まあGitBookでなくとも(例えばmdBookでも)基本は変わらないわけだし。mdBookはとっとと多言語サポート入れてくれ。docsifyとかは開発活発なのかな?

ところで、以下のやり方だと実質1コミットしかない短命なgh-pagesブランチが作られては上書きされて消えていく。初めはMarkdownがあるからHTMLやPDFは欲しい人が適宜作れるから気にしなくていいやと思っていたが、開発が止まって生成用のツールが入手できなくなる可能性を考えると、今後もどんどんバージョンを変えていってドキュメントも更新していくであろうプロジェクトではmaster/docsに生成したHTMLを入れてそれも一緒に更新していく方が良さそうな気がするなあ。

したいこと

GitBookを使ってMarkdownからリファレンス的なウェブサイトを生成し、それをGitHub pagesに置いて表示したいが、ローカルで毎回buildするのはたるいからCIサービスを使いたい

具体的には、以下をやる

  1. Markdownでドキュメントを書く
  2. GitBookでHTMLを生成する
  3. それをGitHub pagesに置く
  4. 上記を全てCircleCIを使って自動化してmasterにpushするたびに実行する

Markdownでドキュメントを書く

頑張って書く。

GitBookを使うなら、gitbook initするとREADME.mdSUMMARY.mdができる。README.mdは最初のページになる。SUMMARY.mdは基本的にリストがあるだけで、ここには目次を書いていく。これが最終的に左側にメニューバーとして出現し、またページをめくっていくときの順番にもなる。

だいたいこんな感じ↓

# Summary

* [Introduction](README.md)
* [Usage](Usage.md)
* [Reference](Reference.md)

GitBookでHTMLを生成する

基本的にこれはお手軽だ。ただ、少しばかり用意するべきものがある。book.jsonに使うパッケージやその設定を書く必要がある。例えば、

{
    "root": "book/",
    "plugins": [
        "hints", "anchors", "fontsettings", "search", "back-to-top-button"
    ]
}

rootはドキュメントのルートディレクトリだ。先のREADMESUMMARYを入れる場所。このjsonファイルがレポジトリのトップにあるなら、book/ディレクトリ以下に全てを置くことになる。これらの他にも使ってみているプラグインはあるが、面倒なのでスルー。気が向いたら書くかも知れない。

HTMLファイルを生成する前に、これらのプラグインをインストールする。

$ gitbook install # プラグインをインストール
$ gitbook build   # HTML生成

buildするとしばらくして_book/ディレクトリにindex.htmlその他が出力される。これをgh-pagesにぶち込めばよいわけだ。

GitHub pagesに置く

GitHub pagesでは、以下の3通りの場所にindex.htmlを置くことができる。

  • masterブランチのルート
  • masterブランチのdocs/
  • gh-pagesブランチのルート

gh-pagesdocsというのは駄目だ。

さて、_book/以下にあるものを改めてルートにしてgh-pagesに置く必要がある。ウェブページに必要のないソースコードなどはディレクトリに含めたくない。どうするか。

_book/内でgit initを実行し、_bookの内容しか持っていないレポジトリを作ってしまえばよい。そうするとソースコードは含まれなくなる。そこからおもむろにgh-pagesブランチを作り、元レポジトリのgh-pagesブランチに全てを無視してpushすればよい。

# 生成されたHTMLが入っているディレクトリに移動
     $ cd _book/
# そのディレクトリをルートとしてgit init
_book$ git init
# 仮のmasterに1コミット(虚無)
_book$ git commit --allow-empty '[ci skip] init docs'
# そこから分岐してgh-pagesを作る
_book$ git checkout -b gh-pages
# そこに_book以下全てを入れる
_book$ git add .
# _book以下全てをルートに持っている状態にしてgh-pagesにコミット
_book$ git commit -am "[ci skip] update docs"
 # USER/REPOのgh-pagesに無理やりpush
_book$ git push --force git@github.com:USER/REPO.git gh-pages

力技だが、これでなんとかなる。

だが結構アレなことをするし、いちいちこれを手元でやりたくない。そもそもgit add .とかして、ローカルにある変なファイル(秘密鍵とか中学二年生の頃に書いた日記のtxtファイルとか)を含めてしまったらどうするんだ(心配性)。全世界に公開されるんだぞ。いや絶対そんなもん自分のレポジトリ下にコピーして来ないが、CIサービスでやればそもそも毎回まっさらな仮想環境が構築されるので絶対に大丈夫だという安心感がある。

というわけで後は、これをCircleCIで自動でやる。

CircleCIからgh-pagesにpush

というわけで最低限必要なものを示す。

version: 2.1
executors:
  default:
    docker:
      - image: circleci/node:8.11.4

jobs:
  deploy:
    executor:
      name: default
    steps:
      - add_ssh_keys:
          fingerprints:
            - "<fingerprint of your ssh-key for deployment>"
      - checkout
      - run:
          name: install dependencies
          command: |
              npm install gitbook-cli
              git config --global user.name  "Your Name"
              git config --global user.email "Your Email Address"
              ./node_modules/gitbook-cli/bin/gitbook.js install
              ./node_modules/gitbook-cli/bin/gitbook.js build
              cd _book/
              git init
              git commit --allow-empty -m '[ci skip] update docs'
              git checkout -b gh-pages
              git add .
              git commit -am '[ci skip] update docs'
              git push --force git@github.com:USER/REPO.git gh-pages

workflows:
  setup_and_deploy:
    jobs:
      - deploy:
          name: update docs
          filters:
            branches:
              only: master

どこで何をしているかある程度言わないと不親切だが、私は何もわからない。俺達は雰囲気でCIサービスを使っている。

まあ、なんかCircleCI(2.1)はexecutorとして何かの言語のdockerイメージを使うらしく、これを先に設定しておくことで後々似たような設定を何度も書かなくて済むとのことらしい。npmを使いたいのでnodeを指定する。

続いて、jobsというのとworkflowsというのがあり、workflowsではjobsに登録されているものの依存関係やどういう順序で流すかなどを指定できるらしい。つまり、テストして、それが完了していたらそこで作ったバイナリをサーバーに置くとか、そういうことができる。まあ今回は使わないんですけど。

なので今回はworkflowにはdeployしか置いていない。で、deployというjobjobsの中に入れる。まず、そこで使うexecutorを指定。これはexecutorsで指定した名前(今回はdefault)で指定する。で、stepsのところに実際何をしていくかを書いていくようだ。

まず、add_ssh_keysfingerprintsを設定する必要がある。というのも、CircleCIのデフォルトのdeploy keyはRead onlyなので、gh-pagesgit push --forceなどとてもできないのだ。なのでこちらで新しくそのためだけのssh鍵を作り、登録してやる必要がある。

GitHubのレポジトリの設定から「Deploy keys」みたいなとこに飛び、「Add key」で鍵の公開鍵を書く。ここで小さなチェックボックスがあり、チェックすると書き込み権限を与えられるようになっている。厳重だ。忘れかねないので注意だ。

今度はCircleCIの「jobs」から目的のレポジトリのjobに飛び、歯車アイコンをクリックして同様にSSH鍵を登録する。CircleCIがこの鍵を使ってGitHubにpushするので、秘密鍵をコピペする必要がある。なので面倒だからと自分が普段使っている鍵を流用しようとしないように。

で、何故かこれだけだとCircleCIは足してあげたSSH鍵を使おうとしない。なので、明示的にadd_ssh_keysで足した鍵のfingerprintを指定する必要がある。忘れたら or 確認したかったらGitHubなりCircleCIで登録されてる鍵のfingerprintを見ればよい。

それをしておいたら、checkoutによってレポジトリのコードをcheckoutしてきて、それから実行するコマンドを羅列する。command:のところに書くと良い。基本的に上の感じで大丈夫だが、npmのためにpackage.jsonを書いたりgh-pagesなるパッケージをnpmで入れたりするとこの辺の操作が短くなるらしい。初心者なのでそれらが何をしているか知らずなんとなく気持ち悪いので全部手で書いた。

上手くいったらGitHub pagesにGitBookで生成した内容が表示される。確認した後、レポジトリのURL欄にでも貼ると良い。

ちなみにこれを実行するとgh-pagesブランチが突然現れた虚無のmasterからにゅっと生えたブランチということになり、GitHubのNetworkで見ると不連続になっている。ちょっとおもしろい。

ちなみに中学の頃の私は日記を書けるほどマメではなかったので中2の頃の日記のtxtファイルというものはない。

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