Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rails 5とModule#prependがもたらす変化――alias_method_chainの時代は終わる

Rails 4.2のリリースアナウンスには、次期Rails 5に関する興味深い情報が含まれていました。それは「Rails 5はおそらくRuby 2.2を必須とする」というものです。つまり、Ruby 2の優れた機能を本格的に活用できる、初めてのRailsバージョンになるというわけです。

その投稿では、GC対象になったシンボルやキーワード引数が取り上げられていました。しかし私にとって最も興味深いRuby 2の機能のひとつは、Module#prependです。

alias_method_chain の光と影

Railsを学び始めた頃、alias_method_chainには本当に衝撃を受けました。Rubyという言語がどれほど柔軟であるかを如実に示してくれる存在だったからです。

たった1行のコードで、メソッドの動作を完全に変更することができました。ライブラリのコードに直接手を加えて望みの処理を追加する必要はなく、その場で付け足せばよかったのです。私にとってalias_method_chainは、gemへのパッチ作り、初めてのプルリクエスト、そしてオープンソースへの貢献へとつながるきっかけとなりました。

しかし、モンキーパッチと同じように、alias_method_chainは乱用されるようになり、その問題点が次第に明らかになっていきました。

  • 生成されるメソッド名が紛らわしく、エラーの発見やデバッグが難しい。例えば次のようなコードです:
class Person
  def greeting
    "Hello"
  end
end

module GreetingWithExcitement
  def self.included(base)
    base.class_eval do
      alias_method_chain :greeting, :excitement
    end
  end

  def greeting_with_excitement
    "#{greeting_without_excitement}!!!!!"
  end
end

Person.send(:include, GreetingWithExcitement)

Person#greetingにエラーがある場合、バックトレースはエラーがPerson#greeting_without_excitementで発生したと伝えてきます。ところが、そのメソッドはどこにも定義されていません。複数あるgreetingメソッドのうち、どれにバグがあるのかをどうやって見分ければいいのでしょうか?しかも、チェーンが長くなるほどメソッド名はますます分かりにくくなります。

  • 同じクラスに対して同じ引数でalias_method_chainを2回呼び出すと、スタックオーバーフローを引き起こす可能性がある。(理由が分かるでしょうか?) 通常はrequireのパス指定に一貫性があれば起こりませんが、Railsコンソールにコードを頻繁に貼り付けるような開発スタイルでは非常に厄介な問題になります。

  • さらに、Yehuda Katzのブログ記事で指摘されたその他の問題点。この記事によって、多くのRails開発者がalias_method_chainから離れ、モジュールによる継承へと移行していきました。

それでも、なぜ今も使われ続けているのでしょうか?

ほとんどのalias_method_chainは、モジュール内でメソッドをオーバーライドし、そのモジュールを子クラスにインクルードすることで置き換えられます。しかし、それが機能するのはスーパークラスのメソッドをオーバーライドしたい場合のみで、クラス自身のメソッドには適用できません。つまり、こういうことです:

class ParentClass
  def log
    puts "In parent"
  end
end

class ChildClass < ParentClass
  def log
    puts "In child"
    super
  end

  def log_with_extra_message
    puts "In child, with extra message"
    log_without_extra_message
  end

  alias_method_chain :log, :extra_message
end

ChildClass.new.logを実行すると、次のような出力になります:

In child, with extra message
In child
In parent

一方、モジュールを使ってalias_method_chainを置き換えると、出力は次のようになってしまいます:

In child
In child, with extra message
In parent

ChildClasslogメソッド自体を変更しない限り、元の出力順序を再現することはできないのです。Rubyの継承の仕組みでは、そうはいかないからです。正確に言えば、「そうはいかない」でした。

Ruby 2.0で何が変わったのか?

Ruby 2.0まで、コードをクラスの「下」に差し込む方法は存在せず、常に「上」に追加することしかできませんでした。しかしprependを使えば、クラスのメソッドをモジュールのメソッドでオーバーライドしながら、super経由でクラス側の実装にもアクセスできます。先ほどの例なら、次のように書くだけで元の出力順序を完全に再現できます:

class ParentClass
  def log
    puts "In parent"
  end
end

module ExtraMessageLogging
  def log
    puts "In child, with extra message"
    super
  end
end

