pockestrap

Programmer's memo

Performance/RegexpMatch Cop の概要と実装

こんにちは。id:Pocke です。

先日、RuboCop に Performance/RegexpMatch という Cop が追加されました。 Add new Performance/RegexpMatch cop by pocke · Pull Request #3824 · bbatsov/rubocop このCopは、Ruby 2.4 で追加された match? メソッドに対応するものです。

尚、2016/12/27現在このCopは未リリースです。

この記事ではPerformance/RegexpMatch Cop が問題とするRuby 2.4の新機能について触れた後、このCopの機能概要と実装について述べようと思います。

match? メソッドとは

では、match?メソッドとは何でしょうか?

このメソッドは、Regexp, String, Symbolクラスに追加されたものです。 以前からRegexp#matchなどの?がないメソッドは各クラスに存在しました。

新たにmatch?が追加された理由には、パフォーマンス上の問題があります。 Regexp#matchなどのメソッドはMatchDataオブジェクトを生成しますが、それを使用しない場合はオブジェクトを生成する時間が無駄な時間になってしまいます。

if match = /re(gexp)/.match(foo)
  do_something(match[1])
end

例えば、上記のコードはRegexp#matchの結果をmatch変数に格納しています。 このmatch変数の値はMatchDataオブジェクト(もしくはnil)になり、マッチした結果を保持しています。 この例では、正規表現のsub matchをdo_somethingメソッドに渡していることになります。

ですが、次の例ではMatchDataを使用していません。

if /re(gexp)/.match(foo)
  do_something
end

このような場合、matchメソッドの結果は真偽値のみで充分なはずです。そして、その役割をmatch?が担っています。 Ruby 2.4では、上記の例はmatch?メソッドを使って以下のように書き換えることが出来ます。

if /re(gexp)/.match?(foo)
  do_something
end

参考: サンプルコードでわかる!Ruby 2.4の新機能と変更点 - Qiita

RuboCop とは

Ruby の Linter です。 詳しくは bbatsov/rubocop: A Ruby static code analyzer, based on the community Ruby style guide.

Performance/RegexpMatch とは

先述した通り、Performance/RegexpMatchは、match?メソッドに対応するCop(Copとは、RuboCop用語でルールを示します)です。 このCopは先程の様な不必要にmatchメソッドを使っているコードを検出/修正します。

実例を見てみましょう。先程のコードをRuboCopで解析すると、以下のような警告が出ます。 尚、このCopはRuby 2.4以上で動作するため、.rubocop.ymlRubyのバージョンを記載する必要があります。

# test.rb
if /re(gexp)/.match(foo)
  do_something
end
# .rubocop.yml
AllCops:
  TargetRubyVersion: 2.4
$ rubocop --only Performance/RegexpMatch
Inspecting 1 file
C

Offenses:

test.rb:2:4: C: Use match? instead of match when MatchData is not used.
if /re(gexp)/.match(foo)
   ^^^^^^^^^^^^^^^^^^^^^

1 file inspected, 1 offense detected

このように、match?メソッドを使用する様警告が出ます。 そして、MatchDataを参照しているような場合には警告を出しません。

また、このCopはAuto-Correctに対応しているため、-aオプションを付与してRuboCopを実行することで、コードを自動的に修正することが可能です。

$ rubocop --only Performance/RegexpMatch -a
Inspecting 1 file
C

Offenses:

test.rb:2:4: C: [Corrected] Use match? instead of match when MatchData is not used.
if /re(gexp)/.match(foo)
   ^^^^^^^^^^^^^^^^^^^^^

1 file inspected, 1 offense detected, 1 offense corrected


$ git diff
diff --git a/test.rb b/test.rb
index 6d73a78..af34863 100644
--- a/test.rb
+++ b/test.rb
@@ -1,4 +1,4 @@
 # test.rb
-if /re(gexp)/.match(foo)
+if /re(gexp)/.match?(foo)
   do_something
 end

Performance/RegexpMatchの実装

