为什么eval被认为是“邪恶”的?

143
我知道Lisp和Scheme程序员通常会说,除非绝对必要,否则应避免使用eval。我看到过几种编程语言的相同建议,但我还没有看到一份明确反对使用eval的论据清单。在哪里可以找到关于使用eval的潜在问题的说明呢?
例如,我知道在过程式编程中使用GOTO会带来问题(使程序难以阅读和维护,使安全问题难以发现等),但我从未见过反对eval使用的论据。
有趣的是,与GOTO使用的相同论点也适用于continuations,但我看到Schemers不会说continuations是“邪恶的”--你只需要在使用它们时小心。他们更可能对使用eval的代码表示不满,而不是对使用continuations的代码(就我所见--我可能错了)。

5
"eval不是邪恶的,但它所执行的代码可能是邪恶的。" - Anurag
9
我认为你的评论体现了一种非常单一分派对象中心的世界观。这在大多数编程语言中可能是有效的,但在Common Lisp中则不同,其中方法不属于类,而在Clojure中则更为不同,因为类仅通过Java互操作函数进行支持。Jay将此问题标记为Scheme,它没有任何内置的类或方法概念(各种形式的面向对象编程可作为库使用)。 - Zak
3
@Zak,你是正确的,我只懂我知道的语言,但即使你在使用Word文档而没有使用样式,你也不是DRY。我的观点是利用技术不重复自己。 OO不是普遍适用的,这是真的... - Dan Rosenstark
4
我已经擅自给这个问题加上了Clojure标签,因为我认为Clojure用户可能会从这里发布的优秀答案中受益。 - Michał Marczyk
...对于Clojure而言,至少还有一个额外的原因:您将失去与ClojureScript及其衍生版本的兼容性。 - Charles Duffy
2
“goto”是“邪恶”的,因为它是一种变异形式:实际上,一个新值会突然地被分配给指令指针。Continuations不涉及变异;纯函数语言可以使用continuations。它们比诸如if和while这样的控制结构更加纯粹,尽管Dijkstra认为if和while只是对goto和标签的轻微语法糖而已。 - Kaz
12个回答

154

不应该使用EVAL的原因有几个。

对于初学者来说,主要原因是:你不需要它。

例如(假设是Common Lisp):

使用不同操作符计算表达式:

