Ruby でリファクタリングをした際にテスト要否を判断したい

TL; DR

  • rubocop の自動修正とかが正しいのか不安になる
  • Ruby なら ISeq に差分があるか調べればよい
  • line number, column number 問題は本体に手をいれておけばある程度乗り越えられる

前提

tech-blog.monotaro.com

qiita.com

$ 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

puts1 の間に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 の情報を抜いてしまうことにします。

以下コードリーディングしたときの雑な流れです。

  • 起動時のオプションを判断して分岐がありそうなので insnsgrep して 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__ を使うメリットについてよくまとまっています。

pocke.hatenablog.com

また、実行時のバイナリと違うバイナリで比較して意味あんのか、という観点でも課題があります。

これから

ISeq を比較するという手法である限り、__LINE__ などの課題に対して有効な解決策はなさそうです。

一方でバイナリ違う問題については、 RubyVM::AST を用いることで ruby コマンドに手を入れなくても評価可能なのではないか?と思っています。未着手ですけど。「Ruby 本体で AST の等値性を保証してくんねーかなー」という気持ちもありますが、「等値性」がこの場合においては一般的ではないので、自分で 「 lexical な差異には目をつむって、ほかはなんとなくあってそうなの」という評価をするしかなさそうですね。


  1. ゆーて大丈夫だとは思っていますが。現実的に払えるコストなら払っていいんじゃないの、という気持ち

  2. このへんは『Rubyのしくみ Ruby Under a Microscope』 などをどうぞ。

  3. 事例があったら知りたいのでささよろです

  4. --dump=pa で確認可