Ruby でリファクタリングをした際にテスト要否を判断したい
TL; DR
- rubocop の自動修正とかが正しいのか不安になる
- Ruby なら ISeq に差分があるか調べればよい
- line number, column number 問題は本体に手をいれておけばある程度乗り越えられる
前提
$ ruby -v ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin17] $ bundle exec rubocop -V 0.59.2 (using Parser 2.5.1.2, running on ruby 2.5.1 x86_64-darwin17)
モチベ
rubocop には自動修正機能がついているわけですが、実際に rubocop のソースを読んでいない身としては盲信するわけにもいかない、というつらいところがあります。日々活発に追加される cop の中にはもしかしたらバグっているのもあるかもしれません。そうするとプロダクションコードにえいやでいれるにはちょっと気がひけるところがあったりなかったりします1。
ですので上で紹介した記事のように、言語処理系がどのようにソースコードを捉えているのかという観点から auto correct による変更の安全性を確かめたいと思います。
コンセプト
Ruby script を実行する場合には ソースコード -> AST -> ISeq ( instruction sequence ) と変換されてから YARV が ISeq 実行するようになっています2。
ruby コマンドには --dump=insns
という便利なオプションがあり ISeq を(比較的)人間に読みやすい表現で確認することができます。今回はこの --dump=insns
の結果をもとに安全性を担保します。
$ ruby -e 'puts 1' 1 $ ruby -e 'puts 1' --dump=insns == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,6)>============================== 0000 putself ( 1)[Li] 0001 putobject_OP_INT2FIX_O_1_C_ 0002 opt_send_without_block <callinfo!mid:puts, argc:1, FCALL|ARGS_SIMPLE>, <callcache> 0005 leave
実践
簡単なケース
まずは以下のような簡単な例からいきます。
# frozen_string_literal: true class Foo def foo puts 1 end end
puts
と 1
の間に2スペース入ってますね。さて、この状態の --dump=insns
の結果は以下になります。
== disasm: #<ISeq:<main>@sample1.rb:1 (1,0)-(7,3)>====================== 0000 putspecialobject 3 ( 3)[Li] 0002 putnil 0003 defineclass :Foo, <class:Foo>, 0 0007 leave == disasm: #<ISeq:<class:Foo>@sample1.rb:3 (3,0)-(7,3)>================= 0000 putspecialobject 1 ( 4)[LiCl] 0002 putobject :foo 0004 putiseq foo 0006 opt_send_without_block <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache> 0009 leave ( 7)[En] == disasm: #<ISeq:foo@sample1.rb:4 (4,2)-(6,5)>========================= 0000 putself ( 5)[LiCa] 0001 putobject_OP_INT2FIX_O_1_C_ 0002 opt_send_without_block <callinfo!mid:puts, argc:1, FCALL|ARGS_SIMPLE>, <callcache> 0005 leave ( 6)[Re]
今回の本旨とは外れるので内容は飛ばします。 ruby --dump=insns sample1.rb | md5
とかして md5 hash をとっておくと eac204585dcd059cbf333c3f6681d59e
でした。
さて、rubocop に autocorrect してもらうとどうなるでしょうか。
$ bundle exec rubocop -a (snip)
diff --git a/iseq-diff/sample1.rb b/iseq-diff/sample1.rb index 97256e1..f847a3d 100644 --- a/iseq-diff/sample1.rb +++ b/iseq-diff/sample1.rb @@ -2,6 +2,6 @@ class Foo def foo - puts 1 + puts 1 end end
スクリプトの中身は変わっていますが、前後でハッシュ値が変わってないので YARV に渡される AST は変わらないことがわかります。
$ ruby --dump=insns sample1.rb | md5 eac204585dcd059cbf333c3f6681d59e
このケースはこれでおk
難しいケース
先程掲載された insns を見ていると謎の数値がいくつかでてきます。
sample1.rb:1 (1,0)-(7,3)
0000 putspecialobject 3 ( 3)[Li]
このうちいくつかはソースファイルにおける行番号・列番号を示しています。
そのため、以下のようなファイルでは --dump=insns
の結果が変わってしまいます。
# frozen_string_literal: true puts 1
$ ruby --dump=insns sample2.rb == disasm: #<ISeq:<main>@sample2.rb:1 (1,0)-(3,7)>====================== 0000 putself ( 3)[Li] 0001 putobject_OP_INT2FIX_O_1_C_ 0002 opt_send_without_block <callinfo!mid:puts, argc:1, FCALL|ARGS_SIMPLE>, <callcache> 0005 leave $ bundle exec rubocop -a (snip) $ git diff sample2.rb diff --git a/iseq-diff/sample2.rb b/iseq-diff/sample2.rb index 851def2..946ef90 100644 --- a/iseq-diff/sample2.rb +++ b/iseq-diff/sample2.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -puts 1 +puts 1 $ ruby --dump=insns sample2.rb == disasm: #<ISeq:<main>@sample2.rb:1 (1,0)-(3,6)>====================== 0000 putself ( 3)[Li] 0001 putobject_OP_INT2FIX_O_1_C_ 0002 opt_send_without_block <callinfo!mid:puts, argc:1, FCALL|ARGS_SIMPLE>, <callcache> 0005 leave
(1,0)-(3,7)
と (1,0)-(3,6)
で差異があります。これは (lineno,colno)
の組み合わせです。評価したブロックを構成する token について、の最初の token の開始位置と最後の token の終了位置が示されているので、こういった最後の token の終了位置がずれる変更については差分が出てしまいます。
また改行があったときには ( lineno)[LI]
のような表記が現れます。しかし行の変更はほとんどのケースにおいて意味がないので検知したくありません(後述)。そこで ruby コマンドによって --dump=insns
で表示される情報から lineno, colno の情報を抜いてしまうことにします。
以下コードリーディングしたときの雑な流れです。
- 起動時のオプションを判断して分岐がありそうなので
insns
で grep してDUMP_BIT(insns)
がそのフラグっぽいことを突き止める - ruby.c の中で
rb_io_write(rb_stdout, rb_iseq_disasm((const rb_iseq_t *)iseq));
がDUMP_BIT(insns)
を使った分岐の中にあるのでrb_iseq_disasm
が出力を形作る本体であろうと予想をつける rb_iseq_disasm_X
系の中でなんとなくそれっぽいところを変えていく。
実際の diff としてはこうなります。不要な [Li]
あたりのコメントアウトと、(lineno, colno)
を全部 (1, 1)
に置き換えの2点。
diff --git a/iseq.c b/iseq.c index 831a9e8109..a25b179178 100644 --- a/iseq.c +++ b/iseq.c @@ -1895,15 +1895,15 @@ rb_iseq_disasm_insn(VALUE ret, const VALUE *code, size_t pos, } } - { - unsigned int line_no = rb_iseq_line_no(iseq, pos); - unsigned int prev = pos == 0 ? 0 : rb_iseq_line_no(iseq, pos - 1); - if (line_no && line_no != prev) { - long slen = RSTRING_LEN(str); - slen = (slen > 70) ? 0 : (70 - slen); - str = rb_str_catf(str, "%*s(%4d)", (int)slen, "", line_no); - } - } +// { +// unsigned int line_no = rb_iseq_line_no(iseq, pos); +// unsigned int prev = pos == 0 ? 0 : rb_iseq_line_no(iseq, pos - 1); +// if (line_no && line_no != prev) { +// long slen = RSTRING_LEN(str); +// slen = (slen > 70) ? 0 : (70 - slen); +// str = rb_str_catf(str, "%*s(%4d)", (int)slen, "", line_no); +// } +// } { rb_event_flag_t events = rb_iseq_event_flags(iseq, pos); @@ -1965,11 +1965,11 @@ iseq_inspect(const rb_iseq_t *iseq) const rb_code_location_t *loc = &body->location.code_location; return rb_sprintf("#<ISeq:%"PRIsVALUE"@%"PRIsVALUE":%d (%d,%d)-(%d,%d)>", body->location.label, rb_iseq_path(iseq), - loc->beg_pos.lineno, - loc->beg_pos.lineno, - loc->beg_pos.column, - loc->end_pos.lineno, - loc->end_pos.column); + 1, // loc->beg_pos.lineno, + 1, // loc->beg_pos.lineno, + 1, // loc->beg_pos.column, + 1, // loc->end_pos.lineno, + 1); //loc->end_pos.column); } }
実行しないならば外部ライブラリは不要なので miniruby
で十分です。 make miniruby
してできたバイナリを用いて比較していきます。
$ path/to/miniruby --dump=insns sample2.rb | md5 3d80188a91c6d6c6c84f2520b77a43f0 $ rubocop -a (snip) $ git diff sample2.rb diff --git a/iseq-diff/sample2.rb b/iseq-diff/sample2.rb index 851def2..946ef90 100644 --- a/iseq-diff/sample2.rb +++ b/iseq-diff/sample2.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -puts 1 +puts 1 $ path/to/miniruby --dump=insns sample2.rb | md5 3d80188a91c6d6c6c84f2520b77a43f0
一致しましたね!一応手元ではもう少し難しそうな以下のようなファイルでも差分が出ないことを確認しています。
class Foo # todo: nya-n def no_body end def show(_mode) if _mode; puts :a else puts 1 || 2; end end end module Main end def Main.exec Foo.new.show(false) end Main::exec
diff --git a/iseq-diff/sample3.rb b/iseq-diff/sample3.rb index 1cd1af4..6b946e1 100644 --- a/iseq-diff/sample3.rb +++ b/iseq-diff/sample3.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true + class Foo - # todo: nya-n - def no_body - end + # TODO: nya-n + def no_body; end def show(_mode) - if _mode; puts :a else puts 1 || 2; end + _mode ? (puts :a) : (puts 1 || 2) end end @@ -14,4 +15,4 @@ def Main.exec Foo.new.show(false) end -Main::exec +Main.exec
課題
このアプローチにはいくつかの問題点があります。まず、Ruby スクリプトにおいて行番号は、意味のない情報ではありません。例えばみなさんが大好きな Tracepoint の line
イベントに影響があります。まあ Tracepoint をプロダクションコードで使うケースは稀だとは思いますが3。
しかし、ぱっと思いついたものでも __LINE__
についての影響は実際のアプリケーションにも影響がありそうです。
$ cat a.rb __LINE__ $ cat b.rb __LINE__ $ path/to/miniruby --dump=insns a.rb == disasm: #<ISeq:<main>@a.rb:1 (1,1)-(1,1)> (catch: FALSE) 0000 putobject 2[Li] 0002 leave $ path/to/miniruby --dump=insns b.rb == disasm: #<ISeq:<main>@b.rb:1 (1,1)-(1,1)> (catch: FALSE) 0000 putobject 3[Li] 0002 leave
__LINE__
は行番号に変換されますが、これはなんとASTの時点で NODE_LIT
に変換されています4。そのためディスアセンブルした結果を表示する箇所をいかにいじろうとも無力です。しかも実際にメタプロの現場では __LINE__
を使う書き方は頻出です。下記の記事が仕様と __LINE__
を使うメリットについてよくまとまっています。
また、実行時のバイナリと違うバイナリで比較して意味あんのか、という観点でも課題があります。
これから
ISeq を比較するという手法である限り、__LINE__
などの課題に対して有効な解決策はなさそうです。
一方でバイナリ違う問題については、 RubyVM::AST
を用いることで ruby
コマンドに手を入れなくても評価可能なのではないか?と思っています。未着手ですけど。「Ruby 本体で AST の等値性を保証してくんねーかなー」という気持ちもありますが、「等値性」がこの場合においては一般的ではないので、自分で 「 lexical な差異には目をつむって、ほかはなんとなくあってそうなの」という評価をするしかなさそうですね。
-
ゆーて大丈夫だとは思っていますが。現実的に払えるコストなら払っていいんじゃないの、という気持ち↩
-
このへんは『Rubyのしくみ Ruby Under a Microscope』 などをどうぞ。↩
-
事例があったら知りたいのでささよろです↩
-
--dump=pa
で確認可↩