LoginSignup
1
0

More than 5 years have passed since last update.

ActiveModel::AttributeMethods.attribute_method_prefix(suffix)の概要と仕組みを調べたら仕組みの方がなんか怪しい

Last updated at Posted at 2017-02-16

概要

ActiveModel::AttributeMethodsはクラスの各attributeにprefix, suffix, affixをつけたメソッドをまとめて定義出来る便利なモジュールです。

使い方はActiveModel::AttributeMethodsをインクルードしたクラスで

  • attribute_xxxxメソッドをprefix, suffixを引数にして呼び出し
  • define_attribute_methodsをattribute_メソッドを定義したいattributeの名前を引数にして呼び出し
  • 対応するattributeメソッドを定義
    • prefixの場合 : #{prefix}_attributeメソッド
    • suffixの場合はattribute_#{suffix}メソッド
    • affixの場合は#{prefix}attribute#{suffix}メソッド

することでprefix, suffix, affixをつけたメソッドが動的に定義されます。

class Person
  include ActiveModel::AttributeMethods

  # attribute_xxxxメソッドをprefix, suffixを引数にして呼び出し
  attribute_method_prefix 'clear_'
  attribute_method_suffix '_contrived?'
  attribute_method_affix  prefix: 'reset_', suffix: '_to_default!'

  # define_attribute_methodsをattribute_メソッドを定義したいattributeの名前を引数にして呼び出し
  define_attribute_methods :name

  attr_accessor :name

  def attributes
    { 'name' => @name }
  end

  private
  # 対応するattributeメソッドを定義

  ## prefixの場合 : #{prefix}_attributeメソッド
  def clear_attribute(attr)
    send("#{attr}=", nil)
  end

  ## suffixの場合はattribute_#{suffix}メソッド
  def attribute_contrived?(attr)
    true
  end

  ## affixの場合は#{prefix}_attribute_#{suffix}メソッド
  def reset_attribute_to_default!(attr)
    send("#{attr}=", 'Default Name')
  end
end

# > person = Person.new
# > person.clear_name
# => nil
# > person.name_contrived?
# => true
# > person.reset_name_to_default!
# => "Default Name"

仕組み

具体例として上記の

attribute_method_prefix 'clear_'

の実装を追ってみます。

ソースコードを見ると

# Declares a method available for all attributes with the given prefix.
# Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
#
#   #{prefix}#{attr}(*args, &block)
#
# to
#
#   #{prefix}attribute(#{attr}, *args, &block)
# ~略
def attribute_method_prefix(*prefixes)

とありmethod_missingとrespond_to?を使ってメソッドを

clear_name(*args, &block)

から

clear_attribute(:name, *args, &block)

に書き換えているとあります。

実際に実装を追ってみます。
まずクラス生成時にAttributeMethods.attribute_method_prefixが呼ばれます
この例では1つしか引数を渡していませんがまとめて複数渡すことが出来るので可変長引数になっています。

activemodel-5.0.1/lib/active_model/attribute_methods.rb
def attribute_method_prefix(*prefixes)
  self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
  undefine_attribute_methods
end

1行目で各prefixをattribute_method_matchersにセットします。AttributeMethodMatcherの詳細は省きますが、結果として以下のようAttributeMethodMatcherオブジェクトがself.attribute_method_matchersにセットされます。
ここでmethod_missing_targetにmethod_missingで捕捉するメソッドを登録しています。

activemodel-5.0.1/lib/active_model/attribute_methods.rb
def attribute_method_prefix(*prefixes)
  self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
  #=> [ #<ActiveModel::AttributeMethods::ClassMethods::AttributeMethodMatcher:0x007f81323bd1d8
  #@method_missing_target="clear_attribute",
  #@method_name="clear_%s",
  #@prefix="clear_",
  #@regex=/^(?:clear_)(.*)(?:)$/,
  #@suffix=""]
  undefine_attribute_methods
end

その後、define_attribute_methods :name内でdefine_proxy_callというメソッドが呼ばれて、clear_nameが定義されます。ここがポイントなのですが、このメソッド定義はPersonクラス内に直接されるのではなく、動的に生成されたModuleに定義されてそのModuleがPersonクラスの1つ上に差し込まれます

