元编程与eval

所谓元编程就是”生成代码的代码”.

对于”解释型”的编程语言,由于程序整个运行时期都依赖于解释器,最简单的方式就是让语言提供一个eval方法,将字符串当作该语言喂给解释器执行, Ruby,Python,JavaScript都提供了eval方法;

对于区分”编译时”和”运行时”的”编译型”编程语言,可以给这种语言添加一种非常特殊的机制,让它可以在”编译时”生成代码,典型的例子就是C/C++的宏和C++的模板.这种宏或者是模板实际上和语言本身差别很大,C++的模板本身甚至是一门图灵完备的编程语言.

eval的缺点

eval很强大,但是也有很多缺点: 1.将代码作为字符串不能使用IDE/编辑器的语法检查功能,提高了出错的几率,当然这个问题容易解决,只要语法检查工具将eval内的字符串特殊处理即可; 2.字符串由程序拼接而来,有被注入的风险,这就和SQL注入一样; 3.eval内的字符串可能会污染上下文环境或者被上下文环境污染,引发意想不到的bug 因此Ruby提供了几个弱化版的eval来消除这些缺点,它们分别是instance_eval, class_eval, instance_exec

block

讲解instance_eval之前先说一下Ruby的代码块(block)和proc,block作为Ruby的一种基本语言要素,本身不能被当做数据使用,不能动态定义,只能事先写好,但是block可以和proc互相转换,而proc是ruby中的一等公民, 因此block仍然相当于一等公民. block拥有闭包的性质,在形成的时候会包裹当前词法作用域内的绑定,这和其他正确实现闭包的语言没有区别,但是block中有两类变量会被特殊处理: 当前实例(self和instance variable)和当前类 block有两种使用方式(就我目前了解到的):

1.作为普通的闭包

@a = 1
b = 1
puts_self = Proc.new do
  puts self, @a, b
end

puts_self.call # main 1 1

class A
  def call_proc(proc)
      @a = 2
      b = 2
      proc.call
  end
end

A.new.call_proc(puts_self) # main 1 1

这种用法会捕获词法作用域内的绑定(包括self和@a).当使用proc.call或者yield(args)这种方法去调用proc,都是将block当作了普通的闭包.

2.作为可改变上下文的闭包

@a = 1
b = 1
puts_self = Proc.new do
  puts self, @a, b
end

puts_self.call # main 1 1

class B
  def call_proc(proc)
      @a = 2
      b = 2
      instance_eval(&proc)
  end
end
B.new.call_proc(puts_self) #  #<B:0x00000001338410> 2 1

如果block用instance_eval去执行,block里的@a和self都被改变了,它们的上下文变成了instance_eval的调用者—-B的一个实例 #<B:0x00000001338410>;而b仍然被当作普通的变量—-它来自于定义闭包时的词法作用域.

可见,block的当前实例是可以被改变的 此外,block的当前类也能被改变,如果block里有取决于当前类的语句/表达式(如def),那么改变了执行结果也会随着上下文的改变而改变

instance_eval

instance_eval方法如其名,将它的调用者作为当前实例(instance)去eval一个block,block里和instance有关的上下文变量self和instance variable都被相应改变了.

instance_eval在改变了当前实例的同时,还改变了当前类

def_method = Proc.new do
  def test_method
  end
end

class C
  def call_proc(proc)
    instance_eval(&proc)
  end  
end

c = C.new
c.call_proc(def_method)

m = c.method(:test_method)
m.owner # #<Class:#<C:0x00000001fb93b0>>,这是m的eigen class

def是作用于当前类上的,它会把它定义的方法放在当前类里面,test_method位于m的eigen class,说明instance_eval将当前类修改为当前实例的eigen class了.

class_eval

instance_eval类似,class_eval是将它的调用者作为当前类(class)去eval一个block,block里和class有关的上下文变量都会被改变

p = Proc.new do
  def test_method
  end
  puts self
end

class D
end

D.class_eval(&p) # D
d = D.new
m = d.method(:test_method)
m.owner # D

因为只有Class的实例才具有class_eval方法,所以我们直接用D去调用class_eval,很容易看出class_eval将当前类和当前实例都修改成了class_eval的调用者.

instance_exec

前面的instance_eval已经很强大了,但是总感觉少了些什么,加入哪天我们闲得蛋疼了想实现一个可以自定义二元运算的类宏def_calc_method, 它接受一个方法名和一个怎么去计算的代码块,效果就是给它的调用者添加一个可以做这种计算的实例方法 具体来说就是要这样的效果

class LeftValue
  extend BinaryCalcDefiner

  def_calc_method :add do |y|
     @x + y
  end

  def_calc_method :times do |y|
    @x * y
  end

 def initialize(x)
      @x = x
  end
end

five = LeftValue.new(5)
five.add(1) # 6
five.times(4) # 20

看起来还是有一丝酷炫,虽然并没有什么卵用.

怎样实现呢? def_calc_method的架子大概是这样的

module BinaryCalcDefiner
  def def_calc_method(method_name, &calc_proc)
     define_method method_name do |y|
        ______(y, &proc)
     end
  end
end

空白处要填入哪个方法呢? 因为要绑定实例的@x,所以必须要用instance_eval这种能改变上下文的方法去eval传入的块,然而这个块还需要接受一个参数,instance_evalclass_eval肯定是不行的,所以就理所当然地引入了instance_exec来解决这种问题,它eval一个块的时候可以将一个参数作为块的参数

module BinaryCalcDefiner
  def def_calc_method(method_name, &calc_proc)
     define_method method_name do |y|
        instance_exec(y, &proc)
     end
  end
end

大功告成.

总结

eval大大加强了Ruby的元编程能力,而eval本身问题较多.instance_eval, class_eval, instance_exec作为eval方法的弱化版,实际上和SQL的预编译技术差不多,都是将代码中的可变部分控制在很小的范围,以免引入注入风险.此外由于三者都是接受的Ruby的代码块而不是字符串,所以加强了可读性,上下文环境也得以清晰明了(相当于普通的闭包). 三个方法的本质都是通过改变代码块的上下文而使得代码块拥有更强的表达能力,都可以改变当前类和当前实例,特别的,instance_eval可以给带参数的block注入参数.