この記事は エムスリー Advent Calendar 2018 11日目の記事です。
エンジニアリンググループのowlです。好きなマスコットはGopherくん、好きな言語はRuby! なので今回もRubyについてお話します。
ところでRubyistのみなさんはRubyKaigi 2018に行きましたよね? とても良いイベントでした。エムスリーもスポンサーとして参加していましたが覚えていますか?
さてRubyKaigiで最も盛り上がった発表はなんでしょうか。様々な発表が挙げられると思いますが、有力な候補の一つはイベントの最後に行われた TRICK 2018 (FINAL) でしょう。TRICKとはTranscendental Ruby Imbroglio Contest for rubyKaigiの略であり、『超絶技巧 Ruby 意味不明コンテスト in RubyKaigi』のことです。
このコンテストではまともに読むことが出来ない上に何の役にも立たないという前衛芸術のような傑作Rubyコードが綺羅、星の如く生み出されて来ました。
- 螺旋状にアニメーションしながら文字列を表示するQuine*1 (mame)
- 日本語でコードがどのように動作するのかを説明し、しかもその日本語がコメントではなく動作に必要なコードの一部であるQuine (yhara)
- 68文字で作られたテストフレームワーク(colin)
- 3Dのクリスマスツリーをアスキーアートで描写して回転させながら雪を降らせる(tompng) などなど
全ての投稿作はGithubで公開されているのでぜひ実行してみてください。 https://github.com/tric/trick2018
しかし、意外にもこれらの難読コードが丁寧に解説される機会は稀でした。そこで今回は難読Rubyコードを初心者にも理解できるように噛み砕いて解説したいと思います。
題材について
さて今回解説するコードがこちらです。
eval %w?_="";_ _=binding;l oop( )do $>< <"> >\s "if $/> _;p uts "=> \s%p "%[ __.e val (_+ =get s||exit!) ,_=""];rescu e(Ex cep tio n); put s""+ "\e [31 m%p \e[ 0m"% [$! ,_= ""] if/ d\se |ee /!~ "#$!"end?*""
ただのアスキーアート(文字の集合を絵に見せる技法)ではありません、実際に動くRubyコードになっています。TRICK 2018に投稿されたJan Lelis氏の入賞作「IRB」を今回は解説していきます。 (Github: https://github.com/tric/trick2018/tree/master/12-jan )
その内容はアスキーアートが表すとおり! 僅かこれだけの文字数でIRBの基本的な機能を再現します。入力内容が実行されるだけでなく、例外発生時には赤文字でその内容を表示し、複数行の入力もサポートしているという超絶技巧っぷり。
把握しよう: Ripper
整形の仕方も思いつかないような難読コードを読み解こうとした場合、まずインタプリタの気持ちになりましょう。どんなに難読化されているコードであっても当然ながらそれを実行するインタプリタやコンパイラはそれがどんなプログラムであるか、どんな順番で何を実行すればいいのかを認識しているのでその実行計画を眺めればコードの内容がわかってきます。
具体的にはRubyパーサー(構文解析器)の一つであるRipperを利用してコードからAST(抽象構文木)を作ることから始めていきましょう。
require 'ripper' code = DATA.read pp Ripper.sexp(code) __END__ eval %w?_="";_ _=binding;l oop( )do $>< <"> >\s (略)
たったこれだけの記述でLispのS式のようになったASTが得られます。
[:program, [[:command, [:@ident, "eval", [1, 4]], [:args_add_block, [[:binary, [:array, [[:@tstring_content, "_=\"\";_", [1, 17]], [:@tstring_content, "_=binding;l", [1, 30]], (中略) [:@tstring_content, "/!~", [9, 23]], [:@tstring_content, "\"\#$!\"end", [9, 30]]]], :*, [:string_literal, [:string_content]]]], false]]]]
これを眺めるとまずコードの大筋が eval([文字列を要素に持つ配列] * '')
という構造であることがわかりますね。 パーサーは静的解析をするだけなので最初にわかるのはここまでです。evalされる文字列については後で解説するとして先にこの時点で出てきたメソッドについて説明を加えます。
evalメソッドはご存知の方も多いと思います、みんなが大好きなはずです。与えられた引数の文字列をRubyのコードとして評価してくれます。Trickにおける混沌の根源であり、これによって非常に自由度の高いプログラミングが可能になります(その善悪はともかく)。
eval 'p "Hello,world."' => "Hello,world."
Rubyの%リテラルもそれ自体は誰でも知っているでしょう。['ab', 'cd', 'ef']と書く代わりに%w[ab cd ef]
などとする人は多いですね。意外に知られていないのはこの %リテラルの囲い文字が記号なら何でも使える という点です。例えば%w*ab cd ef*
や%w!ab cd ef!
と書いても同様に実行できます、このtrickでは「?」を囲い文字としているワケです。
なぜ囲い文字に「?」を選んだかですが、おそらく内部コードで使わない記号だからでしょう。例えば[]や!を使って囲おうとすると内部コード内で]
や!
が使われた時に閉じ記号だとパースされて面倒なことになります。そこで内部コードに入っていない(使用を避けた)?
が囲い文字として適しています。
%w?ab cd ef? => ["ab", "cd", "ef"]
最後の*演算子ですがこれはjoinの糖衣構文でarray * 文字列はarray.join(文字列)とほぼ同義です。つまり引数の文字列を間に挟んで要素の文字列を結合します。
['a', 'b', 'c'] * '-' => "a-b-c"
つまり、このTrickの大筋をよく使われる書き方に直すとこうなります。
# trick eval %w?inner-code inner-code inner-code? * "" # rewrite eval(['inner-code', 'innner-code', 'innner-code'].join)
「evalメソッド」「%リテラル」「配列を文字列に結合」はRubyにおける難読プログラミングの基本3点セットであり%w[]の内部ではどれだけスペースや改行を含めても文字列に結合した時点で無視されるのでこれらを使えばかなり容易にアスキーアートでコードを書くことが出来ます。
では、次に実際にevalされているコード文字列をRipperのS式から取り出してみましょう。
_="";__=binding;loop()do$><<">>\s"if$/>_;puts"=>\s%p"%[__.eval(_+=gets||exit!),_=""];rescue(Exception);puts""+"\e[31m%p\e[0m"%[$!,_=""]if/d\se|ee/!~"#$!"end
もちろん、これをそのままRubyで実行してもIRBになります。とはいえ、このままでは読みにくいので整形しましょう。実際に整形する際には、これを再度RipperでASTに変換したものを参考にしながら整形して読んでいきますが非常に長くなるので今回は割愛します。
_ = ""; __ = binding; loop() do $> << ">>\s" if $/ > _; puts "=>\s%p" % [__.eval(_ += gets || exit!), _ = ""]; rescue(Exception); puts "" + "\e[31m %p \e[0m" % [$!, _ = ""] if /d\se|ee/ !~ "#$!" end
非常に読みやすくなりましたね、既に「完全に理解した」方もいるかも知れません。最初の行は特に説明は必要ないと思います。この変数_はコンソールからの入力を保持するために使われ、適宜リセットされます。
保持しよう: Binding
_ = "";
__ = binding;
馴染みがないのが2行目のbindingですが、変数__にはKernel.#bindingによって生成されたBindingオブジェクトが格納されます。少しBindingについて解説しますが、非常にややこしいのでこの辺の説明は読み飛ばしても構いません。
まずBindingオブジェクトは一言でいえば「スコープを保存したもの」です。Bindingの用途でもっともわかりやすいのがローカル変数の利用でしょう。ローカル変数は通常であればそれが使われたスコープから移ってしまえばアクセスすることが出来ません。
しかし、bindingによってスコープを保存していればBindingオブジェクトを通じてそれらにアクセスすることが出来ます。実際にはローカル変数だけではなく、記録したスコープ内でアクセスできるものはどれもアクセス可能です。
そうそう、Railsで開発を行っている人の中にはbinding.pryという記述に覚えがある人もいるかもしれません。これもBindingオブジェクトを利用しているよく知られた例です。binding.pryは何をしているのでしょうか?
その前提知識としてpryメソッドを説明すると、pry gemは全てのオブジェクトにpryメソッドを提供します。 このメソッドが呼ばれると処理がいったん中断され、pryのREPL(対話型評価環境)が開かれます。このREPLではレシーバーのオブジェクトが持つメソッドや値を自由に参照したり実行することが可能です。
require 'pry' 'My name is Ozymandias.'.pry
[1] pry("My name is Ozymandias.")> length => 22 [2] pry("My name is Ozymandias.")> upcase => "MY NAME IS OZYMANDIAS."
ではこのメソッドをローカル変数などをスコープごと持っているBindingオブジェクトをレシーバにして呼び出すとどうでしょう? まさにそれがbinding.pryであり、保存されたスコープ時点で実行可能なことは何でもできるREPLが開かれます。こうして非常に便利なデバッグツールが使えるというワケです。
ということで、そんな便利なBindingオブジェクトと便利なevalメソッドを組み合わせれば任意のスコープで任意の処理を実行できます。ちなみにbinding.eval('puts "Hi!"')
ともeval('puts "Hi!"', binding)
とも実装できます。
そうそう、そもそもどうしてBindingがここで必要なのか?という疑問についても解説を加えると、loopブロック内で繰り返される入力&評価の度にスコープが異なるため前の入力で宣言した変数を次の入力で使う、ということが出来ないからです。そこでloopの外でBindingを作っておき共有しているというワケです。
縮めよう: Golf
続いてloop do 〜 end
内の処理の要約を説明しますが、これはとてもシンプルです。
- 「標準入力から実行すべきコードを受け取りevalして結果を表示する」
- 「もしもevalで例外エラーが発生した場合には赤文字でエラー内容を表示する」
$> << ">>\s" if $/ > _;
まず$>
ですがこれは標準出力($stdout
)の別名です。<<メソッドでは引数の文字列を出力します。\sは半角スペースなので画面上に>>
という出力を表示します。キーボードから入力する際のプロンプト(入力位置を示すデザイン)になる部分ですね。
またこの部分にはif文がついており標準入力の内容が格納される変数_
と$/
を比較しています。$/
は入力の区切りを表す文字列で初期値は改行コードになっています。例えばキーボードから入力してEnterで改行コードを入力したところで入力の区切りになるというワケです。
そもそも文字列の比較とはなんだ?と感じる方に説明するとStringについて実装されている比較演算は辞書順の比較であり例えば'a' < 'b' => true
です。
……というのが一般的な説明なのですが、この説明だと今回のような難読コードを理解することは出来ないので補足説明します。辞書順と書きましたがより正確にはバイト列の比較を行っています。
irb> %w[r u b y ! ? ].map{ |ch| [ch, ch.ord] } => [["r", 114], ["u", 117], ["b", 98], ["y", 121], ["!", 33], ["?", 63]] irb> %w[r u b y ! ? ].sort.map{ |ch| [ch, ch.ord] } => [["!", 33], ["?", 63], ["b", 98], ["r", 114], ["u", 117], ["y", 121]]
ちなみにString.#ord
は文字列の先頭文字に対してコードポイントを得るメソッドです。より詳細な説明を加えた理由はこの前提知識が「なぜ改行コードの比較$/ > string
が_!=""
と等価になるのか」を理解するのに必要なためです。
重要なのはキーボードから直接入力が普通できない特殊文字(そう、例えば改行コードですね!)は通常の文字よりも先にナンバリングされているということです。そのためキーボードから入力されるどんな通常文字よりも改行コードは辞書順で先になります(改行コードよりも先になる文字はタブコードなどの特殊文字だけ)。だから$/ > string
は_!=""
とほぼ等価だと言えるのです。
さて、そもそも等価なら何故わざわざ_!=""
ではなく$/>_
を使うのか? 答えは短いからです。いえ本当に1文字分少なく書けるから、というのが理由です。
こういったgolf的な努力によってこのコードの長さは200文字にも満たない短さになっています。ちゃんと詰めれば140文字以下になるそうです。print
の代わりに$><<
が使われていたのも同様にコードが1文字分だけ節約できるというのが理由です。
puts "=>\s%p" % [__.eval(_ += gets || exit!), _ = ""];
次の行も少し見慣れない構文が使われていますが、これはいわゆる書式付き出力です。Rubyには書式付き出力を行うやり方がsprintfメソッドと%演算子の二通りあります。日付フォーマットや0字詰めで利用したことがある人が多いでしょうか。ここで使われている%pはどんなフォーマットかというと、引数のObject#inspectを展開します。
record = { name: "Alice", age: 12} => {:name=>"Alice", :age=>12} "Record: %p" % record => "Record: {:name=>\"Alice\", :age=>12}"
では展開される引数のほうに注目していきます。
[__.eval(_ += gets || exit!), _ = ""]
_ += gets
で標準入力を変数_へ格納しつつevalで評価します。
これで配列のinspectが展開されることになるワケですが、2つ目の要素である_=""
は単に空文字を返すので表示されず実質的にevalの評価結果だけが表示されます。また入力が切れgetsにnilが来た場合はexit!でプログラムは停止します。
小さな正規表現、大きな謎
rescue(Exception); puts "" + "\e[31m %p \e[0m" % [$!, _ = ""] if /d\se|ee/ !~ "#$!" end
そろそろ終わりが見えてきました。このrescue内の例外時処理ですが、やっていることは先程とあまり変わりません。まず$!
は例外発生時にExceptionオブジェクトが格納される変数です。
evalで例外が発生した場合は評価順によりeval後の要素で行っている変数_
の初期化が実行されないためrescue内で初期化してあげる必要があります。
エラーの内容はコンソール上で赤文字で表示するために制御文字\e[31m
を出力した後、例外表示した後はまた通常の文字色に戻すため\e[0m
を出力します。
if /d\se|ee/ !~ "#$!"
このif文ですが、まずは大筋の話から。やっていることは単純で例外エラーのメッセージが特定の正規表現パターン('d e'または'ee'を含む)にマッチする場合は例外として扱わずに、変数_の初期化やエラー表示をスキップします。
!~
は正規表現と文字列を比較してマッチしない場合にtrue、する場合にfalseを返します。"#$!"
はto_s
と同義でExcpetionオブジェクトを正規表現マッチングするために文字列化しています。
実はrubyでは""ダブルクォートによる文字列リテラルの中で式展開を行うとき、その式が@や$のような変数であれば{}を省略できるというルールがあります。言うまでもなくこれもgolfで使われる手法の一つです。
@user = "Alice" puts "#@user" => Alice
では、最後の謎ですがこの/d\se|ee/
という正規表現は一体なんでしょうか? なぜ、特定の例外エラーをスキップする必要があるのでしょう? 謎です。
この部分を読み解くのにしばらく首をかしげていましたが、コードに添えられた説明文を読んでいる時にようやく閃きました。説明文にはこうあります、『The IRB supports multiline input, including =begin..=end comments and heredocs』。つまり=begin/=endなどを含む複数行の入力に対応する、と書かれています。
実はこのtrickはオリジナルのIRBと全く同じように複数行の入力にも対応しています。技巧!これを念頭に入れれば/d\se|ee/
の正規表現が何を想定しているのかがわかりますね。
そうです!
「unexpecte"d e"nd-of-input」 と「embedded document m"ee"ts end of file」 の2つです!
つまりdoやBEGIN、=begin、ifなどセットになるend相当を持つべき文法がend相当なしに使われた場合に発生するエラーですね。rescue内ではこれらの複数行入力の途中と思われる例外エラーだけは処理せずに、そのまま引き続き入力を受け付けるために書かれたif文でした。
「例外エラーが[unexpected end-of-input]あるいは[embedded document meets end of file]ではない場合」という条件を極限まで短くしたのがこの/d\se|ee/!~"#$!"
です。Golf!
(ちなみに、覚えていないだけでRubyKaigi2018の会場で解説時にこの正規表現への言及もされていたようです)
最後に
もしもTRICKに興味がわいたらTRICK上位賞の常連であり運営もされているmame(遠藤侑介)氏の著作『あなたの知らない超絶技巧プログラミングの世界』をお勧めします。文中のコードも7割くらいRubyで書かれているためRubyistにはとても読みやすくなっています。
そして読むだけでなく、実際に書いてみるとどれくらい難しいのかを更に痛感することができるでしょう。
eval %w!h =45;defin e_method('p t'){|s|s .chars{| c|print(c);slee p(0.1)}} ;w='Medici ne_Med ia_Met amorphosis '.chars.map {|c|[c ,0]};n= [*0..2 7].sh uffle; (h+28) .times {puts(" \033[2 J");$ stdout .flush ;w[n.pop ][1]+= 1if(n.si ze>0); w.select{|c,m|m >0}.ea ch.wit h_inde x{|c,i|print(" \033[# {c[1 ]+=1}; #{i+6}H# {c[0]} ") };slee p(0.1) ;};print ("\e[0 ;31m" );[6,1 5,21]. each{|q |print ("\033 [#{h}; #{q}HM") };prin t("\03 3[#{h};#{40}H");pt ('M3,I nc');p rint("\033[#{1 0};#{8}H" );pt("We' reHiring,H aveaMerryChristmas.");print("\033[#{h};0H\e[0;0m");sleep(10);!*''
We are hiring
エムスリーでは半数近くのプロジェクトでRuby(rails)が活躍しています、共に医療 × テクノロジーの未来を切り拓いてくれる仲間を募集中です!
*1:Quine: 自分自身のコードと全く同じ文字列を表示するコード