def define_proxy_call(include_private, mod, name, send, *extra) #:nodoc:
  defn = if name =~ NAME_COMPILABLE_REGEXP
    "def #{name}(*args)"
  else
    "define_method(:'#{name}') do |*args|"
  end

  extra = (extra.map!(&:inspect) << "*args").join(", ".freeze)

  target = if send =~ CALL_COMPILABLE_REGEXP
    "#{"self." unless include_private}#{send}(#{extra})"
  else
    "send(:'#{send}', #{extra})"
  end

  mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    #{defn}
      #{target}
    end
  RUBY
  # def clear_name(*args)
  #   clear_attribute("name", *args)
  # end
  # がModuleに定義される
end
> Person.ancestors
=> [Person,
 #<Module:0x007fcdfc463328>, #差し込まれる
 ActiveModel::AttributeMethods,
 ...
 BasicObject]
> Person.ancestors[1].instance_methods(false)
=> [:name, :clear_name] #clear_nameがmoduleに定義されている

これで準備は整いました 。実際にclear_nameが呼び出される時の流れを追ってみます。
まずPersonオブジェクトがclear_nameを呼び出し、階層を上にたどってModuleに定義されているclear_nameを呼び出します。

スクリーンショット 2017-02-16 16.37.25.png

次にclear_name内でclear_attributeが呼ばれ継承階層を上に辿っていきますが、メソッドが見つからずMethodMissingとなります。

スクリーンショット 2017-02-16 16.39.12.png

MethodMissingとなったので呼び出し元のModuleに戻りmethod_missingメソッドを探します。結果MethodAttributeクラスにmethod_missingがありそのmethod_missing内で登録しておいたmethod_missing_target(clear_attribute)と一致するのでnameを引数にしてPersonObjectのclear_attributeに転送します。

スクリーンショット 2017-02-16 16.43.27.png

実際MethodAttributesのmethod_missingは以下のようになっておりmatched_attribute_method内でメソッドを探索しマッチするものがあればattribute_missingを呼びします。最後にattribute_missing内でtargetのメソッドを呼び出します。

activemodel-5.0.1/lib/active_model/attribute_methods.rb
def method_missing(method, *args, &block)
  if respond_to_without_attributes?(method, true)
    super
  else
    match = matched_attribute_method(method.to_s)
    match ? attribute_missing(match, *args, &block) : super
  end
end

def attribute_missing(match, *args, &block)
  __send__(match.target, match.attr_name, *args, &block)
end

※ 上記の様な仕組みになっていると思います。

何故思いますかというといくらやってもこのmethod_missingの中に入ってこないからです。
普通に無いメソッドをcallすれば入ってくるのですが、なぜ入ってこないかわかりませんでした。

method_missingを使っているとコメントに書いてあるのでMethodAttributes#method_missingに入ってくるはずだと思ってこのように書きましたがそもそもそれが間違っているのかもしれません。

def method_missing(method, *args, &block)
  puts "CALL METHOD MISSING!!"
  if respond_to_without_attributes?(method, true)

> Person.new.nothing_method
CALL METHOD MISSING!!

> Person.new.clear_name
Person.new.clear_name
=> nil

もしご存知の方いればぜひご教示ください!!!!(その他間違いもありましたら奇譚なくご指摘くださいm(_)m)

追記:

上記の説明は以下のところが間違えていました。

次にclear_name内でclear_attributeが呼ばれ継承階層を上に辿っていきますが、メソッドが見つからずMethodMissingとなります。

スクリーンショット 2017-02-16 16.39.12.png

これは実際にはModuel起点でメソッドの探索は行われず、PersonObjectから探索が行われます。

module A
  def hoge
    baz
  end
  def baz
    puts "call form A"
  end
end
class B
  include A
  def baz
    puts "call form B"
  end
end
> B.new.hoge
call form B
=> nil

なのでMethodMissingは呼び出されず、正しい図はこうなります
スクリーンショット 2017-02-16 17.50.25.png

ということはやっぱりMethodAttributeのmethod_missingはcallされてないということになります。
じゃあこのコメントの説明はどういう意味なんだろうか。。。

# Declares a method available for all attributes with the given prefix.
# Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
#
#   #{prefix}#{attr}(*args, &block)
#
# to
#
#   #{prefix}attribute(#{attr}, *args, &block)
# ~略

追記の追記

このコメントが追加されたのは2009年でそれ以降変わってないようです。
かつこの時の実装は明らかにsendしていますね

def method_missing(method_id, *args, &block)
  method_name = method_id.to_s
  if match = match_attribute_method?(method_name)
    guard_private_attribute_method!(method_name, args)
    return __send__("#{match.prefix}attribute#{match.suffix}", match.base, *args, &block)
  end
  super
end

ますます怪しい
https://github.com/rails/rails/commit/f8d3c72c39ad209abca7f3613f91fb3a03805261

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0