こんにちは。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.ymlにRubyのバージョンを記載する必要があります。
# 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 を先にお読みいただくと、この先をスムーズに読み解くことが出来ると思いますので、是非ご覧ください。
記事執筆時点のソースコードについて述べようと思います。後の変更でコードが書き換わっていたとしてもご容赦下さい。
- 該当コミット: Add new
Perfomance/RegexpMatchcop · bbatsov/rubocop@864531f - 主に見るソース: rubocop/regexp_match.rb at 864531f61634354570a6b4458cb599c4373659b7 · bbatsov/rubocop
Entry point
まずは、このCopが実行されるentry pointについて見ていこうと思います。
このCopでは、entry pointがon_ifとon_caseの2箇所あります。
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メソッドの実装を見てみましょう。
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?
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を参照している」かどうかを検出します。
では、具体的にコードを見ていきましょう。
- https://github.com/bbatsov/rubocop/blob/864531f61634354570a6b4458cb599c4373659b7/lib/rubocop/cop/performance/regexp_match.rb#L81-L88
- https://github.com/bbatsov/rubocop/blob/864531f61634354570a6b4458cb599c4373659b7/lib/rubocop/cop/performance/regexp_match.rb#L132-L169
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つのことをしています。
では、この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_searchはdef_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の実装などこの記事で述べていないコードもありますので、興味があればコードを覗いてみて下さい。