(let ((ops '(+ *)))
  (dolist (op ops)
    (print (eval (list op 1 2 3)))))

这可以更好地书写为:

(let ((ops '(+ *)))
  (dolist (op ops)
    (print (funcall op 1 2 3))))

有很多学习Lisp的初学者认为需要使用EVAL,但实际上并不需要,因为表达式会被求值,函数部分也可以求值。大多数情况下使用EVAL表明缺乏对求值器的理解。
宏的情况也是相同的。初学者经常编写宏,而应该编写函数 - 不理解宏的真正用途并且不理解函数已经完成了工作。
通常使用EVAL是选错工具,并且通常表明初学者不理解通常的Lisp求值规则。
如果您认为需要EVAL,请检查是否可以使用FUNCALLREDUCEAPPLY替代。
- FUNCALL - 使用参数调用函数:(funcall '+ 1 2 3) - REDUCE - 对一组值调用函数并组合结果:(reduce '+ '(1 2 3)) - APPLY - 以列表作为参数调用函数:(apply '+ '(1 2 3)) 问:我是否真的需要eval,编译器/求值器已经知道我想要什么了吗?
对于略微高级的用户而言,避免使用EVAL的主要原因有:
- 您希望确保代码已编译,因为编译器可以检查代码中的许多问题并生成更快的代码,有时是非常非常非常(这是1000倍的因数;-))快的代码。 - 构造并需要求值的代码可能无法尽早地编译。 - 任意用户输入的EVAL开启了安全问题 - 使用EVAL进行某些求值可能发生在错误的时间并创建构建问题。
以下是一个简化的例子来解释最后一点:
(defmacro foo (a b)
  (list (if (eql a 3) 'sin 'cos) b))

因此,我可能想编写一个宏,根据第一个参数使用 SINCOS

(foo 3 4) 执行 (sin 4),而 (foo 1 4) 执行 (cos 4)

现在我们可能有:

(foo (+ 2 1) 4)

这样做并不能得到期望的结果。

此时,您可能希望通过对变量进行EVAL操作来修复宏FOO

(defmacro foo (a b)
  (list (if (eql (eval a) 3) 'sin 'cos) b))

(foo (+ 2 1) 4)

但是这仍然不起作用:

(defun bar (a b)
  (foo a b))

变量的值在编译时是未知的。

避免使用 EVAL 的一个普遍重要原因:它经常被用于丑陋的 hack。


3
谢谢!我只是不理解最后一点(评估在错误的时间?)-- 你能否再详细解释一下吗? - Jay
44
+1是正确答案,人们会回到“eval”只是因为他们不知道有特定的语言或库功能可以完成他们想要做的事情。类似的例子在JS中也有:我想使用动态名称从对象中获取属性,所以我写了eval("obj.+" + propName),但我本可以写成obj[propName] - Daniel Earwicker
2
@Daniel 可能是指 eval("obj." + propName),这应该按预期工作。 - claj
嘿,你在叫谁丑陋的黑客?! - Darren Ringer
何时使用eval:用户需要在交互式应用程序中输入代码。使用内置库的解决方法通常很难理解/冗长。何时使用宏:性能(特定领域编译)一旦确定瓶颈,第三方库包装器代码生成。这些情况很少见,也许总共不到5%的使用次数。 - Kevin Kostlan
显示剩余7条评论

44

eval(在任何语言中)并不像电锯那样是邪恶的。它是一个工具。它恰好是一个功能强大的工具,如果被错误使用,可能会割掉四肢和开膛破肚(比喻性地说),但同样适用于程序员工具箱中的许多工具,包括:

  • goto 及其相关操作
  • 基于锁的线程管理
  • continuations(续体)
  • 宏(卫生或其他)
  • 指针
  • 可重复异常
  • 自修改代码
  • ...还有更多。

如果你发现自己必须使用这些功能强大且潜在危险的工具,请在链式问题中反复自问三次“为什么?”。例如:

“为什么我必须使用eval?” “因为foo。” “为什么foo是必要的?” “因为......”

如果你在这个链式问题的结尾发现这个工具看起来确实是正确的选择,请使用它。做好文档工作。进行充分的测试。一遍又一遍地检查正确性和安全性。然后再去做。


谢谢 - 这就是我之前听说过的 eval(“问问自己为什么”),但我从未听说或阅读过潜在的问题。从这里的答案中,我现在知道它们是什么(安全和性能问题)。 - Jay
8
代码可读性。Eval可以完全破坏代码的结构,使其难以理解。 - JUST MY correct OPINION
我不明白为什么你的列表中有“基于锁的线程”[sic]。并发的形式不一定涉及锁,而且锁的问题通常是众所周知的,但我从未听说过有人将使用锁描述为“邪恶”。 - asveikau
4
Lock-based threading(基于锁的线程)是出了名难以正确实现的(我猜99.44%使用锁的生产代码都有问题)。它不可组合,容易将“多线程”代码转换为串行代码。(对这个问题进行修正只会使代码变得缓慢而臃肿。)存在着很好的替代方案,如STM或actor模型,除了在最底层的代码中使用,否则使用它都是邪恶的。 - JUST MY correct OPINION
“为什么链” :) 确保在3步之后停止,否则可能会受伤。 - szymanowski
显示剩余2条评论

28

EVAL可以使用,但必须准确知道要输入什么。任何进入EVAL的用户输入都必须进行检查和验证。如果不知道如何确保100%安全,请勿使用。

基本上,用户可以输入所涉及语言的任何代码,并将其执行。你可以自己想象他能造成多少破坏。


1
所以,如果我实际上是基于用户输入使用算法生成 S表达式,并且如果在某些特定情况下这比使用宏或其他技术更容易和更清晰,那么我想这没有什么“邪恶”的吧?换句话说,eval的唯一问题与直接使用用户输入的SQL查询和其他技术相同? - Jay
10
之所以称之为“邪恶”,是因为相比于其他做错事情,做这件事情错得更加严重。而我们都知道,新手总会犯错误。 - Tor Valamo
3
并不是说在所有情况下都必须在执行代码之前对其进行验证。例如,实现一个简单的REPL时,你可能只需将输入直接传递给未经检查的eval函数,这并不会有问题(当然,如果编写基于Web的REPL,则需要一个沙盒,但对于运行在用户系统上的普通CLI-REPLs而言,情况并非如此)。 - sepp2k
1
就像我说的那样,你必须确切地知道当你将什么输入eval中时会发生什么。如果这意味着“它将在沙盒限制内执行一些命令”,那就是它的意思。;) - Tor Valamo
@TorValamo 你听说过越狱吗? - Loïc Faure-Lacroix

23

“何时应该使用eval?”可能是一个更好的问题。

简短的答案是“当你的程序旨在在运行时编写另一个程序,然后运行它时”。遗传编程是一个例子,这种情况下使用eval很有意义。


完美的答案。 - Marc
在这种情况下,为什么要使用eval,如果我们可以进行compile,然后再进行funcall呢? - Will Ness

15

我认为,这个问题并不特定于LISP。以下是关于PHP的相同问题的答案,它同样适用于LISP、Ruby和其他支持eval函数的语言:

Eval() 的主要问题有:

  • 可能存在不安全的输入。传递不受信任的参数是一种失败的方式。通常确保参数(或其中的一部分)完全可靠并不是一个简单的任务。
  • 过于巧妙。使用eval()会使代码更加聪明,因此更难以跟踪。引用Brian Kernighan的话:“调试比写代码本身困难两倍。因此,如果您尽可能巧妙地编写代码,则根据定义,您不够聪明,无法调试它。”

实际使用eval()的主要问题仅有一个:

  • 经验不足的开发人员没有充分考虑就使用它。

摘自这里

我认为“过于巧妙”是一个很棒的观点。对于这个问题,代码高尔夫和简洁的代码总是会导致“聪明”的代码(对于这些,eval函数是一个很好的工具)。但在我看来,应该为可读性而编写代码,而不是展示你有多聪明,更不是为了“节约纸张”(你不会打印它的)。

此外,在LISP中,与eval相关的一些问题与其运行的上下文有关,因此不受信任的代码可能会获得更多的访问权限;然而,这个问题似乎很普遍。


4
使用EVAL时的“恶意输入”问题只会影响非Lisp语言,因为在这些语言中,eval()通常需要一个字符串参数,而用户的输入通常被拼接到其中。用户可以在其输入中包含引号并逃逸到生成的代码中。但是在Lisp中,EVAL的参数不是一个字符串,用户的输入不能够逃逸进入代码,除非您非常鲁莽(例如,您使用READ-FROM-STRING解析输入创建了一个S表达式,然后将其包含在EVAL代码中而没有对其进行引用。如果您对其进行引用,就没有办法逃脱引号)。 - Throw Away Account

12
规范的回答是远离eval。我认为这很奇怪,因为它是一个原语,与其他七个原语(其他原语为cons、car、cdr、if、eq和quote)相比,它在使用和喜爱方面远远落后。
来自《On Lisp》的一段话:“通常,显式地调用eval就像在机场礼品店购物。等到最后一刻,你不得不为有限的二流商品付出高昂的代价。”
那么我什么时候会使用eval呢?正常使用之一是通过评估(loop (print (eval (read))))在REPL中拥有一个REPL。每个人都能接受这种用法。
但是,您还可以使用反引号将宏与eval结合使用,以便在编译之后进行评估来定义函数。
(eval `(macro ,arg0 ,arg1 ,arg2))))

而它会为您杀死上下文。

Swank(用于emacs slime)充满了这些情况。它们看起来像这样:

(defun toggle-trace-aux (fspec &rest args)
  (cond ((member fspec (eval '(trace)) :test #'equal)
         (eval `(untrace ,fspec))
         (format nil "~S is now untraced." fspec))
        (t
         (eval `(trace ,@(if args `(:encapsulate nil) (list)) ,fspec ,@args))
         (format nil "~S is now traced." fspec))))

