32bit/64bit環境を判別するRubyコード + 解説
注意事項
- おそらくCRuby (いわゆるMRI) 限定です。3.1.2で確認しています。
- 環境構築が面倒だったため、実際には32bit環境でテストしていません。意図通りに動かなかったらすみません……
以下解説
doキーワードの多重性
RubyではLRパーサージェネレーターであるBisonを使いながらも、LR(1)とは思えない高度な構文をパースさせるために、字句解析器が非常に複雑な状態管理を行っています。見た目が同じトークンでも、Bisonに渡すときには状態に応じて異なるトークンとして認識させることがあります。
doキーワードもそのような多重性をもつトークンの例で、構文解析器からは以下の5種類のトークンとしてあらわれます:
- 通常の識別子
foo.do
- ラムダ式のdo-endブロック
-> do end
- 真のdo-endブロック
f g do end
- 偽のdo-endブロック
f do end
- whileのdo-end
while false do end
このようにdoの区別が必要な理由について、やや情報は古いですがRubyソースコード完全解説の第11章 状態つきスキャナが非常にわかりやすいのでそちらも参照してください。
真のdo-endブロックと偽のdo-endブロック
Rubyではメソッドに高々1個のブロックを渡すことができます。これに (&block
の形で渡すほかには) 波括弧ブロック { ... }
とdo-endブロック do ... end
の2種類があることはよく知られていますが、実はdo-endがさらに2種類に分かれます。
- 真のdo-endブロックは先行するメソッド呼び出し構文がコマンド (括弧を使わずに引数を渡すメソッド呼び出し) のときに発生します。
- それ以外の場合は偽のdo-endブロックになります。
偽のdo-endブロックは優先順位の観点からは { ... }
と同等に扱われます。
f g do end
# ^ これはfの引数だが、括弧がないので真のdo-endブロックである
f(g) do end
#^^^ これは引数リストが括弧で囲まれたものとして扱われるため、偽のdo-endブロックである (→実際には {} と同じ優先度で扱われる)
真のdo-endブロックと偽のdo-endブロックを区別するには、別の括弧メソッド呼び出しの中に入れてみるのが簡単です。
f(f g do end) # SyntaxError
f(f(g) do end) # OK
ビットスタック
真のdo-endブロックと偽のdo-endブロックは do
トークンの種類によって区別され、その do
トークンは先行するメソッド呼び出しの形態によって種類が決まります。そのために字句解析器は先行するメソッド呼び出しを何とかして区別する必要があります。
そのために必要な情報はLRスタックに入っているはずですが、パーサーはLRスタックを自由に触れるわけではないので簡単にはいきません。そこでBison側の情報に頼らずに独自に状態管理を実装しているようなのですが、メソッド呼び出しの中にさらに別の構文 (()
, []
, "#{}"
など) が入る可能性まで考えると単純な有限状態機械で管理することは難しく、括弧のネスト状態を自前で管理することになります。
そこでCRubyのパーサーではメモリ管理をケチって、VALUE型の整数をビットのスタックとみなしてそこで状態管理を行います。これを以下の目的で2本保持します:
- 真のdo-endブロックと偽のdo-endブロックの区別 (コマンド形式のメソッド呼び出し中はフラグを立てる)
- whileのdo-endと偽のdo-endブロックの区別 (whileの条件式中はフラグを立てる)
このスタックはビット演算で扱われ、溢れた分は破棄され0になります。つまり、この溢れ方を検出できればVALUEの長さ (=ポインタの長さ) がわかります。
優先度の違いを利用する
evalしてSyntaxErrorを検出するなどの方法をとることもできますが、それでは綺麗ではないので双方の条件で構文解析に通るような書き方を考えます。
まず、whileとの曖昧性を利用する場合、解釈次第で必要なendの個数が変わってしまうので、それを回収するためによりトリッキーな仕組みが必要になります。これは大変なので、真のdo-endと偽のdo-endの曖昧性のほうを使うことにします。
するとまず必要なのは優先度によって異なる結果に解釈される式です。これは以下のようにすれば作れます。
f g do end # f(g) do end
f g { } # f (g {})
スタックを追い出す
次に、パーサーを混乱させるためにスタックから必要な情報を追い出す方法を考えます。いくつかの括弧で同じことができますが、もっとも短くて綺麗なのは ()
をネストさせる方法です。
パーサーの状態を大きく変えないようにしながら、ネストした括弧をうまく挿入する必要があります。さいわい、先ほどの例は括弧関数呼び出しを挟んでも壊れません。
f g() do end # f(g()) do end
f g() { } # f (g() {})
あとは、この引数部で大量の括弧をネストさせます。こうすることでスタックからデータを追い出すことができます。
# たくさんネストさせるとdo-endが{}に化ける
f g(((((...))))) do end
ここで、32と64の中間 (48くらい) のネストにしておくことで、32bit環境と64bit環境で異なる結果を得ることができます。
構文の曖昧性から情報を取り出す
最後に、構文の曖昧性から情報を取り出します。
今回の例ではdo-endブロックが渡される相手 (f, g) が異なるため、f, gでそれぞれdo-endブロックを呼び出してあげれば2つの構文解析結果を区別することができます。
Discussion
Debian 11環境(ruby 2.7.4p191)で試したところきちんと動作しました。
Debian/Ubuntuならdebootstrapを使うと手軽に32bit chroot環境を作れます(カーネルは64bitのままで動く)。