では、このCopの実装について見ていきたいと思います。 単純に実装するのであればon_send内でメソッド名がmatchであるかをチェックするだけで良いのですが、このCopではfalse positiveを防ぐためいくつかの工夫がなされています。

なお、RuboCopの実装に不慣れな方は RuboCop の Cop の実装について - Qiita を先にお読みいただくと、この先をスムーズに読み解くことが出来ると思いますので、是非ご覧ください。

記事執筆時点のソースコードについて述べようと思います。後の変更でコードが書き換わっていたとしてもご容赦下さい。

Entry point

まずは、このCopが実行されるentry pointについて見ていこうと思います。 このCopでは、entry pointがon_ifon_caseの2箇所あります。

https://github.com/bbatsov/rubocop/blob/864531f61634354570a6b4458cb599c4373659b7/lib/rubocop/cop/performance/regexp_match.rb#L90-L106

def on_if(node)
  return if target_ruby_version < 2.4

  cond, = *node
  check_condition(cond)
end

def on_case(node)
  return if target_ruby_version < 2.4

  case_cond, = *node
  return if case_cond
  when_clauses(node).each do |when_node|
    cond, = *when_node
    check_condition(cond)
  end
end

if式、case式内の条件文の部分をRegexpMat#check_conditionに渡していることがわかると思います。

つまり、x = /re/.match("foo")のようなコードは最初からターゲットにはなっていません。

check_condition

では、次にcheck_conditionメソッドの実装を見てみましょう。

https://github.com/bbatsov/rubocop/blob/864531f61634354570a6b4458cb599c4373659b7/lib/rubocop/cop/performance/regexp_match.rb#L124-L130

def check_condition(cond)
  match_node?(cond) do
    return if last_match_used?(cond)
    add_offense(cond, :expression,
                format(MSG, cond.loc.selector.source))
  end
end

condが以下の2条件を満たしている場合に、add_offenseメソッドを呼び出していることがわかると思います。

  • match_node?(cond)の結果がtrueである
  • last_match_used?(cond)の結果がfalseである

なお、add_offenseは対象のnodeにoffense(offenseとは、RuboCop用語でコードへの警告のことを示します)があることを追加するメソッドです。 つまり、このメソッドがCopとしての一つのゴールです。

この条件を一つづつ見ていきましょう。

match_node?

https://github.com/bbatsov/rubocop/blob/864531f61634354570a6b4458cb599c4373659b7/lib/rubocop/cop/performance/regexp_match.rb#L51-L74

def_node_matcher :match_method?, <<-PATTERN
  {
    (send _recv :match _)
    (send _recv :match _ (:int ...))
  }
PATTERN

def_node_matcher :match_operator?, <<-PATTERN
  (send !nil :=~ !nil)
PATTERN

def_node_matcher :match_with_lvasgn?, <<-PATTERN
  (match_with_lvasgn !nil !nil)
PATTERN

MATCH_NODE_PATTERN = <<-PATTERN.freeze
  {
    #match_method?
    #match_operator?
    #match_with_lvasgn?
  }
PATTERN

def_node_matcher :match_node?, MATCH_NODE_PATTERN

少し長いですが、match_node?メソッドの定義はこのコードの一番下にあります。

match_node?メソッドは、def_node_matcherを使用して定義されています。

def_node_matcherに使用されているMATCH_NODE_PATTERN定数の中身は以下のようになっています。

{
  #match_method?
  #match_operator?
  #match_with_lvasgn?
}

NodePatternに慣れない方の為に解説をすると、この{}というパターンは or を表しており、中にあるパターンのいずれかにマッチすればパターン全体がマッチすることになります。
また、#...というパターンはメソッド呼び出しであり、対応するメソッドに Node を渡して呼び出した結果がtrueであれば、パターンにマッチすることになります。

(NodePatternを詳しく知りたい方は、rubocop/node_pattern.rb at master · bbatsov/rubocop を読むと良いと思います。)

さて、上記を踏まえてこの matcher 定義を見ると、match_node?trueになるのはmatch_method?match_operator?match_with_lvasgn?がtrueになる場合、ということがわかると思います。