我不认为这是一种肮脏的黑客行为。我经常自己使用它来将宏重新整合到函数中。


1
你可能想要查看内核语言 ;) - artemonster

12

已经有许多优秀的答案,但是这里还有Matthew Flatt的另一种看法,他是Racket的实现者之一:

http://blog.racket-lang.org/2011/10/on-eval-in-dynamic-languages-generally.html

他提出了许多已经涉及到的观点,但仍有些人可能会发现他的观点有趣。

简介:eval的使用环境会影响结果,但程序员通常没有考虑到这一点,导致意外的结果。


7

关于Lisp eval的另外两点:

  • 它在全局环境下进行评估,会丢失你的本地上下文。
  • 有时候你可能会想使用eval,但实际上你应该使用读取宏“#.”,它在读取时进行评估。

我了解在Common Lisp和Scheme中使用全局环境是正确的,那么Clojure也是这样吗? - Jay
2
在Scheme中(至少对于R7RS,可能也适用于R6RS),您必须将环境传递给eval函数。 - csl

4

Eval函数不安全。例如,您有以下代码:

eval('
hello('.$_GET['user'].');
');

现在用户来到您的网站并输入URL:http://example.com/file.php?user=);$is_admin=true;echo(

那么生成的代码将是:

hello();$is_admin=true;echo();

6
他谈论的是Lisp而非PHP。 - fmsf
4
他具体谈论了Lisp语言,但总体上讲了任何有eval函数的编程语言。 - Skilldrick
4
@fmsf - 这其实是一个与语言无关的问题。即使对于静态编译语言,它们也可以通过在运行时调用编译器来模拟 eval。 - Daniel Earwicker
1
在这种情况下,语言是重复的。我在这里看到了很多类似的情况。 - fmsf
9
PHP的eval函数和Lisp的eval函数并不相同。它对一个字符字符串进行操作,而URL中的漏洞取决于能够关闭文本括号并打开另一个括号。Lisp的eval函数不容易受到这种攻击。如果你以正确的方式对来自网络的数据进行沙盒处理(且该结构足够容易遍历),那么你可以评估这些数据。 - Kaz
显示剩余3条评论

4

就像GOTO“规则”一样:如果你不知道自己在做什么,可能会弄得一团糟。

除了只使用已知和安全的数据来构建某些东西之外,还存在这样一个问题,即某些语言/实现无法对代码进行足够的优化。你最终可能会在eval中得到解释性代码。


那个规则和GOTO有什么关系?有没有任何编程语言的特性,你无法搞砸它? - Ken
2
@Ken:我的回答中有引号,是因为没有GOTO规则。只有那些害怕独立思考的人才会坚持不用它。eval也是一样的。我记得曾经通过使用eval大幅加速了某些Perl脚本的执行速度。它只是你工具箱中的一个工具而已。新手程序员在其他语言结构更简单更好用时常常使用eval。但是完全避免使用它只是为了装酷和迎合教条主义者的心理? - stesch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接