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.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/RegexpMatch
cop · 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の実装などこの記事で述べていないコードもありますので、興味があればコードを覗いてみて下さい。