Eval家族的那些事兒

lanzhiheng發表於2019-03-30

許多程式語言都會附帶eval的功能,通常會出現在動態語言中,它就有點像是一個微型的直譯器,可以在執行時解釋程式碼片段。這篇文章主要以Ruby為例,詳細介紹Ruby中的eval家族。

程式碼片段的執行者eval

Eval是Ruby語言中比較有意思的一個功能了。其實不僅僅是Ruby,許多語言都開放了這個功能。不過在不同語言裡,該功能的命名方式以及側重點會有所不同。

在Lua程式語言中,eval的功能通過一個叫load(版本5.2之後)的函式來實現,不過它解釋完程式碼片段之後會返回一個新的函式,我們需要手動呼叫這個函式來執行對應的程式碼片段

> load("print('Hello World!')")()
Hello World!
複製程式碼

詭異的地方在於,它不能解析單純的算術運算

> 1 + 2
3

> load("1 + 2")()
stdin:1: attempt to call a nil value
stack traceback:
	stdin:1: in main chunk
	[C]: in ?
複製程式碼

要解析算術運算,需要把它們包裝成方法體

> f = load("return 1 + 2")
> f()
3
複製程式碼

在Python中該功能是通過名為eval的函式來實現,用起來就像是一個簡單的REPL

In [2]: eval
Out[2]: <function eval>

In [3]: eval('1 + 2')
Out[3]: 3

In [4]: eval('hex(3)')
Out[4]: '0x3'
複製程式碼

不過奇怪的地方在於它不能直接解析Python中語句,比如說print語句

In [5]: eval('print(1 + 2)')
  File "<string>", line 1
    print(1 + 2)
        ^
SyntaxError: invalid syntax
複製程式碼

要列印東西,可以考慮把上述語句封裝成一個方法

In [12]: def c_print(name):
   ....:     print(name)
   ....:

In [13]: eval("c_print(1 + 2)")
3
複製程式碼

相比之下,Ruby的eval似乎就沒節操得多,或許是因為借鑑了Lisp吧?它幾乎能執行任何程式碼片段

> eval('print("hello")')
hello => nil
> eval('1 + 2')
 => 3
複製程式碼

接下來我嘗試用它來執行指令碼檔案中的程式碼片段,假設我有這樣一個Ruby指令碼檔案

// example.rb
a = 1 + 2 + 3

puts a
複製程式碼

想要執行這個檔案,最直接的方式就是

ruby example.rb

6
複製程式碼

然而你還可以通過eval來做這個事情

> content = File.read("example.rb") # 讀取檔案中的程式碼片段
 => "a = 1 + 2 + 3\n\nputs a\n"
> eval(content)
6
 => nil
複製程式碼

當然Ruby中的eval絕不僅如此,且容我慢慢道來。

Eval與上下文

在Ruby中用eval來執行程式碼片段的時候會預設採用當前的上下文

> a = 10000
=> 10000
> eval('a + 1')
=> 10001
複製程式碼

我們也可以手動傳入當前上下文的資訊,故而,以下的寫法是等價的

eval('a + 1', binding)
=> 10001
複製程式碼

bindingeval在當前的作用域中都是私有方法

> self
 => main
> self.private_methods.grep(:binding)
=> [:binding]
> self.private_methods.grep(:eval)
=> [:eval]
複製程式碼

在功能上,它們分別來自於Kernel#bindingKernel#eval

> Kernel.singleton_class.instance_methods(false).grep(:eval)
 => [:eval]
> Kernel.singleton_class.instance_methods(false).grep(:binding)
=> [:binding]

> Kernel.eval('a + 1', Kernel.binding)
=> 10001
複製程式碼

有了這兩個東西,我們可以寫出一些比較有意思的功能。

> def hello(a)
>   binding
> end

> ctx = hello('hello world')
複製程式碼
> eval('print a') # 列印當前上下文的變數`a`
10000 => nil

> eval('print a', ctx) # 列印`hello`執行時上下文的變數`a`
hello world => nil
複製程式碼

通過binding擷取hello方法的上下文資訊並儲存在物件中,然後把該上下文傳遞至eval方法中。此外,上文的ctx物件其實也有它自己的eval方法,這是從Binding類中定義的例項方法。

> ctx.class
=> Binding

> Binding.instance_methods(false).grep /eval/
=> [:eval]
複製程式碼

區別在於它是一個公有的例項方法,接收的引數也稍微有所不同。更簡單地我們可以用下面的程式碼去列印hello執行時上下文引數a的值。

ctx.eval('print a')
hello world => nil
複製程式碼

更多的eval變種

在Ruby中eval其實還存在一些變種,比如我們常用的用於開啟類/模組的方法class_eval/module_eval其實就相當於在類/模組的上下文中執行程式碼。為了在例項變數的上下文中執行程式碼,我們可以採用instance_eval

a. class_eval/module_eval

在Ruby裡面類和模組之間的關係密不可分,很多時候我們會簡單地把模組看成是無法進行例項化的類,它們兩本質上是差不多的。於是乎class_evalmodule_eval兩個方法其實只是為了讓編碼更加清晰,兩者功能上並無太大區別。

> class A; end
 => nil
> A.class_eval "def set_a; @a = 1000; end"
 => :set_a
> A.module_eval "def set_b; @b = 2000; end"
 => :set_b
> a = A.new
 => #<A:0x00007ff59d955fc0>
