Rails Rails 中 mattr_accessor 一处文档错误

vincent · 2015年01月11日 · 3585 次阅读
本帖已被管理员设置为精华贴

发现错误

最近写一个 gem 的时候偶然接触到 Rails ActiveSupport 扩展 module 的 mattr_accessor 系列方法,包括 mattr_accessor、mattr_reader 和 mattr_writer。 记得以前探索 Rails 源代码的时候经常遇到 mattr_accessor 方法,当时并没有细究,这次碰巧要自己用到,所以仔细研究了其文档和实现源码,居然发现文档描述有明显的错误。

Rails 的官方文档中提到,mattr_accessor 用于为类属性定义类和实例对象两者的访问器,然后还提供一段示例代码演示其用法。

mattr_accessor(*syms, &blk) public
Defines both class and instance accessors for class attributes.

演示代码如下:

module HairColors
  mattr_accessor :hair_colors
end

class Person
  include HairColors
end

Person.hair_colors = [:brown, :black, :blonde, :red]
Person.hair_colors     # => [:brown, :black, :blonde, :red]
Person.new.hair_colors # => [:brown, :black, :blonde, :red]

当我运行该代码的时候,发现无法运行,报错在 Person.hair_colors 处,信息如下:

[5] pry(main)> Person.hair_colors = [:brown, :black, :blonde, :red]
NoMethodError: undefined method `hair_colors=' for Person:Class
from (pry):7:in `__pry__'

刚开始还有点不相信,分别在 Rails 4.1.8,4.2.0 和 3.2.x,Ruby 1.9.3,2.0.0 和 2.1.5 下运行, 都出现这个错误,这下确信文档描述应该是有问题的。我想把问题彻底搞清楚,于是仔细查看 Rails ActiveSupport 中相关的源代码,发现的确是文档描述的行为和程序实际行为不符。

探寻原因

mattr_accessor 系列方法的代码在 rails/activesupport/lib/active_support/core_ext/module/attribute_accessors.rb 文件中,相关的代码并不复杂,部分代码如下:

# 此处忽略注释
def mattr_writer(*syms)
  options = syms.extract_options!
  syms.each do |sym|
    raise NameError.new("invalid attribute name: #{sym}") unless sym =~ /^[_A-Za-z]\w*$/
    class_eval(<<-EOS, __FILE__, __LINE__ + 1)
      @@#{sym} = nil unless defined? @@#{sym}

      def self.#{sym}=(obj)
        @@#{sym} = obj
      end
    EOS

    unless options[:instance_writer] == false || options[:instance_accessor] == false
      class_eval(<<-EOS, __FILE__, __LINE__ + 1)
        def #{sym}=(obj)
          @@#{sym} = obj
        end
      EOS
    end
    send("#{sym}=", yield) if block_given?
  end
end

# 此处忽略注释
def mattr_accessor(*syms, &blk)
  mattr_reader(*syms, &blk)
  mattr_writer(*syms, &blk)
end

从中可以看到,mattr_accessor 分别 call mattr_reader 和 mattr_writer,mattr_writer 的主要逻辑是定义类变量(class variable,命名为 @@#{sym}),然后定义类方法(见 def self.#{sym}=(obj))和普通实例方法(见 def #{sym}=(obj))。当 Person include HairColors 的时候,普通实例方法被 mix 进 Person 的,但类方法并不会被 mix 进 Person。可以用下面更简明的例子演示 include module 并不能 mix 类方法。

module Foo
  def method1
    puts "method1 in Foo"
  end

  def self.method2
    puts "method2 in Foo as class method"
  end
end

class Bar
  include Foo
end

Bar.new.method1  # => "method1 in Foo"

Foo.method2 # => "method2 in Foo as class method"

Bar.method2 # => NoMethodError: undefined method `method2' for #<Bar:0x007fdb0a121488>

原来 mattr_accessor 为 HairColors 生成的 def self.hair_colors 类方法不能 mix 进 Person,从而导致 Person.hair_colors 出错,但是 Person.new.hair_colors 能够正常运行的。

解决方法

如果希望 Person 通过类方法和实例方法都能使用 hair_colors ,应该怎么做呢? 可以把 mattr_accessor 放在 included 钩子中执行,代码如下所示:

module HairColors
  extend ActiveSupport::Concern
  included do
    mattr_accessor :hair_colors
  end
end

class Person
  include HairColors
end

Person.hair_colors = [:brown, :black, :blonde, :red]
Person.hair_colors     # => [:brown, :black, :blonde, :red]
Person.new.hair_colors # => [:brown, :black, :blonde, :red]
HairColors.hair_colors # => undefined method `hair_colors' for HairColors:Module

它引发的一个新问题是 HairColors.hair_colors 变得不可用了,但我发现 Rails 中大多是使用这种手法处理的,这种情况下估计不怎么要直接用到 HairColors.hair_colors 吧。

总结

Rails 官方文档中关于 mattr_accessor 的描述的确有问题,示例代码不能正确运行,而且还会误导使用者,通过仔细探索 Rails 的源代码,找到了问题的源头,并且彻底弄清楚了它的实现机制。我已经修正文档的错误,提交了 pull request,希望能够被接受。

runup 关于 included 方法的问题 提及了此话题。 04月20日 17:26
需要 登录 后方可回复, 如果你还没有账号请 注册新账号