カスタムCopでリファクタリング

RuboCopのカスタムCopを書いてリファクタリングを行う話として、丁度良い事例があったので紹介します。

改善したいコード

仕事先のRailsアプリを眺めてみると、昔から慣習的に次のようなコードが書かれていることが分かりました。

module A
  extend ::ActiveSupport::Concern

  included do
    def foo
    end

    def bar
    end
  end
end

本来は、特別な理由が無い限り次のように書かれるべきコードです。

module A
  def foo
  end

  def bar
  end
end

これは後から分かったことですが、このようなコードはファイル数で言うと数百件、メソッド定義数で言うと千件弱あるようでした。

用意したカスタムCop

そこで、RuboCopのカスタムCopを書いて、このコードを自動修正することにしました。詳しい書き方についてはここでは深入りしませんが、カスタムCopは、別にGem等にパッケージングしなくとも、適当なファイルを置いて読み込めば使えるし、適当に設定すればテストも書けるので、一般的に想像されるよりも気軽に書けるものです。

完成したものを、後になって雑Cop集であるSevencopに追加して公開したものがこちらです。

さて、このCopは冒頭のコードをおおよそ次のような形に変換します。

module A
  extend ::ActiveSupport::Concern

  public def foo
  end

  public def bar
  end

  included do
  end
end

具体的な実装としては、included の中にメソッド定義を見つけるたびに、included の直前にそれを移動するようになっています。直前ではなく直後に入れる実装だと、上から処理していった場合、結果的に定義順序が逆順になってしまうので、直前を選んでいます。また、可視性が変わらないような配慮を手短に実現するために、インラインなスタイルで記載することで元々の可視性を維持するようにしました。

このCopによる自動修正だけでは、冒頭に紹介した理想的なコードと異なり、可視性に関する部分が残ってしまいますが、これは Style/AccessModifierDeclarations というCopで別途適切に自動修正されるので、最終的にはおおよそ理想的なコードになります。Style/AccessModifierDeclarations は最近まで自動修正に対応していませんでしたが、こういう用途等で便利に使えるかと思い、夏頃になんとなく対応しておいたのが功を奏しました。

また、元々メソッド定義しか含まれていなかった included については、上のように変換後に空っぽのまま残ってしまいます。これは別途、空の included を削除するカスタムCopを適当に書いて消し込みました。

自動修正してはいけないパターン

さて、これで全てが万事解決したわけではありません。冒頭で説明した二つのコードは、include してみると明らかに挙動が異なります。

module A
  extend ::ActiveSupport::Concern

  included do
    def foo
    end

    def bar
    end
  end
end

class B
  include A
end

上のコードの場合、B#fooB#bar が定義されます。

module A
  def foo
  end

  def bar
  end
end

class B
  include A
end

一方、上のコードの場合、A#fooA#bar が定義されます。

そのため、クラス側にメソッドを定義されることを(書いた本人がそれを意図しているかどうかはさておき)利用しているパターンは自動修正せず、利用していないパターンだけを自動修正する必要があります。

この挙動の利用方法には、二つのパターンがあります。

パターン1: クラスに定義されたメソッドを上書きするもの

module A
  extend ::ActiveSupport::Concern

  included do
    def foo
    end
  end
end

class B
  def foo
  end

  include ::A
end

上のパターンは、既にクラスに定義されているメソッドを上書きする、という使い方です。クラス側に定義されているメソッドについても何らかの方法で動的に定義されている可能性があるので、これを静的検査で検知するのは難しいです。

パターン2: 親モジュールのメソッドをOverrideするもの

module A
  extend ::ActiveSupport::Concern

  included do
    def foo
    end
  end
end

module B
  def foo
  end
end

class C
  include ::A
  include ::B
end

上のパターンは、C < B < A という継承関係であるのに反して、先に include されているはずの A#foo の定義が優先されて使われてしまう、というものです。include を内部的に呼び出す宣言的なメソッドや、モジュールの側で更に include されている場合など、複雑な実現方法もあるので、これも静的検査で検知するのは難しいです。

