コンテンツにスキップ

二つの実行

アプリは一つのファイルです。 それを二つのやり方で走らせ、その二つが同じプログラムであることが、若草の狙いのすべてです。 このページは、それぞれの実行が何でできていて、何を共有していて、どこが同じでないかの話です。

この二つは、Ruby の二つの実装です。 書いているあいだは CRuby、リリースすると spinel、つまり事前コンパイル方式の Ruby コンパイラです。 違いは四つの種類に分かれます。 走る前に断られるもの黙って違う答えを返すもの二つめの標準ライブラリ同じプログラムで速さが違うものです。

三つの Ruby

若草は独自の言語を定義していません。 書けるのは Ruby で、それが二段階に狭められ、そこにライブラリが足されています。

受け取る範囲
CRuby Ruby のすべて。書いているあいだ、アプリを動かしているのがこれです。
spinel 事前コンパイルできる範囲。evalmethod_missing、実行時に作るクラス、コンパイル時に見えない名前を使ったリフレクションは受け取りません。一覧はこの下にあり、完全な一覧はコンパイラ自身が持っています。
若草 spinel の範囲から、さらに五つの形を除いたもの。書き換えるビュー、繰り返しの中の要素のブロック、ブロックから書き換えるグローバル変数、ブロックでも書き下した proc でもないハンドラ、+ で伸ばす配列です。どれもコンパイルは通るのに振る舞いが変わるので、コンパイラが動く前に wakakusa check が断ります(若草が断る書き方)。

引き算のうえで若草が足すのは一つだけで、それは言語ではなくライブラリです。 画面を組み立てるメソッドと、runeverytasksqlite_exec などです。 クラスもブロックも require も標準ライブラリも、Ruby 自身のものです。

つまり若草のアプリは、Ruby の二つの実装がどちらも動かせる Ruby のプログラムです。 このページの残りは、その二つが何でできていて、どこが違うかの話です。

書いているあいだに走るもの

$ ruby -Ilib -Idoor/cruby app.rb

インタプリタ側の実行は、これだけです。 CRuby がそのファイルを読みます。 ロードパスに置くのは、若草の共有部分と、door を一つです。 door がエンジンを共有ライブラリとして開き、その関数を宣言します。 どちらの実行なのかを知っているのは、この door だけです。

ここでのアプリは本物の Ruby で、本物のインタプリタが動かしています。 クラスも、ブロックも、require も、標準ライブラリも、ごみ集めも、すべて CRuby のものです。

リリースしたときに走るもの

$ spinel -Ilib -Idoor/spinel --int-overflow=promote -c -o app.c app.rb
$ cc -O2 app.c libspinel_rt_mt.a libpixie_capi.a  -o app

同じ Ruby を spinel が C にコンパイルし、cc がそれをエンジンごとネイティブバイナリにします。 wakakusa translate は、その C を書き出すところで止まります。 中身は普通に読める C のファイルです。

コンパイルされたバイナリは、エンジンと、コンパイル済みの Ruby と、spinel のランタイムを含みます。 それ以外は、システム自身のライブラリしかリンクしません。

どちらも動かしているもの

エンジンは C ABI の向こうに一つだけあります。 crates/pixie-capi がそれで、中身は pixie のカーネルと、描画を受け持つ gpui です。 どちらの実行でも同じコードです。 インタプリタ側はそれを共有ライブラリとして開き、コンパイルされた側は静的なほうをリンクします。

やり取りできることは、わざと少なくしてあります。 要素は開き、番号で書き込み、閉じます。 ハンドラは door が配る番号です。 文字列の一覧は文字列の一覧として渡ります。 エンジンは Ruby のオブジェクトを持ちません。 だから同じ実装を、インタプリタからもコンパイル済みのバイナリからも、そのまま使えます。

その番号も手で書いてはいません。 elements.toml が唯一の表で、そこにすべての要素と、それぞれが取るキーワードと、その型と既定値があります。 tools/gen.rb がその表から、アプリが呼ぶ Ruby と、両側が数える番号と、エンジン側の定数を書き出します。 片側に分岐のないキーが残っていればテストが落ちます。 だから、ある要素が Ruby では一つの意味を持ち、描かれる側では別の意味を持つ、ということが起きません。

二つが同じでないところ

基準になるのは CRuby です。 二つが食い違ったとき、正しいのはインタプリタ側で、コンパイルされた側の不具合ということになります。 違いは四つの種類に分かれます。 今どの種類の話をしているかは、知っておくと役に立ちます。 気づかないまま影響が出るのは、そのうち一つだけだからです。

1. 走る前に断られるもの

プログラム全体をコンパイルするので、バイナリの中にインタプリタはいません。 インタプリタが必要な書き方は、コンパイル中にエラーになります。 何がだめかと、その行が出ます。 最初の wakakusa build で分かることであって、配ったあとに分かることではありません。