class ChildClass < ParentClass
  prepend ExtraMessageLogging
  def log
    puts "In child"
    super
  end
end
In child, with extra message
In child
In parent

完璧ですね。

それでもprependの動きがイメージしにくい場合は、次のようなコードと考えると分かりやすいでしょう:

class NewChildClass < ChildClass
  include ExtraMessageLogging
end

ChildClass = NewChildClass

ただし実際のprependはクラス名を汚すことなく、既に存在するオブジェクトに対しても影響を与えます。

(そう、Rubyではクラス名を再代入することもできるのです。ただし、あまりおすすめできるやり方ではありません。)

Railsにとってこれは何を意味するのか?

こうして、alias_method_chainを使う最後の言い訳が、Ruby 2.0のもとで消え去りました。Rails本体に残っていた数少ないalias_method_chainの使用例のひとつが、こちらです:

rails/activesupport/lib/active_support/core_ext/range/each.rb
require 'active_support/core_ext/module/aliasing'

class Range #:nodoc:

  def each_with_time_with_zone(&block)
    ensure_iteration_allowed
    each_without_time_with_zone(&block)
  end
  alias_method_chain :each, :time_with_zone

  def step_with_time_with_zone(n = 1, &block)
    ensure_iteration_allowed
    step_without_time_with_zone(n, &block)
  end
  alias_method_chain :step, :time_with_zone

  private
  def ensure_iteration_allowed
    if first.is_a?(Time)
      raise TypeError, "can't iterate from #{first.class}"
    end
  end
end

これを、モジュールを使って次のように書き換えられます:

require 'active_support/core_ext/module/aliasing'

module RangeWithTimeWithZoneSupport #:nodoc:

  def each(&block)
    ensure_iteration_allowed
    super(&block)
  end

  def step(n = 1, &block)
    ensure_iteration_allowed
    super(n, &block)
  end

  private
  def ensure_iteration_allowed
    if first.is_a?(Time)
      raise TypeError, "can't iterate from #{first.class}"
    end
  end
end

Range.send(:prepend, RangeSupportingTimeWithZone)

ずっとすっきりしました。Range#eachの名前も変わらないまま保たれ、ensure_iteration_allowedもモンキーパッチではなくなっています。

パッチではなく継承を使う

Rubyは非常に高い柔軟性を備えており、それこそが私がこの言語を愛する理由のひとつです。しかし同時に、Rubyには強力なオブジェクトモデルもあります。自分のコードを差し込みたいときは、安易なハックに頼る前に、まずモジュールと継承で実現できないか検討してみてください。そのほうがコードは格段に理解しやすくデバッグしやすくなり、alias_method_chainのような手法がもたらす、見つけにくい副作用も回避できます。

alias_method_chainは、Railsで出会った中でも最もクールなメソッドのひとつでした。しかし、その寿命はもう長くありません。私たちは成長し、もはや必要としていないのです。それがなくなっても、寂しくは思わないでしょう。

  1. Windows Vistaサポート終了前に知っておくべきこと

    ※本記事は公開当時(2017年)の情報に基づいています。 Windows Vistaが動作するパソコンをお持ちですか? Vistaはかなり年季の入ったOSになりつつありますが、日常的な用途にはまだ十分に対応できる性能を持っています。そのため、仕事や趣味の場面で今なおVistaに頼っているユーザーも少なくありませんでした。 しかし残念ながら、マイクロソフトは2017年4月11日をもってWindows Vistaのサポートを完全に終了しました。これは、無償の障害対応などが含まれる「メインストリーム サポート」が終了してから5年後のことです。メインストリーム サポート終了後も、Vistaは状況に応

  2. PythonとMatplotlibで折れ線グラフの終端に注釈(ラベル)を付ける方法

    PythonとMatplotlibを使えば、折れ線グラフの各線の終端に直接ラベル(注釈)を表示できます。これにより、凡例を目で追わなくても、どの線が何を表しているのか一目でわかる、読みやすいグラフを作成できます。 実装の手順 図のサイズを設定し、サブプロット間および周囲の余白を調整します。 データの行数を指定する変数 rows を初期化します。 Pandasのデータフレーム(長方形の表形式データ)を作成します。 データフレームの累積和(cumsum)を計算します。 plot()メソッドでデータフレームをプロットします。 各line(線オブジェクト)とname(列名)を反復処理し、線の終端に注