发现错误
最近写一个 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
,希望能够被接受。