程序人生

写优雅的程序,做优雅的人

Rails 中 mattr_accessor 一处文档错误

| Comments

发现错误

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

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

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

演示代码如下:

1
2
3
4
5
6
7
8
9
10
11
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 处,信息如下:

1
2
3
[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 文件中,相关的代码并不复杂,部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  # 此处忽略注释
  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 类方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  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 钩子中执行,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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,希望能够被接受。

Comments