The boss's mission:
写一个类宏,功能与attr_accessor类似,但会创建经过校验的属性,名字attr_checked。
需求:
- 接受属性名,和block。block用于校验属性,如果对一个属性赋值,非true就报错。
- 只给特定的类用,所以不要放到标准库中。只有当类加了CheckedAttributes模块,才拥有这个功能。
A Development Plan:
开发计划:
- 使用eval方法快速编写内核方法add_checked_attribute,用来为类添加一个校验属性。
- 重构这个方法,不用eval.
- 通过代码块来校验属性。
- 把这个方法修改为名为attr_checked的类宏,让它对所有类可用。
- 写一个模块,通过hook method为指定的类添加attr_checked方法。
第一步
创建2个拟态方法,读/写 方法。在写方法加入校验属性值是否nil/false.
require 'test/unit'
class Person; end
class TestCheckedAttribute < Test::Unit::TestCase
def setup
add_checked_attribute(Person, :age)
@bob = Person.new
end
def test_accepts_valid_values
@bob.age = 20
assert_equal 20, @bob.age
end
def test_refuses_nil_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = nil
end
end
def test_refuses_false_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = false
end
end
end
def add_checked_attribute(klass, attribute)
eval "
class #{klass}
def #{attribute}=(value)
raise 'Invalid attribute' unless value
@#{attribute} = value
end
def #{attribute}()
@#{attribute}
end
end
"
end
add_checked_attribute(String, :my_attr)
第二步 ,重构
防止代码外泄后,被攻击,另外增强代码可读性,所以不用eval。标准库里寻找替代方法,注意scope。使用class_eval重新定义类。
def add_checked_attribute(klass, attribute)
klass.class_eval do
...
end
end
定义读写方法,不能用def关键字,改用动态方法传递参数。
另外,不能使用"@#{attribute}" = value 这种字符串给实例变量赋值了。改用其他method,Object#instance_variable_set().
结果:
def add_checked_attribute(klass, attribute)
klass.class_eval do
define_method("#{attribute}=") do |value|
raise 'Invalid attribute' unless value
instance_variable_set("@#{attribute}", value)
end
define_method attribute do
instance_variable_get("@#{attribute}")
end
end
end
第三步
增加block验证,测试加入代码块条件,{|v| v >= 18 }
定义add_checked_attribute方法增加Proc参数, &validation
在raise上修改为Proc.call(value) ,Invokes the block.
class TestCheckedAttribute < Test::Unit::TestCase
def setup
add_checked_attribute(Person, :age) {|v| v >= 18 }
@bob = Person.new
end
...
def test_refuses_invalid_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = 17
end
end
end
def add_checked_attribute(klass, attribute, &validation)
klass.class_eval do
define_method("#{attribute}=") do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}", value)
end
...
第四步,校验过的属性。
把内核方法add_checked_attribute改造成类宏attr_checked,放到类Person中。
class Person
attr_checked :age do |v|
v >= 18
end
end
如果让 attr_checked 对所以方法可用,可以定义为Class或Module的实例方法。
Person的类是Class。Person作为Class的对象可以调用attr_checked方法 .
这样就不需要class_eval打开类了。因为attr_checked执行时要定义的类self就是Person.
class Class
def add_checked(attribute, &validation)
define_method("#{attribute}=") do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}", value)
end
define_method attribute do
instance_variable_get("@#{attribute}")
end
end
end
同时,要修改测试类的代码,去掉 add_checked_attribute(Person, :age) {|v| v >= 18 }
第 5 步 hook methods
定义一个模块,包含include到Person中。
module ClassMethods
def attr_checked(attribute, &validation)
define_method("#{attribute}=") do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}", value)
end
define_method attribute do
instance_variable_get("@#{attribute}")
end
end
end
但是, 我们的目的是做出atrr_checked,这是是类宏,类方法。
而类包含的方法都是实例方法,Person.attr_checked是❌的。
所以想办法让attr_checked成为Person的类方法。
这里使用Hook methods. Object#included.
- 当Person包含模块CheckAttributes时,会自动调用钩子方法included.
- 这个钩子方法传递参数就是类的名字,使用extend方法进行类扩展
- extend方法把模块ClassMethods中的方法attr_checked包含到Person的单件类中。
所以,attr_checked就成了类方法,可以被类直接使用了,模拟了类宏。
⚠️ :这里attr_checked内生成的动态方法,是类Person的实例方法。
类方法的定义 有三种。分别是
- def 类名.method;end
- 在类中,用def self.method;end
- 在类中,用class << self...end
代码:
module CheckedAttributes
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def attr_checked(attribute, &validation)
define_method("#{attribute}=") do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}", value)
end
define_method attribute do
instance_variable_get("@#{attribute}")
end
end
end
end
小结 :Wrap_up
这是一个有难度的元编程问题,编写了自己定义的类宏。
最终的全代码
require 'test/unit'
class TestCheckedAttribute < Test::Unit::TestCase
def setup
@bob = Person.new
end
def test_accepts_valid_values
@bob.age = 20
assert_equal 20, @bob.age
end
def test_refuses_invalid_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = 17
end
end
end
module CheckedAttributes
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def attr_checked(attribute, &validation)
define_method("#{attribute}=") do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}", value)
end
define_method attribute do
instance_variable_get("@#{attribute}")
end
end
end
end
class Person
include CheckedAttributes
self.attr_checked( :age ) do |v|
v >= 18
end
end