無視すべきパターンの検知方法

今回は、実際に実行してみて、つまり動的に検査することで、上記のようなパターンに一致するものが無いか調べることにしました。一致するメソッド定義全てに # rubocop:disable Sevencop/MethodDefinitionInIncluded というコメントを付けてCopを無効化すれば、安全に自動修正できるようになるはずです。

Railsでは、Rails.application.eager_load! を呼び出すと、モデルやコントローラーなどの定義が一通り読み込まれます。これを活用して、上述のパターンに一致するものがあればその箇所を報告してもらうことにしましょう。

メソッド上書きの検知

1つ目の、既にクラスに定義されているメソッドを上書きするパターンについて。これは、included の中でいまからメソッドを定義する際に、自身にそのインスタンスメソッドが既に定義されていないかを調べれば判断できます。Rubyには Module#method_added という便利なフックがありますが、これはメソッドが定義された後に呼び出されるので、今回は使えません。そこで今回は、前述のカスタムCopを少し編集して、事前にコードを次のような形に変換してみることにしました。

module A
  extend ::ActiveSupport::Concern

  included do
    method_will_be_defined_in_included(:foo)
    def foo
    end
  end
end

method_will_be_defined_in_included を適当に定義して、Rails.application.eager_load! を呼び出せば、このパターンに一致するメソッド定義を洗い出せます。

class Module
  # @param method_name [Symbol]
  def method_will_be_defined_in_included(method_name)
    if method_defined?(method_name, false) || private_method_define?(method_name, false)
      # ここで報告したりする
      # (今回は `caller` を見て呼び出し元のソースコードにコメントを挿入する自己破壊的なコードにしました)
    end
  end
end

Rails.application.eager_load!

親モジュールに対するOverrideの検知

次に、included によって後に include されるモジュールよりも優先されるメソッドが定義されるパターンの検知方法について。これは、後に定義されるメソッドに影響を及ぼしているか検知したいので、そのメソッドが定義されたタイミングではまだ検知できません。そこで、すべての定義が終わった後で、結果的にそういう構造になったメソッド定義を探し出すことにしました。

Rails.application.eager_load!

# この慣習で `included` が使われているのはアプリ内のモデルとコントローラーだけなので、
# これらのすべての子孫クラスから探索する
result = [
  ApplicationController,
  ApplicationRecord
].flat_map(&:descendants).flat_map do |klass|

  # 1. そのクラスから、`included` 内の `def` で定義されたメソッドを探索する
  methods_maybe_defined_in_included = klass.instance_methods(false).map do |method_name|
    klass.instance_method(method_name)
  end.select do |method|
    path = method.source_location[0]
    File.read(path).include?('included do')
  end

  # 2. そのクラスの継承ツリーから、モジュール上に定義されたメソッドを探索する
  methods_defined_in_module = klass.ancestors[1..].grep_v(Class).flat_map do |mod|
    mod.instance_methods(false).map do |method_name|
      mod.instance_method(method_name)
    end
  end

  # 1と2から得られた二つの集合を積算すると、ほしいメソッドの集合を得られる
  method_names = methods_maybe_defined_in_included.map(&:name) & methods_defined_in_module.map(&:name).uniq

  method_names.map do |method_name|
    "#{klass}##{method_name}"
  end
end

puts result.sort

上記のコードでこのパターンに一致するメソッド定義を洗い出せるので、同様に該当箇所に # rubocop:disable ... なコメントを挿入し、Copを無効化します。

結果

これでCopを無効化すべき箇所を無効化できたので、改めてRuboCopで自動修正すれば、リファクタリング完了です。実際には +62,932 −62,469 という差分になりました。

おわり

以上です。RuboCopのカスタムCopを書いてリファクタリングを行う話として、丁度良い事例があったので紹介しました。