toml11をマイナーアップデートした

しばらく前だが、アップデートした。最新バージョンはv3.1.0だ。

Added

TOML11_USE_UNRELEASED_TOML_FEATURES

TOML言語仕様はまだちょいちょいバージョンアップされている。なので「TOMLとしてリリースされていないがtoml-lang/toml:masterにmergeされた、おそらく次のリリースで入るであろう機能」というものがある。

このうち、便利そうなもの2つは先行して実装した。その2つとは、「basic stringで生のタブ文字を許容する」と「浮動小数点数の指数部で0-prefixを許容する」だ。

ただ、これらの機能はまだTOML側ではリリースされていない。基本的にリリースされていない仕様に依存するのはあまりよくないことなので、TOML11_USE_UNRELEASED_TOML_FEATURESというフラグによって制御することにした。toml11をincludeする前にこれを定義するか、コンパイルオプションで定義しておくことによって、上記2つの機能がアクティブになる。

前者は、特に複数行文字列を書いているときに便利だ。以下のような文章で、パラグラフのはじめにタブを使いたいときがある。これまでは、文字列内でのタブ文字は許されていなかったので、以下のTOMLは文法違反だった(\tのところにタブ文字がある場合)。

long_string = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
(\t)Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. 
"""

これが許可された。

後者は、数値の入力で圧倒的に便利だ。多くの言語で使われている1.0e+01のようなフォーマットで、eの後に来る指数部分での0-prefixを許容する。詳細は以前書いた。

TOMLで浮動小数点数の指数部分でleading zeroが許可される - in neuro

TOML側で次のリリースが来次第(そしてこれらの機能がrevertされない限り)、このマクロは削除され、デフォルトの挙動にする予定だ。

toml::value::at

toml::value vに対して、v.as_table()["hoge"]ではなくv["hoge"]としたいという要望があり、色々考えた末toml::value::atを追加した。

以下のような書き方ができる。

const toml::value  v = toml::parse("hoge.toml");
const toml::value& t = v.at("table");

文字列が来たらtableだと思ってキャストしてみて、失敗したらtype_error、成功したらキーを探す。なければout_of_range

これは、C++プログラマにとってatという名前から予想される範囲の実装だと思う。STLatは範囲チェックをして、失敗したら例外を投げる。toml11のatは型チェックと範囲チェックをして、失敗したら例外を投げる。

だが実際には、要望が合ったのはoperator[]だ。std::mapなどのoperator[]は値の変更・追加ができるので、

toml::value  v = toml::table{};
v["key"] = 42;

のような書き方ができるようになる。なるほど便利だ。

これに関してはとても悩んだが、今回は見送った。

理由は、一貫性のある実装ができないからだ。より正確に言うと、期待される振る舞いに一貫性がない。

C++プログラマにとってSTLはあまりにも馴染み深く、野良ライブラリのAPISTLと少しでも違ったら怒り狂ったC++プログラマに逆さに吊られ、未定義動作によって呼び出された悪魔への生贄に捧げられる[要出典][この記述には問題があります]。なのでライブラリ作者としてはとりあえずSTLから予想される挙動と合わせておくのが吉だ。

と考えると、テーブルにoperator[]でアクセスした場合は、値があった場合はそれへの参照が、なかった場合は値が作られてそれへの参照が来るべきだ。これはstd::mapと同じ振る舞いだ。また、配列にoperator[]でアクセスした場合は、境界チェックをせず、値がなかった場合は潔く未定義動作にするべきだ。これはstd::vectorと同じ振る舞いだ。配列に対するoperator[]は極限まで高速であるべきで、境界チェックが入ったり、例外が飛ぶことは期待されていない。(注:規格としては特にnoexceptであるとは触れられていないので、境界を踏み越えたときに未定義動作として例外が飛ぶのはありかも知れない。だが、operator[]他いくつかの関数は規格で償却定数時間で終了すると定められているので、勝手にresizeするのは規格違反だろう)

これをこの通り実装するのは簡単だが、その後のことを考えてみよう。以下のコードはわかりやすいだろうか?

toml::value  v = toml::table{};
v["key"]          = 42;            // OK. key = 42を定義。
v["table"]        = toml::table{}; // OK. 新しいテーブルを定義。
v["table"]["key"] = 42;            // OK. table.key = 42を定義。
v["array"]        = {1,2,3,4,5};   // OK. arrayを定義
v["array"][5]     = 6;             // Undefined! 範囲エラー
                                   // 配列に値を追加しようとした

最後の行は罠になる。ではどういう実装になるべきか? チェックしてout_of_range例外を吐く? push_back()してback()を返す? どういう実装になっても、std::vector::operator[]とは食い違ってしまう。実質的に選択肢がないのだ。

ここを正しくやれるアイデアが出なかった(どれかで妥協もできなかった)ので、これは実装しなかった。

Fixed

extended conversion

バグが一つと、ドキュメントのミスが一つ見つかった。

toml11は、ユーザー定義クラスとtoml::valueの間の変換をサポートしている。例えば、あなたがext名前空間内にfooという構造体を持っているとして、それにfrom_tomlという関数を実装すると、toml::find<ext::foo>(data, "foo")がと書けるようになる。

namespace ext
{
struct foo
{
    int         a;
    double      b;
    std::string c;

    void from_toml(const toml::value& v)
    {
        this->a = toml::find<int        >(v, "a");
        this->b = toml::find<double     >(v, "b");
        this->c = toml::find<std::string>(v, "c");
        return;
    }
};
} // ext

const auto data = toml::parse("example.toml");

const foo f = toml::find<ext::foo>(data, "foo");

だがいつでも変換用のメンバ関数を追加できるわけではない。例えば、デフォルトコンストラク不能な型の場合、from_tomlを呼ぶためのインスタンスを作れない。他に、サードパーティライブラリを使っていてその初期化にTOMLを使いたい場合、そのライブラリの中で定義されているクラスに勝手にメンバ関数を実装することはできない。

なので、外からも変換関数を定義できるようにしていた。

namespace toml
{
template<>
struct from<ext::foo>
{
    static ext::foo from_toml(const value& v)
    {
        ext::foo f;
        f.a = find<int        >(v, "a");
        f.b = find<double     >(v, "b");
        f.c = find<std::string>(v, "c");
        return f;
    }
};
} // toml

TOMLへのシリアライズtoml::valueへの直接代入を許すために、逆の変換も同じ要領でできるようになっている。

ここで、私はtoml::get側のSFINAEをミスっており、双方向の変換がないと変換できないことになっていた。これはバグで、from<T>さえあればtoml::get<T>は動くべきだ。さらに、READMEでtoml::from<T>::from_tomlstaticにするのを忘れていた。@htrefil氏の報告によって、ベッドの中でGmailを開いてコードを見た私と恐らく起きていたであろう@jcmoyer氏の両名がREADMEのミスと中にあったバグに気づき、私が(明日直そう)と思って就寝している間に@jcmoyer氏がPRを投げていた。

というわけで直った。

move semantics

前のバージョンではmove関係のロジックが若干おかしくなっており、toml::valuemoveしてtoml::getに渡すことでコピーが生じる可能性があった。これを消そうとしたらuse-after-moveを大量に引き起こしてしまい(サニタイザーのお世話になった)、色々直した。

正直言って今moveで渡しさえすればどんな状況でもコピーが本当に一切起きないかと言われるとあまり自信がない。が、とりあえずUBsanとAsanはテストケースの範囲内で文句はないそうなので、まあ使う分には大丈夫だと思う。少なくとも、普通の参照かconst参照で渡している限りはこの手の問題は発生しない。

サニタイザをCIで流せるようにするべきだと思いながらずっとやっていない。

今後の方向性

かなり大きめのやりたいこととして、以下のようなものがある。だがこれに割けるほどの時間はあまりありそうにない。

error recovery mode

デフォルトにする気はないが、ファイルを読んでいる時は文法エラーを警告にしておいて、1ファイルに複数の文法ミスがあった際に全てを報告できるようにしたいという気持ちがある。パースして直して再度読み込むのは面倒なので、一度に複数直せるようにエラーは複数表示して欲しい。

だがこれは若干めんどいので(壊れている配列などはどこで「配列が終わった」と判定すればいいか? など)、結構な変更が必要そうで尻込みしている。

これを実装すれば、ついでに「壊れている値は未初期化のままとりあえず全部読み込む」ということも可能になると思われる(ファイル内の全エラーを見つけるためには、パーサはファイル終端まで届かなければならないので)。そういうことを必要とする人がいるかどうかは置いといて、おまけもついてくると思うと少しやりたさはあるのだが……。

noexcept mode

完全に無例外保証というのは不可能だが(std::vectorがメモリ確保に失敗したらbad_allocが飛んでくる)、文法エラーを例外ではなくresultでやりたいという気持ちはある。そのために実は内部ではできるだけresult<T, E>を使っているのだが、処理が複雑になっているところを置き換えるのが難しいという理由で放置している。意を決して見てみたら意外とできるのではと思いたいが……。

単純に失敗したらその場で全てを放り出して例外を投げて終わりにしてしまうというちゃぶ台返し方式が便利すぎるのが悪い。中でキャッチとかし始めると死ぬが。

コンパイル高速化

toml11はコンパイルが遅い。これに関しては、ユーザーが使いそうな関数の宣言だけ取り出したヘッダを作り、先にそれ以外をコンパイルしておくことによって解決しないだろうかと思いつつほぼ何もしていない。

以前、toml::basic_valueのうちいくつかのパターンと、toml::parseserialize関数をextern templateで先にコンパイルしておくというのをやってみたが、あまり劇的には短縮されなかった。GCCのプロファイルを見る限り、コード生成部分よりももっと別のところが時間を食っていて、結局そのブランチはマージしていない。多分ヘッダをもっと分けないと効果がないのだろう。

おわり

そんな感じです。

ドキュメントは何で書けば良いのか

今までドキュメントは適当なMarkdownで書いてそれを適当にGitBookでウェブサイトに変換していた。だがGitBookはコマンドラインツールの開発をやめてしまっている。なのでGitBook公式のホスティングサービスに移行するか、何か代替品を探さなければならない。というわけで探しているのだが……。

  • 既存のGitBook用ファイルを使いまわしたい
    • 何に移行するにしても少しの手直しは必要になるのである程度は我慢。
    • このためにAsciiDocとかLaTeXはとりあえず除外
  • 数式が書ける
    • これは(mathjax/katexの違いはあれど)大抵のものがクリアしている
  • HTMLが出力できる
    • 基本的に全てが対応している。
  • PDFも出力できる
    • 全然誰も興味がないらしい……誰も対応していない……。
  • ヒントボックスとかが欲しい
    • テンプレート機能があればなんとかなるが、あったほうが圧倒的に楽。
  • 多言語対応(できれば)
    • 最悪全言語生成して繋げばいいので優先度は低め。
  • インストールが楽
    • そんなソフトウェアはない。

GitBook v2

ホスティングサービスとして生まれ変わったGitBook。

Pros

  • ホスティングサービス上でのサポートが超強力
    • 強いエディタ、チーム管理、ロゴやデザインのカスタマイズ、etc
  • GitHub、Slackなどの外部サービスとの連携が楽。
  • GitBookの資産はほぼそのまま利用可能(当然)

Cons

  • 多言語対応がなくなった
  • PDFの出力をサポートしなくなった
  • 関係ないけどAsciiDocサポートもなくなった
  • 簡単なのは使えたから気づかなかったけど数式プラグインは移行途上らしい
  • 手元で動かせないので向こうのサービスに完全に依存してしまう

mdBook

RustによるGitBook的ツール。発展途上感はある。

Pros

  • 速い。ストレスフリー(大事)
  • GitBookからの移行も楽
  • cargoが入っていればインストールが結構楽

Cons

  • 多言語対応なし
    • 4年間(Issue番号一桁、2015年から)、多言語対応はどういう形でなされるべきか、そもそも必要かとの議論が続いている。
  • ヒントボックスがない(自分で作れはするっぽい?)
  • PDF出力が少し面倒(mdbook-latexを使う?)
  • サイドバーの索引をfoldできない
    • 本ならChapterの階層は深くならないのだろうが、設定の説明をカテゴリ別に分けてると見栄えが悪い……。

docsify

静的サイトジェネレータではなく、Markdownファイルを読み込んでその場でレンダリングするらしい。

Pros

Cons

  • 環境構築は(mdBookよりは)難しい
    • PDFを作るプラグインの実行に失敗した
    • のでPDFの見栄えがまだわからない

GitBook v1

能力的には最強だったのに開発が止まってしまった可哀想な子。

Pros

  • PDFも出力可能、しかもPDFの見た目が綺麗
  • 多言語対応あり、最初に言語を選ぶ形
  • 多彩なヒントボックス、その他見栄えを整えるためのプラグインが多数。すぐ思いつくことは大抵できる。

Cons

  • 実行速度が遅い
  • 開発終了

まとめ

改めて見てみると、GitBook v1がハチャメチャに強かっただけだった。

GitBook v2はエンタープライズ向けWebサイトホスティングサービス的な方向に向かっている気がする。コラボレーションとかブランディングのような機能が強化され、ローカルでは使えなくなり、PDFやeBookへの出力や多言語対応は切られた。

mdBookは速度やインストールの手軽さを考えるとかなり良いが、やはりまだ機能が少ない。ヒントボックスなんかはプラグインとして作ればウケるかもしれない(探せばあるかもしれない)が、Web関係はほとんど触ったことがない分野なのでできるかどうかわからない。

docsifyが今のところ一番いい代替だろうか。PDFの見栄えがGitBook並みによければ、移行してもいいかもしれない。

GitBook v1、なぜ死んでしまったのか……。フォークしてnodejs勉強して自分でアップデートし続ければいいのかな。まあソフトウェアそのものでさえ手間がすごいのだから、ドキュメントにそこまでの手間はかけられないのだが。


追記

この記事を書いた後しばらくgitbookのためのDockerイメージを作ろうとしていたのだが、dependencyの複数が開発停止になっており、HTMLはかろうじて作れるものの、PDFはかなり詰んでいる。手間を考えると、やはりもうgitbookは捨てるしかなさそうだ。具体的には、svgexportが依存しているphantomjsが開発停止になっているし、mathjaxが近頃メジャーバージョンアップをしたせいでダウングレードしないと動かない。

かと言ってGitBook v2は目的に合わなさすぎて使えない。PDFを諦めてもいいのかもしれないが、そうなると静的サイトが生成されてほしいし、となるとmdbookしかない。mdbookの拡張機能について学ぶ必要があるだろう。あるいはdocsifyからPDFを生成できるようにちょっと頑張ってみるかだ。


docsifyでPDFを作ってみたが、gitbookと比較すると以下のような不満点がある。

  • 目次が生成されない
  • ページとファイルが対応しない
  • ファイル内リンクが生成されない
  • コードブロックの背景が出力されない

思いっきり頑張って設定してみれば解決するのかもしれないが、プラグインに頼らずgitbook pdfでこれら全てが満たされたPDFが出てくるgitbookはやはり便利だった……。

そこで頑張って設定するならいっそpandocでいいのではという気が少しずつしてくる。


mdbook-latexを試してみた。8月にレポジトリが発生したレベルで新しいものなので苦労したが、まあ割となんとかなった。

gitbookのPDFほどの華やかさはないが、途中経過として普通にLaTeXを出力できるのでそこはどうとでもなる。

今後に期待というところだろうか。latex/pdf関連で何かできそうなことがあるか余暇で調べつつ、mdbookを使っていこうかな。


一応、Dockerを使ってGitBookのPDFを作ることができた。まだ少しは延命できそうではある。いいことかどうかは置いといて。

インデントのスタイルについて

そういえば、自分のインデントスタイルについて人に話すことってないなと思ったので。

インデントやコードのスタイルは初心者には軽視されがちだ。なぜならそれは「本質ではない」からだ。どんなコーディングスタイルでも動くものは動く。コンパイラにとって改行や空白は意味をなさないからだ(一部の言語を除いて)。だが、長いコードを保守したり、人のコードを読んだり追加したりする経験が長くなってくると、「本質ではない」ところで気を散らされることが本当に嫌になってくる。人間の目はものの形を捉えることが出来る。コードの構造をパッと見の形状に反映させることができれば、よりたくさんの情報を高速に読み手に伝えることができ、デバッグや理解が楽になる。

「本質ではない」のはインデントだけではなく、変数名や型名なんかもそうだ。a, b, ... z, aaのような名前をつけて行ってもなんの問題もない。動くものは動く。それでも名前をつけるのは、名前が一種の外部記憶装置の役割を果たし、変数や型が持っている性質を覚える手間をなくしてくれるからだ。人間の脳はそんなに高性能ではないので、長いコードを補助無しで読みきるのは骨だ。読みにくいコードは保守もしにくい。読みにくさ、理解のしにくさは人を品質改善から遠ざけるし、バグ取りの苦痛を倍加する。

変数名に情報を載せるように、見た目の印象、コードの概形にも情報を載せることができる。それがインデントや改行位置のスタイルを整えるということだ。型名や変数名は局所的な情報になってしまうので、全体的なコードの構造を効率的には載せられない。逆に、インデントや改行位置によって、コードの構造を理解する補助ができる。

私は私なりのスタイルを持っていて、大体の私のプロジェクトではそのスタイルを使用している。一応その大半に理由があるので、一度それをまとめてみようと思った。とはいえこれが唯一絶対の正しいスタイルだとは思っていない。別のプロジェクトに関わるときは、しばらくそのプロジェクトのコードを読んで、そのプロジェクトで使われているスタイルを模倣している(.clang-formatなどが提供されていればそれに従っている)。自分のスタイルを押し付けず、相手のコードの中では相手のスタイルに従うのが礼儀だと思っているからだ。

ところで私は普段からあまりエディタの自動補正を使っていない。設定してあるのはインデントの幅くらいだ。というのも自分のスタイルは染み付いているからそもそも書く端から条件は満たされていくし、他人のコードに書き足す時にいちいちエディタの設定を変えるのも面倒だからだ。そもそも私は見た目が整っていないコードが単に嫌いなので、インデントを揃えることに若干の喜びを感じられる。というわけでわざわざ自動化するメリットがあまりないのだ。

というわけで。


  • 1行は(大体)80文字

これはありがちな設定だ。いきなり(大体)などとファジーな定義が出てきたが、これは私が人間に取っての読みやすさを重視しているからだ。例えば、以下のような関数があったとしよう。

void some_class::some_function(const some_type& x) const noexcept
{
    return;
}

この関数の、noexcepttが81文字目だったとして、これは改行するべきだろうか? 現代的なターミナルは80文字をかなり超えて文字を表示できるし、それを縦に何分割かしても、八十数文字は普通に確保できるだろう。むしろ完璧に80文字以内のコードを読む時も、だからといってターミナルにギッチギチに表示する人は少数派な気がするし、多少はみ出ても折り返してしまうということはない。

改行は必要だが、やはり読みやすさを若干損ねる。近接性が若干とはいえ犠牲になるからだ。やはり、関数の修飾子は関数定義と同じ行にあって欲しい。一定量を超えて長くなるなら改行するしかないが、数文字だけのために改行するのはどうかとも思う。

他にも以下のような場合が例外ケースになる。

reference       operator[](size_type i)       noexcept {/*...*/}
const_reference operator[](size_type i) const noexcept {/*...*/}

このようなアクセサの実装は大半の場合に1行にギリギリ収まるか収まらないかの範囲になる。この場合、上下でほぼ同じ実装になることが明らかなので、その比較をしやすい形で実装した方が好ましい。2つが異なる実装になっているとバグである可能性の方が高いからだ。似た実装は、似ていることがわかりやすい形で配置したほうが良い。もちろん実装が長くなってくるとそういうことはできないが。

ちょいちょい、例えば関数定義で大量の引数があっても改行を一切挟まないコードというのを見ることがある。他人のスタイルは尊重したくはあるが、こういうのはちょっとどうかと思う。折り返さないエディタだと一瞥して定義が見渡せないし、折り返されるとインデントもクソもない一番最初のカラムから次の行が開始するので、読みやすさというものがない。画面全面を使って一度に表示しても、コードの大半が左端に寄ってしまうので読みにくい。あれはどういう理由であのスタイルになっているのか知りたいところではある。


  • ブロックはオールマンスタイル

こういうやつのことだ。

if (/**/)
{
    // ...
}
for(std::size_t i=0; i<10; ++i)
{
    // ...
}

私はギチギチに書かれたコードというのが好きではない(よく使われるスタイルではあるが)。全ての行に沢山の文字が書かれていると、行を見間違えたりする。適切な位置に空行が入っていれば、異なる処理は異なるもの、似た処理は似たものと瞬間的にわかる。空行は必要だ、特に処理の内容が大きく変わるときは。

このスタイルでは行数は多く消費するが、最近の画面は広い。スペースは広く使って良いはずだ。もしそれでもコードが長くなりすぎて読めないなら、それは多分、その実装が特殊だ。全然難しいことをしているつもりがないのにこのスタイルでは行数が多くなりすぎるというのなら、実装を考えなおしたほうが良いと思う。


  • 4スペースインデント

インデントの幅は、2か4が主流であとはTab派が同じくらいいるという感じだろうか。私は4スペース派だ。これはブロックにオールマンスタイルを使っているのと同じ理由で、インデントが一発でわかるようにするためだ。2スペースは、そこまで悪くはないが、やはり抑え気味に見える。もっと思いっきり見分けが付くようにして構わない。一瞬見ただけで見分けがつくようにするためにインデントを使っているのだから、見分けがつきやすいほうが良いだろう。あまりにも読みにくくならなければ、だが。個人的には、流石に8スペースはやり過ぎではないかと思っている。あまり長くすると目で追いにくい。個人的に瞬間的に識別できて目で追いやすいバランスの取れた個数が4スペースだと思っている。

Tabは、手元では調整できても別の環境で見ると幅が違っていて困惑することがあるので使っていない。私は多分、自分のコードの大部分を文字列ではなく概形で覚えているのだと思う。

4スペースだとネストした時にすぐに80文字を突き破るのが良くないという意見がありそうだが、それはネストが深すぎる。何かを考えなおしたほうが良い。もし特殊な理由によってそれが絶対に必要なら、そうするべきだった理由をコメントで並べて、そこだけインデントなしにするなどの対策を打てばいいだろう。


  • namespaceではインデントを付けない

namespaceは大抵、そのプロジェクト固有のもの一つ(と、たまにdetailとか)で、そう簡単にコードのどまんなかで気軽に開いたり閉じたりするようなものではない。普通はファイルの最初と最後で開いて閉じるだけだ。変なことをしていても、最初と最後に少し深い領域ができて、そこはすぐに抜ける。つまり、トラッキングするコストがほぼない。なんだかんだいって、行数80文字はそれなりに厳しい条件だ。元々無いコストを下げるためにファイル全体を4文字インデントするのは無意味だ。

基本的に、スタイルというのは理解のコストを書き方の制限というある種の不便と交換するものだ。理解のコストと書き方の制限との間にトレードオフが存在していることを忘れてはいけない。


  • カラムを揃える

これも賛否あふれるトピックだ。というかスタイルというのは賛否が極端に入り乱れる。私は揃える派だ。揃えることのデメリットは、型名や一部の変数が変わった際に行全体が変更を受けるというもので、diffがわかりにくくなってしまうことだろう。私はそれを受け入れてでもここは揃えておきたい。構文木レベルでdiffを取るツールが普及してくれればよいのだが。

例えば以下はtoml11の今のmasterにあるコードの断片だ。

const std::vector<key>&  keys    = kv.unwrap().first.first;
const region<Container>& key_reg = kv.unwrap().first.second;
const value_type&        val     = kv.unwrap().second;

今見ると、一回unwrapして一時変数に参照を置け、という気分になるが……。とりあえず、ここで言いたいのは、縦を合わせておくことでこれらの変数の出自が瞬間的にわかるということだ。上下に同じパターンが出てきているところは、文字列を読んで理解する処理よりもさらに速く、見た次の瞬間に浮き彫りになる。だからこそ、「何度もunwrapをするな」という感想が一瞬で湧く。リファクタリングの機運が高まる。コードがより綺麗になっていく。

const std::vector<key>& keys = kv.unwrap().first.first;
const region<Container>& key_reg = kv.unwrap().first.second;
const value_type& val = kv.unwrap().second;

どうだろう、見えるだろうか?


  • 計算を行う演算子には空白を入れ、改行時は行末に残す
const auto x = 1 + 2;
const auto y = 2 +
               3;

これも、スペースがあった方がより読みやすいという意識の現れだ。改行した際に最後に残すのは、最後に演算子がない行はセミコロンが抜けているのかと思ってしまうからだ。2行め以降はインデントで所属をある程度明らかにできるが、行末には演算子を残しておく以外の方法ではそういった補助を入れられない。

例外的に、結合が強そうな印象を持っている演算子には空白を入れていない。

ptr->f();
x.f();
++iter;
-z;

// ptr -> f();
// x . f();
// ++ iter;
// - z

operator.operator->に関しては、どうしても縦列を合わせたほうがよいと判断した場合は空白を入れることもあるが、まあ滅多にないことだ。

これは多分、私の中で四則演算に相当する演算子は、項と項の間にあるものという認識なのだろう。左辺、右辺の項としてのまとまりの方が、演算子とその両辺とのまとまりよりも強いという感覚がある。


細かい例外が沢山あるような気がするので面倒になってきた。だいたい私が主に使っているスタイルとその理由は以下のとおりである。

.clang-formatも作ったのだが、元々行あたり文字数制限をかなりファジーにやっているせいで、コストの値をちょうどよくするのがとても難しい。コードは大量に書いているので、私のコードを再現するようにclang-formatのパラメータを最適化するツールでも書こうか。それのハイパーパラメータの調整にまた手間取りそうだな。

しかし、これで私の派閥がだいぶ伝わったに違いない。ちなみにたけのこ派だ。

入力に応じてテンプレート引数を変える

C++は静的に型付けを行うので、基本的に実行時に型を切り替えることはできない。なので、入力に応じて異なる型を返すことはできない。だが継承を使っているなら、常に基底クラスへのポインタを返しつつ、入力に応じて派生クラスのテンプレート引数を変更することは可能だ。

例えば、以下のようなものがあるとしよう。

struct Base
{
    virtual ~Base() = default;
};

template<typename T>
struct Derived : public Base
{
    ~Derived() override = default;
};

ここで、例えば入力によってTの型を変えたいとする。これは簡単で、以下のようにすればよい。

std::unique_ptr<Base>
read_input(const std::map<std::string, std::string>& input)
{
    if(input.at("type") == "double")
    {
        return std::make_unique<Derived<double>>();
    }
    else if(input.at("type") == "float")
    {
        return std::make_unique<Derived<float>>();
    }
    else
    {
        throw std::runtime_error("unexpected type appears: "s +
                input.at("type"));
    }
}

必要なら、このような関数を入れ子にしていけばよい。

template<typename T1, typename T2>
struct Derived : public Base
{
    ~Derived() override = default;
};

// read_T1でT1を決め、この関数に型情報を渡して呼ぶ
template<typename T1>
std::unique_ptr<Base>
read_T2(const std::map<std::string, std::string>& input)
{
    if(input.at("type2") == "double")
    {
        return std::make_unique<Derived<T1, double>>();
    }
    else if(input.at("type2") == "float")
    {
        return std::make_unique<Derived<T1, float>>();
    }
    else
    {
        throw std::runtime_error("unexpected type appears: "s +
                input.at("type2"));
    }
}

std::unique_ptr<Base>
read_T1(const std::map<std::string, std::string>& input)
{
    if(input.at("type1") == "i32")
    {
        // read_T2にT1の分の型情報を渡す
        return read_T2<std::int32_t>(input);
    }
    else if(input.at("type1") == "u32")
    {
        return read_T2<std::uint32_t>(input);
    }
    else
    {
        throw std::runtime_error("unexpected type appears: "s +
                input.at("type1"));
    }
}

全く同じ型のセットが複数あるなら、以下のようにできる。そういう状況はあまりなさそうではあるが、まだあり得そうな場合として、何かの機能をオンオフするboolテンプレート引数を複数取るということにしよう。この状況ならとり得るオプションがonoffしかないので、同じ分岐が何度も続くことになる。そういうときに何度も同じコードを書くのはタルいので、再帰すればよい。このとき、当然ながら参照するべきキーが異なるはずなので、それは再帰する度に消費していかないといけない。

template<bool Cond1, bool Cond2, bool Cond3, bool Cond4>
struct Derived : public Base
{
    ~Derived() override = default;
};

// 条件が4つ決まっていたらコンストラクタを呼ぶ
template<bool ... Cs>
typename std::enable_if<
    sizeof...(Cs) == 4, std::shared_ptr<Base>>::type
read_input(const std::map<std::string, std::string>&,
           std::vector<std::string>)
{
    return std::make_shared<Derived<Cs...>>();
}

// まだ4つ決まっていなければ、次を決める
template<bool ... Cs>
typename std::enable_if<
    sizeof...(Cs) < 4, std::shared_ptr<Base>>::type
read_input(const std::map<std::string, std::string>& input,
           std::vector<std::string> keys)
{
    // 4つあるはずのキーのリストがなくなっていたらエラー
    if(keys.empty())
    {
        throw std::out_of_range("missing option");
    }
    // 最後尾のキーを取得し、pop_backしておく
    const auto key = keys.back();
    keys.pop_back();

    if(input.at(key) == "on")
    {
        // 現時点での最後尾のものが決まる
        // (既に決まっているCs...は以前pop_backしたものなので後ろに来る)
        return read_input<true, Cs...>(input, std::move(keys));
    }
    else if(input.at(key) == "off")
    {
        return read_input<false, Cs...>(input, std::move(keys));
    }
    else
    {
        throw std::runtime_error("unknown option : "
            + input.at(key));
    }
}

int main()
{
    const std::map<std::string, std::string> input{
        {"opt1", "on"},
        {"opt2", "off"},
        {"opt3", "on"},
        {"opt4", "off"}
    };
    const auto derived =
        read_input(input, {"opt1", "opt2", "opt3", "opt4"});
}

上のコードはC++11でも動くはずだが、C++17を使えば少し楽になる。if constexprが使えるからだ。

template<bool ... Cs>
std::shared_ptr<Base>
read_input(const std::map<std::string, std::string>& input,
           std::vector<std::string> keys)
{
    if constexpr (sizeof...(Cs) == 4)
    {
        return std::make_shared<Derived<Cs...>>();
    }
    else
    {
        if(keys.empty())
        {
            throw std::out_of_range("missing option");
        }
        const auto key = keys.back();
        keys.pop_back();

        if(input.at(key) == "on")
        {
            return read_input<true, Cs...>(input, std::move(keys));
        }
        else if(input.at(key) == "off")
        {
            return read_input<false, Cs...>(input, std::move(keys));
        }
        else
        {
            throw std::runtime_error("unknown option : "
                + input.at(key));
        }
    }
}

if constexprを使えば、再帰を終える場合のSFINAEとオーバーロードが必要なくなる。ここで普通のifを使うと、あり得る全ての個数のCsに対してDerived<Cs...>を実体化しようとしてしまう。Derivedは4つのパラメータを取るので、当然4以外の個数のパラメータが来るとエラーになる。なのでここではsizeof...(Cs) == 4以外のときの実体化を抑制しなければならず、普通のifではなくif constexpr(か、SFINAE)を使う必要がある。

普段パラメータパック展開をする際は受け取る側もパラメータパックを受け取ることが多い。なので上のようなコードが規格的に許されているのか確信が持てず、N3337をちょっと見てみた。だが検索してパラパラ見た限りではその場合に対して陽に言及したところは見つからなかった。パック展開に関してのところをざっと読んだ限りでは、単にそこに(文脈に応じた形で)展開されて終わりのように読める。単に展開されることしか定められていないなら、受け取る側が何であるかは関係ないだろう。もし展開結果がエラーになるのなら、エラーになって終わりだ。それ以上のことを細かく決める必要もなさそうだ。だとすると、上のコードは特に何も問題ないだろう。

可能な限りテンプレートで静的に決めておきたいが実行時に分岐もしないといけないような場合は、こういう選択肢もある。メリットは静的に決めることでコンパイラに最適化の機会を与えられること、デメリットは全てのパターンが生成されるのでコンパイル時間が爆発することだ。実行時に分けてもパフォーマンス的に影響がない、またはよりパフォーマンスが向上するところを上手く選んでテンプレートから継承に切り替えるとよいだろう。

言語間の速度差について

全く同じアルゴリズムを似たようなスキルの人が書いても、使った言語によって速度差が出る。

たとえばガベージコレクタが一瞬実行を停止させてしまったり、配列の境界チェックが必ず行われたり、ネイティブコードにコンパイルされなかったり、そもそもの処理系が遅かったりなんてこともあったりなかったりする。

なので、例えばGCがあって(GCはかなり高速らしいが)境界チェックがあるGoでCの速度を追い抜こうとすると、かなり上手くハマった場合でしか達成できないだろう。ほんの少し遅いか、倍程度時間がかかるくらいの範囲になるのではないか。これはGoが悪いわけではなく、若干の速度を犠牲にしても安全なプログラムが書けるならその方がいいという判断であり、実際世の中のほとんどのアプリケーションの要求はGoの速度でもはや十分、むしろ速すぎるくらいだ。なのでこの選択が間違っているとは一切思えない。

それでも更なる速度向上が必要な分野というものは存在する。速ければ速いほど嬉しい分野、科学技術計算、機械学習、データベース、グラフィクス、ゲーム……。そういった場面では、異なる選択があり得る。手動でメモリを管理してガベージコレクタのオーバーヘッドをなくしたり、配列外参照をすることはないと信じて境界チェックをなくしたりする。柵を乗り越えて、自己責任で崖際を走っているわけだ。そのほうがインコースだからというだけの理由で。

さて、ゼロオーバーヘッドを謳っている言語として、C++やRustがある。これらの言語はプログラムを書くのをより便利にするような抽象化を提供するが、そのために不要な実行コストを持ち込まないように注意深く設計されている。これらの言語は、他の何で(例えばCで)同等の抽象化を行っても、それ以上無駄を削れないようなコードを生成することを目標としている。

What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better. -- Bjarne Stroustrup

もちろん、C++の継承に付随するvtableや、Rustのトレイトオブジェクトなんかは抽象化をしない場合と比べると若干のオーバーヘッドがあるが、それは抽象化そのものに付随するオーバーヘッドと解釈される。それ以上削るためには、そのレベルの抽象化(この場合、継承(のような機能、関数ポインタなどを含む)を使って実行時にコードを切り替える)を行うことを諦めざるを得ない。

CやFortranではあまりゼロコスト抽象化といった表現がされないが、おそらく「低級言語」と揶揄されたりする以上言及するべき抽象化が提供されていないと思われているのだろう(Cのポインタなんかは規格的にはアドレスのように振る舞うものならアドレスでなくてもいいので、見方によっては抽象化されているのだが、実際には誰も抽象化していない)。実際これらの言語は基本的にほぼオーバーヘッドがない。提供する抽象化機能もC++やRustに比べると少ないが。

私は、C、C++Fortran、Rustの全てを結構書いたことがあるし人のコードも読んだことがあるが、これらの言語間でどの言語が速いなどと速度を競うことに意味はないと思っている。ちょっとしか触ったことがないがZigやDも。これらの言語は概ね似たような速度が出て、書いた人のスキルに依存するところが非常に大きいからだ。同じ言語を使っていても書いた人によって倍以上の速度差が出ることは多いし、FortranやCで頑張って書かれたコードをC++で短く書き直したら倍速くなったみたいなことも何度もある。だが、C++を使う時に変なことをすると、例えばクラスのコンストラクタの裏に隠れている動的メモリ確保に気づかず適当に作っては消したりすると、バカみたいに遅くなることもある。C++やRustは最低限のコストに止めているとはいえ抽象化も提供するので、抽象化された便利な機能の裏側で実際にはオーバーヘッドがかかっていることに習熟しないと気づかない、ということがあり得る。だが一概にクラスを作ったから遅くなるということはない。そこを見分けるには経験と学習コストが必要だ。そういうことは言語自体の速度の問題だろうか? 人の理解度の問題だろうか? もしそれが言語の問題だというなら、最適化が難しくなる(というか、理解を要求する)代わりにコードを書くのが便利になる機能を追加することは高速な言語として欠陥か?

実際には半々だろう。学習コストは下げたほうが良い。速度は上げたほうが良い。コードは簡潔な方がいい。そのどれかが駄目な言語は、誰かしらにやり玉に上げられる。だがこれらの全てにはトレードオフがある。現実には全てを同時に達成するのは殆ど不可能なくらい難しい(そもそも、プログラミングというものそれ自体が多大な学習コストを要求しているんじゃないのか?)。そういうことを前提にすると、理想にまだ到達していない言語の設計の問題であり、目的に対し要求される最低限の学習コストを払えない人間の問題でもある(あるいは目標が高すぎる)。多くの言語はそれぞれ少しずつ異なる目標を持っている。簡潔に書けて学習コストが(想定内のことをしている限り)低い代わりに速度が遅い言語もあれば、そこそこ簡潔に書けて速度も悪くない、というバランスの取れた言語もある。好きなところを選べばいいだけだ。そして、何もかもを犠牲にして速度を出したいなら、残りのどちらを主に犠牲にするか、つまり全部自分で書くか(コードの簡潔さ)、抽象化の裏側を学んで少し楽をするか(学習コスト)、選べばいい。言語ごと選んでもいいし、言語の中のどの機能を使うかを選んでも良い。

ついでに、学習コストが高いことは常に悪かというとそれも難しい。参入してくるプログラマのその後のキャリアなどにも関わってくる(多くの問題解決方法を抱えた人のほうが潰しが効くが、実績を積む速度が遅くなりすぎると次の仕事に影響が出るかもしれない)。ここでも、たくさんの付随するパラメータがある。ゆっくりスキルアップできる環境なら、少し学習コストが重くても一度学べばその後楽ができるだろう。素早く完成させてしまわないといけないなら、多少コードが冗長でもとっとと書き始められる方がいいかもしれない。それでも、結局自分で書くなら裏側がわかるはずなので学習に少しコストを掛けたほうがいいだろうし、知らない問題解決方法を知った方がたいていは速く終わるのだが……。

もう不毛な話をするのはやめたらどうか。プログラマの存在を抜きにして一番速い言語なんてものは決められないし、学習コストや簡潔さのような他のパラメータ抜きにどのプロジェクトでも常に選ぶべき言語なんてものも存在しない。プロジェクトの規模や複雑さに関わりなく常に他の言語と比べて最高のパフォーマンスを発揮する言語は存在しないし、ライブラリの存在やコミュニティの広さに影響を受けない言語も存在しない。徐々にLLVMデファクトとしての地位を固めて来ている今、コンパイラのバックエンドの出来は少しずつ問題になりにくくなってきた。我々が考えるべきなのは、トレードオフの片方の端を無視してどれかの言語の方が常に良いと叫ぶことではない。

我々が考えるべきことは、自分の目的に沿ってそれぞれトレードオフのある複数の理想のうちどれを優先すべきかを考え、そしてその選択に最も適したコードを書き、可能なら言語にフィードバックを与えることだ。そして、経験を積んでどういうときにどういった目標が優先されるかを精度よく認識できるようにすることだろう。

-OfastはNaNチェックを無効化する

gccには-Ofastというオプションが存在する。これは、標準規格への適合性を犠牲にしてでも高速なバイナリを吐くモードだ。色々なことが犠牲になるが、NaNチェックも犠牲になる。

以下のコードを見て欲しい。

wandbox.org

#include <iostream>
#include <cassert>
#include <cmath>

int main()
{
    const double x = -std::log(-1.0);
    std::cout << "x = " << x << std::endl;
    std::cout << std::boolalpha;
    std::cout << "x is nan? " << std::isnan(x) << std::endl;
    std::cout << "x is finite? " << std::isfinite(x) << std::endl;
    assert(std::isfinite(x));
    assert(not std::isnan(x));
    return 0;
}
Start

x = nan
x is nan? false
x is finite? true

0

Finish

嘘をつくな。

ちなみに以下の通り、-O3ならチェックはする。

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

気になるのでちょっとアセンブリも見てみよう。

godbolt.org

check():
        mov     eax, 1
        ret

即値1を返していて草。

ちなみに以下の通り、引数によらずisfiniteが全てtrueに変換されている。

https://godbolt.org/z/22TJMu

テストコードのコンパイルの際は気をつけましょう。

      糸冬
---------------
 制作・著作 GCC

追記:

流石にちょっと雑かなと思ったので。-Ofast-ffast-mathというフラグを自動で立てるのだがこれはIEEE754に厳密には従わない(が概ね問題がなく高速な)コードを生成する。例えば浮動小数点数は足す順序を変えただけで少し結果が変わってしまったりするので、数式だと思えば意味が同じコードでもちょっと結果が変わったりするのだが、-ffast-mathを立てるとそういう最適化をしたりしなかったりする。

で、-ffast-mathはいくつかのフラグを一気に立てるためのメタなフラグで、このなかに-ffinite-math-onlyというフラグがある。これは、infNaNが出てこないと仮定して(ちょうどコンパイラがUBを利用するように)最適化を行うフラグで、当然諸々のチェックは取り除かれる。InfNaNも出てこないと思って最適化していいので、InfかNaNだったときの分岐を消すのは当然だ。

もう少し細かく話すと以上のような感じになっている。

gitbookのmathjaxプラグインが突如動かなくなる

どうやら昨日mathjaxがメジャーバージョンアップ(v3.0.0)したらしい。リリースノートによると、中身がモダンなテクニックを使って全て書きなおされているようだ。

This version is a complete rewrite of MathJax from the ground up using modern programming techniques, including Typescript, ES6 modules, Promises, and more.

圧倒的に多くのコードに依存されているであろうに、それでも一気に書きなおしてモダンな機能にスイッチする決断を下せるというのは素晴らしいことだと思う(特にTypescript!)。この調子で開発が続くなら、今後もコードの質の水準は高く保たれるのではなかろうか。今までレンダリングが遅かったりしてあまり好感度が高くなかったが、今回かなり上がった。

それはそれとして、gitbookのプラグインとしてのmathjaxはmathjax-node経由でmathjaxに依存している。依存経路が複雑だったのでまだちゃんと追えていないが、gitbookでmathjaxを使おうとするとバージョン3のコードが降ってきて、バージョン2時代のコードをロードしようとして落ちる。これは困った。

機能はリリース予定日だったので、ドキュメントページがそのために落ちてリリースが伸びるというのは耐え難い。ので、今回は強引にも程がある方法を取った。CircleCIでhtmlをビルドしてpushしているので、CircleCI上でgtibookがnpm経由で取ってきたmathjaxをrm -rfし(node_modules/mathjaxに入っていることは知っているので)、そこに自分でmathjaxのレポジトリをクローンして2.7.6にチェックアウトした。パッケージマネージャなんのその、自力ダウングレードだ!! 頑張って書き直したmathjaxには申し訳ないとは思っている。多分私がでかめのプロジェクトで「書きなおしていいよ」などと言われたらウキウキで参加すると思うので。

とりあえず、当座を凌ぐためならこれで何とかできる。後々、どこかのパッケージのバージョン指定が2固定になるか、あるいは追従して3以上固定になるまではこれでしのごう。たまにその辺りを調べるのを忘れないようにしないと、とても強引なコードが残ってしまう……。