書き方 どうなるか
evalinstance_eval("…") 断る(ブロックを渡す instance_eval { } はコンパイルできる)
method_missing 呼ばれない。定義するとコンパイル時に警告が出る
名前を実行時に決める define_method リテラルの名前だけがコンパイルできる
ObjectSpaceTracePointset_trace_func 断る
オブジェクトとしての bindingcallcc 断る(リテラル名の binding.local_variable_get(:x) は使える)
refinement(refineusing 解決されない
Class.new(parent) { … }、クラス本体の外での Klass.include(M) 断る。クラスの構造はコンパイル時に固まる
methodsinstance_variables、名前を実行時に決める instance_variable_get 断る(リテラルの :@x は使える)
コンパイラが持っていないライブラリの require 実行時ではなく、コンパイルエラーになる
自作クラスを端とする Range オブジェクト クラス名を挙げてコンパイルエラー(x.clamp(lo..hi) は使える)

若草は、この上にさらに五つを足しています。 コンパイルは通るのに振る舞いが変わってしまう書き方です(若草が断る書き方)。

2. 黙って違う答えを返すもの

ゲートがあるのは、この種類のためです。 下の各行は、固定しているリビジョンで両方の実装を実際に走らせて確かめたものです。

書き方 CRuby コンパイルされた実行 代わりの書き方
繰り返しの中で作って、あとで呼ぶブロック 0,1,2 2,2,2。繰り返しの最後の値が見える 行に必要なものを引数に取るメソッド。ビューの中ではこの形自体を断る
整数の配列や Hash から読んだ nil との比較(xs[9] < 5 NoMethodError true。整数の入れ物から読んだ nil は整数の枠に収めた特別な値なので、比較すると答えが返る 読んだ値を先に確かめる。h.fetch(k, 0)nil?
整数に収まらない浮動小数点数、1e20.to_i 100000000000000000000 RangeError Float のまま持つか、変換の前に範囲を絞る
種を与えた Random ある数列 別の数列 生成器を自分で書く。移植した二つのゲームがそうしている
Exception#backtracecaller フレームの一覧 [](クラスとメッセージは正しい) メッセージを記録する

算術そのものはこの表にありません。 コンパイルされた実行は --int-overflow=promote でビルドしてあるので、整数は 64 ビットを越えても回り込まずに伸びます。 ローカル変数でも、フィールドでも、配列の中でも同じです。

3. 標準ライブラリは二つめの実装

書くのは FileJSONCSVTimeNet::HTTPEnumerable など、Ruby 自身のライブラリです。 これを若草のライブラリで置き換えてはいません。 ただし実際に答えているコードは、片方が CRuby のもの、もう片方がコンパイラ自身のランタイムとパッケージです。 そのため、揃っている範囲は同じではありません。

  • require が要るライブラリは、コンパイラが同梱しているものです。 base64csvdigesterbforwardablejsonnet/httpopenssloptparsepathnamesecurerandomsetstringiostrscantmpdiruri があります。 これ以外を require するとコンパイルエラーになります。
  • Time そのものは require なしで使えます。 require "time" が足す文字列の解析(Time.parseTime.strptime)はありません。
  • Net::HTTP は接続ごとに 1 リクエストです。 keep-alive もパイプラインも HTTP/2 もプロキシも cookie の保持もなく、リダイレクトは追わずに 3xx の応答として返ります。
  • openssl は外向きのクライアントに要る範囲だけで、証明書は OS の信頼ストアで検証します。
  • 文字列の符号化は UTF-8 と ASCII-8BIT だけです。

demo/stdlib.rbdemo/files.rbdemo/reader.rb は、アプリが実際に使う範囲でこの二つが同じ答えを返すことを、確かめておくために置いてあります。

4. 同じプログラムで、速さが違う

スレッドだけは、二つの振る舞いが違っていて、しかもどちらも間違っていません。 コンパイルされた実行のランタイムには、グローバルロックがありません。 計算を回す二本のスレッドは二つのコアで走り、ウィンドウは描き続けます。 CRuby はスレッドを一度に一本しか走らせないので、同じアプリが倍の時間かかり、処理が終わるまでウィンドウが止まります。

だから重い計算は、書いているあいだのほうが、リリースしたあとより悪く見えます。

そのスレッドがある時点までにどこまで進んでいるかも、二つの実行で一致しません。 片方はマシンの時計で動き、もう片方はスクリプトが進める時計で動くからです。 task があるのはそのためで、エンジンが答えを待つので、次の手順に進む前に二つの実行が同じところまで進みます。 動かし続けたい処理は普通の Thread にして、その結果はタイマーの側で拾います。

コンパイラ自身の一覧

ここに挙げたのは、若草のアプリが出会う範囲です。 全体の一覧はコンパイラ自身が持っています。 tools/spinel_setup.sh が取ってくる spinel のチェックアウト、~/.cache/spinel/<sha>/ の下にある docs/limitations.md です。 並べ方も同じで、事前コンパイルという方式そのものに由来するもの、今は限られているが直せるもの、意図して変えてあるものに分かれています。

時計

どちらの実行も、一つの時計で動きます。 ウィンドウではフレームが、スクリプトでは advance:<ms> がそれを進めます。 タイマーも、アニメーションも、ゲームのティックも、その一つの時計を読みます。 だからどれも、ゲートが待つものではなく、比べられるものになります。

残るのは何か

この仕組みそのものについて、確かめることは残りません。 残るのはアプリの側だけです。 それが wakakusa gate で、一つのスクリプト、二つの実行、記録を一バイトずつ比べます(検証と配布)。