> a.set_a
 => 1000
> a.set_b
 => 2000
> a.instance_variable_get('@a')
 => 1000
> a.instance_variable_get('@b')
=> 2000
複製程式碼

我們也可以通過多行字串來定義相關的邏輯

> A.class_eval <<M
> def print_a
>  puts @a
> end
> M
 => :print_a

> a.print_a
1000
複製程式碼

不過在正式編碼環境中通過字串來定義某些函式邏輯實在是比較蛋疼,畢竟這樣的話就沒辦法受益於程式碼編輯器的高亮環境,程式碼可維護性也相對降低。語言設計者或許考慮到了這一點,於是我們可以以程式碼塊的形式來傳遞相關的邏輯。等價寫法如下

class A; end

A.class_eval do
  def set_a
    @a = 1000
  end

  def set_b
    @b = 2000
  end

  def print_a
    puts @a
  end
end

i = A.new
i.set_a
i.set_b

puts i
puts i.instance_variable_get('@a')
puts i.instance_variable_get('@b')
i.print_a
複製程式碼

列印結果

#<A:0x00007fb75102cb18>
1000
2000
1000
複製程式碼

與之前的例子所達成的效果是一致的,只不過寫法不同。除此之外,他們兩個的巢狀層級是不一樣的

> A.class_eval do
>   Module.nesting
> end
 => []

> A.class_eval "Module.nesting"
 => [A]
複製程式碼

實際上,我們還可以用最開始介紹的eval方法來實現相關的邏輯

> A.private_methods.grep(:binding)
 => [:binding]
複製程式碼

可見對於類A而言,binding是一個私有方法,因此我們可以通過動態發派來獲取類A上下文的資訊。

> class_a_ctx = A.send(:binding)
=> #<Binding:0x00007f98f910ae70>
複製程式碼

拿到了上下文之後一切都好辦了,可分別通過以下兩種方式來定義類A的例項方法。

> class_a_ctx.eval 'def set_c; @c = 3000; end'
=> :set_c

> eval('def set_d; @d = 4000; end', class_a_ctx)
=> :set_d
複製程式碼

簡單驗證一下結果

> a = A.new
=> #<A:0x00007f98f923c078>
> a.set_d
=> 4000
> a.set_c
=> 3000
> a.instance_variable_get('@d')
=> 4000
> a.instance_variable_get('@c')
=> 3000
複製程式碼

b. instance_eval

通過instance_eval可以在當前例項的上下文中執行程式碼片段,我們先簡單地定義一個類B

class B
  attr_accessor :a, :b, :c, :d, :e
  def initialize(a, b, c, d, e)
    @a = a
    @b = b
    @c = c
    @d = d
    @e = e
  end
end
複製程式碼

例項化之後,分別用不同的方式來求得例項變數每個例項屬性相加的值

> k = B.new(1, 2, 3, 4, 5)
 => #<B:0x00007f999fa2c480 @a=1, @b=2, @c=3, @d=4, @e=5>

> puts k.a + k.b + k.c + k.d + k.e
15

> k.instance_eval do
>   puts a + b + c + d + e
> end

15
複製程式碼

這只是個簡單的例子,在一些場景中還是比較有用的,比如可以用它來定義單例方法

> k.instance_eval do
>   def sum
>     @a + @b + @c + @d + @e
>   end
> end

> k.sum
=> 15

> B.methods.grep :sum
 => []
複製程式碼

我們們依舊可以採用最原始的eval方法來實現類似的功能,這裡暫不贅述。

安全問題

對於動態語言來說eval是一個很強大的功能,但隨之也帶來了不少的安全問題,最麻煩的莫過於程式碼注入了。假設你的程式碼可以用來接收使用者輸入

# string_handling.rb
def string_handling(method)
  code = "'hello world'.#{method}"
  puts "Evaluating: #{code}"
  eval code
end

loop { p string_handling (gets()) }
複製程式碼

如果我們的使用者都是善意使用者的話,那沒有什麼問題。

> ruby string_handling.rb
slice(1)
Evaluating: 'hello world'.slice(1)
"e"

upcase
Evaluating: 'hello world'.upcase
"HELLO WORLD"
複製程式碼

But,如果一個惡意的使用者輸入了下面的內容

slice(1); require 'fileutils'; FileUtils.rm_f(Dir.glob("*"))
複製程式碼

那是不是有點好玩了?假設執行指令碼的系統角色有足夠的許可權,那麼當前目錄下的所有東西都會被刪除殆盡。利用動態發派來實現類似的功能或許更加安全一些

# string_handling_better.rb
def string_handling_better(method, *arg)
 'hello world'.send(method, *arg)
end
複製程式碼

我們可以對使用者的輸入先進行預處理,然後再把它傳遞到定義好的string_handling_better方法中去。

> string_handling_better('slice', 1, 10)
 => "ello world"
複製程式碼

尾聲

這篇文章分別從不同的角度談論了eval,以及它的一些變種。它是一個很強大的功能,不過能力越大責任越大,相應的還會帶來一定的風險,若使用不當會引發系統問題。現實程式設計生活當中,直接使用eval的場景並不多,畢竟程式碼寫在字串裡面的話,少了編輯器的語法高亮還是會為程式設計師帶來不少困擾。不過採用class_eval/module_eval來開啟類/模組,並以程式碼塊的方式來定製邏輯的案例倒是數見不鮮。

相關文章