また、詳しい説明は省略しますがこの3つの matcher は以下の場合に true を返します。

  • #match_method?
    • foo.match(/re/)
    • foo.match(/re/, 1)
  • match_operator?
    • foo =~ /re/
    • re =~ "foo"
  • match_with_lvasgn?
    • /re/ =~ foo

つまり、matchメソッドの呼び出し、もしくは=~演算子の呼び出しをしている場合にtrueが返ります。

さて、賢明な読者であればここまでのコードのみで先程の例に警告を出すことが可能であることがわかると思います。

# - if の条件文内に => `on_if`に該当
# - `match` メソッドの呼び出しがある => `match_method?`に該当
if /re(gexp)/.match(foo)
  do_something
end

ですが、このCopにはもう一つの条件、last_match_used?があります。これは何をしているのでしょうか? 次の章ではこのメソッドの働きについて見ていきます。

last_match_used?

以下のいくつかのコードは同じ動きをします。

# その1
if match = /re(gexp)/.match(foo)
  do_something(match[1])
end

# その2
if /re(gexp)/.match(foo)
  do_something(Regexp.last_match[1])
end

# その3
if /re(gexp)/.match(foo)
  do_something($~[1])
end

# その4
if /re(gexp)/.match(foo)
  do_something($1)
end

なんと、matchメソッドの結果を明示的に変数に代入しなくても使えてしまうのです!

このlast_match_used?メソッドでは、上記のように「変数に明示的にMatchDataを代入はしていないが、グローバル変数経由でMatchDataを参照している」かどうかを検出します。

では、具体的にコードを見ていきましょう。

def_node_search :search_match_nodes, MATCH_NODE_PATTERN

