Ruby元编程笔记——编写代码的代码

任务:写一个类宏

这个类宏与 attr_accessor 类似,名字叫 attr_checked 方法,它有如下特征:

  1. 它会创建经过检验的属性
  2. 接受属性名和代码块,代码块用来校验,如果对一个属性赋值,而代码块没有返回true,就会抛出异常。
  3. attr_checked并不在每个类中都可用,因为它的初衷并不是把标准库搞的乱七八糟,只有当一个类包含CheckedAttributes模块时才能使用这个方法。

开发步骤:

  1. 使用 eval() 编写一个名为 add_checked_attribute() 的内核方法,为类添加一个最简单的经过校验的属性,比如说 age 。

    这里用 eval 方法(背景知识2,3,4)是为了快速通过测试。

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
31
32
33
34
35
36
37
38
39
40
41
42
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 方法为什么要仿照 attr_accessor 的创建的读写拟态方法呢?也许因为方法的核心内容就是读写吧。在明确了读写对象的基础上才有了比较等其它操作。所以第一步的核心是先开发出读写方法。

  1. 重构 add_checked_attribute() 方法,去掉 eval() 。

    因为 eval 的缺点(背景知识3),接下来去掉 eval() 。

    不用 eval() 方法,就无法使用代码字符串,继续使用 class 关键字,因为 class 不接受变量作为类名,#{klass}和的值无法动态传入。此时,可以用 class_eval 方法进入类的作用域。

    同时在定义方法时也不能用 def 了,因为只有在运行时才知道方法的名字。这里可以用defind_method 来动态定义方法。至于操作实例变量,可以用 instance_variable_get 和 instance_variable_set 方法。

    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
    31
    32
    33
    34
    35
    36
    37
    38
    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)
    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
  2. 通过块来校验属性。

    目前代码只能简单地对赋值 nil 或 false 的情况抛出异常,没有代码块验证的灵活。这里通过 &validation 来做代码块验证。

    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
    31
    32
    require 'test/unit'
    class Person; end
    class TestCheckedAttribute < Test::Unit::TestCase
    def setup
    add_checked_attribute(Person, :age) {|v| v >= 18 }
    @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
    def add_checked_attribute(clazz, attribute, &validation)
    clazz.class_eval do
    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
  3. 将 add_checked_attribute() 改名为 attr_checked 的类宏,它对所有类可用。

    这意味着应该定义一个可以在类定义中使用的方法,另外新方法不能像 add_checked_attribute() 一样接受类名作为参数,应该只接受属性名作为参数。

    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
    31
    32
    33
    34
    35
    require 'test/unit'
    class Person
    attr_checked: age do |v|
    v >= 18
    end
    end
    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
    class Class
    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
  4. 写一个模块,通过钩子方法(背景知识5)为指定的类添加 attr_checked 方法。

    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
    31
    32
    33
    34
    35
    36
    37
    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
    require 'test/unit'
    class Person
    include CheckedAttributes
    attr_checked :age do |v|
    v >= 18
    end
    end
    class TestCheckedAttributes < Test::Unit::TestCase
    def setup
    @bob = Person.new
    end
    def test_accepts_valid_values
    @bob.age = 18
    assert_equal 18, @bob.age
    end
    end

最后的结果是,公用验证代码放在 CheckedAttributes 模块中,类宏 attr_checked 验证条件在类中使用,测试液运行正常。代码结构干净利索。

背景知识补充:

1.绑定对象

Binding 就是一个用对象表示的完整作用域。可以通过 Binding 对象和来捕获并带走当前作用域,然后使用 eval 来计算结果,实现类似于闭包的操作。

Binding.pry会在当前绑定上打开一个Ruby解释器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClass
def my_method
@x = 1
binding
end
end
b = MyClass.new.my_method
puts eval "@x", b
class AnotherClass
def my_method
eval 'self', TOPLEVEL_BINDING
end
end
puts AnotherClass.new.my_method #=> main
2.eval 的用处

eval 方法自带共享作用域(和变量)的功能,能够自动衔接目标类的上下文。

从外部传入一个任意的代码字符串给 eval 方法,这样就可以创建一个简单的Ruby解释器。很多Ruby解释器都使用了 eval 方法。

3.eval 的麻烦

1.不支持编辑器的功能特性,比如语法高亮和自动完成。

2.难以阅读和修改

3.Ruby执行字符串前不会进行语法检查

4.安全性不好——有可能会被代码注入

4.防止代码注入:

1.限制 eval 方法只执行自己写的代码字符串。

2.禁用 eval 方法。

3.识别污染对象和设置安全级别。

5.钩子方法

Ruby 提供的钩子方法种类繁多,覆盖了对象模型中绝大多数事件。当中这些事件发生(如被包含,扩展,方法增加删除等),就会触发它们。

通过覆写钩子方法如 Module#included() 方法,使得当一个模块被包含时执行额外的代码。它跟环绕别名所起到的作用有点像。

就算不覆写,也可以借助环绕别名把普通方法变成钩子方法。

尾声

用平常心对待代码,它应该简洁而清晰,而不是晦涩难懂。

要有智慧忘掉所学的东西,根本没有什么元编程,只有编程而已。