def_node_search :last_matches, <<-PATTERN
  {
    (send (const nil :Regexp) :last_match)
    (send (const nil :Regexp) :last_match _)
    ({back_ref nth_ref} _)
    (gvar #dollar_tilde)
  }
PATTERN


# 中略



def last_match_used?(match_node)
  scope_root = scope_root(match_node)
  body = scope_root ? scope_body(scope_root) : match_node.ancestors.last
  match_node_pos = match_node.loc.expression.begin_pos

  next_match_pos = next_match_pos(body, match_node_pos, scope_root)
  range = match_node_pos..next_match_pos

  find_last_match(body, range, scope_root)
end

def next_match_pos(body, match_node_pos, scope_root)
  node = search_match_nodes(body).find do |match|
    match.loc.expression.begin_pos > match_node_pos &&
      scope_root(match) == scope_root
  end
  node ? node.loc.expression.begin_pos : Float::INFINITY
end

def find_last_match(body, range, scope_root)
  last_matches(body).find do |ref|
    ref_pos = ref.loc.expression.begin_pos
    range.cover?(ref_pos) &&
      scope_root(ref) == scope_root
  end
end

def scope_body(node)
  node.children[2]
end

def scope_root(node)
  node.each_ancestor.find do |ancestor|
    ancestor.def_type? ||
      ancestor.class_type? ||
      ancestor.module_type?
  end
end

def dollar_tilde(sym)
  sym == :$~
end

長いですね。 まずはlast_match_used?メソッドについて見ていきましょう。 このメソッドは大きく分けて3つのことをしています。

  • グローバル変数が有効であるスコープを取得する
  • 対象のスコープ内で、matchメソッドが次に呼び出される位置を取得する
  • 指定する範囲の中でグローバル変数が使われているか検査する

では、この3つのことについて詳しく説明します。

グローバル変数が有効であるスコープを取得する

MatchDataを格納するグローバル変数のスコープは、少し変わった動きをします。 これらの変数のスコープはローカルスコープですが、通常のローカル変数と違いブロックの終了で変数が死にません。

ローカルスコープ 通常のローカル変数と同じスコープを持ちます。つまり、 class 式本体やメソッド本体で行われた代入はその外側には影響しません。 プログラム内のすべての場所において代入を行わずともアクセスできることを除いて、通常のローカル変数と同じです。 https://docs.ruby-lang.org/ja/latest/doc/spec=2fvariables.html#global

例を見ましょう。

def foo
  tap do
    /x/ =~ 'x'
    p $~ # => #<MatchData "x">
  end
  p $~ # => #<MatchData "x">
end

foo

p $~ # => nil

fooメソッドの実行結果を見ると、$~がブロックのendでは初期化されず、メソッドのendになって初めて初期化されていることがわかると思います。

このCopでは、上記のスコープに対応するため

  • メソッド定義
  • クラス定義
  • モジュール定義

のどれかに遭遇するまでASTを上に辿り、最初に見つかった定義箇所をそのグローバル変数のスコープとしています。 それを行っているのがscope_rootメソッドです。

def scope_root(node)
  node.each_ancestor.find do |ancestor|
    ancestor.def_type? ||
      ancestor.class_type? ||
      ancestor.module_type?
  end
end

対象のスコープ内で、matchメソッドが次に呼び出される位置を取得する

以下のようなコードを考えてみましょう。

def foo
  if x.match(/re/)
    do_something
  end
  if x.match(/rerere/)
    do_something2($~)
  end
end

このコードでは、最初のmatchメソッドの呼び出しはmatch?に置き換えられることがわかると思います。 一方、2つ目のmatchメソッドの呼び出しは$~を参照しているため、match?に置き換えることが出来ません。

この様なケースでも正しくCopを動かすには、検査対象のmatchメソッドの次に呼び出されるmatchメソッドの位置を把握し、検査対象のmatchとその次のmatchの間にグローバル変数があるかをチェックする必要があります。 2つ目のmatchメソッド以降にあるグローバル変数は無視しなければなりません。

それを行っているのが、next_match_posメソッドです。

def next_match_pos(body, match_node_pos, scope_root)
  node = search_match_nodes(body).find do |match|
    match.loc.expression.begin_pos > match_node_pos &&
      scope_root(match) == scope_root
  end
  node ? node.loc.expression.begin_pos : Float::INFINITY
end

このメソッドは、検査対象のmatchメソッドより後にmatchメソッドの呼び出しがあればその位置を、なければ便宜上無限大の値を返しています。

この実装には、search_match_nodesというメソッドが使用されています。 これはdef_node_searchというメソッドによって定義されたメソッドです。

MATCH_NODE_PATTERN = <<-PATTERN.freeze
  {
    #match_method?
    #match_operator?
    #match_with_lvasgn?
  }
PATTERN

def_node_search :search_match_nodes, MATCH_NODE_PATTERN

def_node_searchdef_node_matcherと似ています。 def_node_matcherで定義されたメソッドは、渡されたNode自体がパターンにマッチするかを検査します。 対して、def_node_searchで定義されたメソッドは渡されたNode以下に存在する、パターンにマッチするNodeのリストを返します。

このメソッドを使用して、next_match_posは実装されています。

指定する範囲の中でグローバル変数が使われているか検査する

さて、これが最後になります。 今まで説明していたコードで手に入った「スコープの範囲」と「グローバル変数が有効な範囲」を使用して、その範囲内で対応するグローバル変数が使用されているかを検査します。

def find_last_match(body, range, scope_root)
  last_matches(body).find do |ref|
    ref_pos = ref.loc.expression.begin_pos
    range.cover?(ref_pos) &&
      scope_root(ref) == scope_root
  end
end

また、このfind_last_matchメソッド内で使用されているlast_matchesメソッドは、先程説明したdef_node_searchで定義されたメソッドです。

def_node_search :last_matches, <<-PATTERN
  {
    (send (const nil :Regexp) :last_match)
    (send (const nil :Regexp) :last_match _)
    ({back_ref nth_ref} _)
    (gvar #dollar_tilde)
  }
PATTERN

ここまで読んでいただいた方なら、このパターンの意味するところはなんとなく理解できると思います。


以上でこのCopのコードの説明を終わります。 autocorrectの実装などこの記事で述べていないコードもありますので、興味があればコードを覗いてみて下さい。