请解释一下 Paul Graham 在 Lisp 方面的一些观点

150

我需要帮助理解一些来自保罗·格雷厄姆《Lisp与其他语言的区别》的观点。

  1. 新的变量概念。在Lisp中,所有变量实际上都是指针。数值有类型,而不是变量,赋值或绑定变量意味着复制指针,而不是指向的内容。

  2. 符号类型。符号与字符串的区别在于可以通过比较指针来测试它们是否相等。

  3. 使用符号树表示代码的符号标记。

  4. 整个语言始终可用。不存在真正的阅读时间、编译时间和运行时间之间的区别。您可以在阅读时编译或运行代码,在编译时读取或运行代码,在运行时读取或编译代码。

这些观点意味着什么?在C或Java等语言中,它们如何不同?现在除了Lisp系语言外,是否有其他语言具有这些结构构造?


10
我不确定在这里使用函数式编程标签是否合适,因为在许多 Lisp 中编写命令式或面向对象的代码可能也同样可行,事实上有很多非函数式的 Lisp 代码存在。我建议您删除函数式编程标签并添加 Clojure 标签--希望这可以吸引基于 JVM 的 Lisp 程序员提供一些有趣的内容。 - Michał Marczyk
4个回答

100
马特的解释很好,他尝试进行了一些与C和Java的比较,我不打算再这样做,但由于某种原因,我偶尔会非常喜欢讨论这个话题,所以这是我来回答的机会。
关于第(3)和(4)点:
你的清单中的第(3)和(4)点似乎是最有趣且至今仍然相关的。
要理解它们,有一个清晰的图片是有用的——即 Lisp 代码,以程序员键入的字符流的形式,在执行之前的处理过程。 我们使用具体的例子来说明:
;; a library import for completeness,
;; we won't concern ourselves with it
(require '[clojure.contrib.string :as str])

;; this is the interesting bit:
(println (str/replace-re #"\d+" "FOO" "a123b4c56"))

这段Clojure代码的输出为aFOObFOOcFOO。需要注意的是,Clojure可能无法完全满足您列出的第四点,因为读取时间不真正开放给用户代码;但我将讨论如果情况改变会意味着什么。假设我们在某个文件中拥有这段代码,并要求Clojure执行它。此外,出于简单起见,假设我们已经通过库导入。有趣的部分从(println开始,到极右侧的)结束。这被词法分析/解析为预期的方式,但是一个重要的问题已经出现:结果不是某些特殊的编译器特定AST表示 - 它只是一个普通的Clojure / Lisp数据结构,即一个嵌套列表,其中包含一堆符号、字符串和 - 在这种情况下 - 一个对应于#"\d+"字面量的单个编译正则表达式模式对象(稍后详细说明)。一些Lisps会在这个过程中添加自己的小扭曲,但Paul Graham大多是指Common Lisp。在涉及您的问题的要点上,Clojure类似于CL。

编译时的整个语言:

在这一点上,编译器处理的所有内容(同样适用于Lisp解释器;Clojure代码始终被编译)都是Lisp程序员习惯于操作的Lisp数据结构。此时,一个美妙的可能性变得明显:为什么不允许Lisp程序员编写操纵表示Lisp程序的Lisp数据并输出表示转换后程序的转换数据的Lisp函数,以代替原始函数?换句话说——为什么不允许Lisp程序员将其函数注册为编译器插件(称为Lisp中的宏)?事实上,任何像样的Lisp系统都具备这种能力。
因此,宏是常规的Lisp函数,在最终编译阶段发出实际目标代码之前,在编译时操作程序的表示。由于宏所允许运行的代码种类没有限制(特别是,它们运行的代码本身经常使用宏设施自由编写),因此可以说“整个语言在编译时都可用”。
整个语言在读取时:
让我们回到那个#"\d+"正则表达式字面量。如上所述,这在编译器准备编译新代码之前在读取时转换为一个实际的已编译模式对象。这是如何发生的呢?
好的,Clojure目前的实现方式与Paul Graham所想的有些不同,虽然通过巧妙的hack可以实现任何可能性。在Common Lisp中,概念上会稍微清晰一些。基本原理类似:Lisp Reader是一个状态机,除了执行状态转换并最终声明是否已到达“接受状态”外,还会输出字符表示的Lisp数据结构。因此,字符123变成数字123等。现在重要的是:用户代码可以修改这个状态机。(正如之前提到的,在CL的情况下完全正确;对于Clojure,则需要使用hack(不鼓励且实际上不使用)。但我偏题了,我应该详细说明PG的文章...)
所以,如果你是一位Common Lisp程序员,并且你喜欢Clojure风格的向量字面量的想法,你可以向读取器插入一个函数来适当地响应某些字符序列——可能是[#[——并将其视为向量字面量的开始,直到匹配的]结束。这样的函数称为读取器宏,就像常规宏一样,它可以执行任何类型的Lisp代码,包括使用先前注册的读取器宏启用的时髦符号编写的代码。因此,在读取时,整个语言都在那里。

总结:

实际上,迄今为止展示的是可以在读取时间或编译时间运行常规的Lisp函数;从这里到理解如何在读取、编译或运行时进行读取和编译本身只需要一步,那就是意识到读取和编译本身是由Lisp函数执行的。你可以随时调用readeval从字符流中读取Lisp数据或编译和执行Lisp代码。整个语言就在那里,一直存在。
注意,Lisp满足您列表中的第三点对其满足第四点的方式至关重要 - Lisp提供的宏的特定风格在很大程度上依赖于代码由常规Lisp数据表示,这是通过(3)实现的。顺便说一下,这里真正关键的只是代码的“树状”方面 - 您可以想象使用XML编写的Lisp。

4
注意:使用“常规(编译器)宏”这个词汇,你就接近于暗示编译器宏是“常规”的宏。但至少在Common Lisp中,“编译器宏”是一种非常特定且不同的东西。参考链接:http://www.lispworks.com/documentation/lw51/CLHS/Body/26_glo_c.htm#compiler_macro - Ken
Ken:好发现,谢谢!我会把它改成“普通宏”,我认为这不太可能使任何人困惑。 - Michał Marczyk
非常棒的回答。我从中学到的比花费数小时在谷歌上搜索/思考问题要多得多。谢谢。 - Charlie Flowers
编辑:啊,误解了一个长句子。修正了语法(需要一个“同行”接受我的编辑)。 - Tatiana Racheva
S表达式和XML可以指定相同的结构,但是XML更冗长,因此不适合作为语法。 - Sylwester

68

1) 变量的新概念。在Lisp中,所有变量都是指针。值有类型,而不是变量,分配或绑定变量意味着复制指针,而不是它们所指向的内容。

(defun print-twice (it)
  (print it)
  (print it))

'it' 是一个变量。它可以绑定到任何值,没有限制和类型与变量相关联。如果调用函数,不需要复制参数。变量类似于指针,有一种方法可以访问绑定到变量的值,并且无需保留内存。我们在调用函数时可以传递任何数据对象:任何大小和任何类型。
数据对象具有“类型”,所有数据对象都可以查询其“类型”。
(type-of "abc")  -> STRING

2) 符号类型。与字符串不同的是,你可以通过比较指针来测试相等性。

符号是带有名称的数据对象。通常可以使用名称来查找对象:

|This is a Symbol|
this-is-also-a-symbol

(find-symbol "SIN")   ->  SIN

由于符号是实际的数据对象,我们可以测试它们是否为同一对象:

(eq 'sin 'cos) -> NIL
(eq 'sin 'sin) -> T

这使我们可以使用符号来编写句子,例如:
(defvar *sentence* '(mary called tom to tell him the price of the book))

现在我们可以统计句子中“THE”的数量:
(count 'the *sentence*) ->  2

在Common Lisp中,符号不仅有名称,还可以具有一个值、一个函数、一个属性列表和一个包。因此,符号可用于命名变量或函数。属性列表通常用于向符号添加元数据。
3)使用符号树的代码表示方法。
Lisp使用其基本数据结构来表示代码。
列表(* 3 2)既可以是数据也可以是代码:
(eval '(* 3 (+ 2 5))) -> 21

(length '(* 3 (+ 2 5))) -> 3

树:
CL-USER 8 > (sdraw '(* 3 (+ 2 5)))

[*|*]--->[*|*]--->[*|*]--->NIL
 |        |        |
 v        v        v
 *        3       [*|*]--->[*|*]--->[*|*]--->NIL
                   |        |        |
                   v        v        v
                   +        2        5

4) 整个语言始终可用。读取时间、编译时间和运行时间之间没有真正的区别。您可以在阅读时编译或运行代码,编译时阅读或运行代码以及运行时阅读或编译代码。

Lisp提供了READ函数从文本中读取数据和代码,LOAD函数加载代码,EVAL函数评估代码,COMPILE函数编译代码和PRINT函数将数据和代码写入文本。

这些函数始终可用。它们不会消失。它们可以成为任何程序的一部分。这意味着任何程序都可以始终读取、加载、评估或打印代码。

在像C或Java这样的语言中,它们有何不同之处?

这些语言不提供符号、代码作为数据或数据作为代码的运行时评估。C中的数据对象通常是无类型的。

除了LISP家族语言之外,还有其他语言现在具有这些结构吗?

许多语言具有其中一些能力。

区别:

在Lisp中,这些能力被设计成易于使用的语言特性。


35

对于第(1)和(2)点,他是在历史上进行讨论。Java的变量基本相同,这就是为什么你需要调用.equals()来比较值。

第(3)点讨论S表达式。Lisp程序采用这种语法编写,这种语法相对于像Java和C这样的临时语法提供了许多优势,例如通过宏捕获重复模式,远比C宏或C++模板更清晰地操作代码,并使用与数据相同的核心列表操作操作代码。

以C为例,第(4)点:该语言实际上是两种不同的子语言:if()和while()等语句以及预处理器。您可以使用预处理器来避免一直重复自己,或者使用#if/#ifdef跳过代码。但是,这两种语言是相当独立的,您无法像使用#if那样在编译时使用while()。

C++通过模板使情况变得更糟。查看一些关于模板元编程的参考资料,它提供了一种在编译时生成代码的方式,并且对于非专家来说非常难以理解。此外,这实际上是使用模板和宏的一堆技巧和技巧,编译器无法为其提供一流支持——如果您犯了一个简单的语法错误,编译器无法为您提供明确的错误消息。

好吧,使用Lisp,您可以在一个单一的语言中拥有所有这些。您可以使用相同的东西在运行时生成代码,就像您在第一天学习时一样。这并不是要说元编程很容易,但是使用一流的语言和编译器支持肯定更加直观。


7
此外,这种能力(和简洁性)已经有50多年的历史了,并且很容易实现,即使是初学者也可以在最少的指导下完成它,并了解语言基础知识。对于Java、C、Python、Perl、Haskell等编程语言来说,你不会听到类似的声称它们适合初学者作为好的项目! - Matt Curtis
9
我不认为Java变量和Lisp符号相似。Java中没有符号的表示法,而且你只能获取变量的值。字符串可以被intern(因为它们可能被多次使用),但通常它们不是名称,所以谈论它们是否可以被引用、评估、传递等甚至都没有意义。 - Ken
2
可能超过40岁更准确 :), @Ken: 我认为他的意思是1) Java中的非原始变量是按引用传递的,这与Lisp类似,2) Java中的内部化字符串类似于Lisp中的符号 - 当然,就像你所说的,你不能在Java中引用或评估内部化的字符串/代码,因此它们仍然有很大的不同。 - user21037
4
@Dan - 不确定第一次实现是何时,但关于符号计算的最初麦卡锡论文发表于1960年。 - Inaimathi
Java确实在Foo.class / foo.getClass()的形式上部分/不规则地支持“符号”——即类型为Class<Foo>的对象有点类似——枚举值也是如此。但是它只有非常少量的Lisp符号的影子。 - BRPocock
Java是一种静态类型语言,其本地类型与对象行为不同存在缺陷,无法满足(1)的要求。LISP通常具有强类型(不像PHP那样自动转换值),但与Java的静态类型相比也具有动态类型。 - Sylwester

-4

第(1)点和第(2)点也适用于Python。以简单的例子“a = str(82.4)”为例,解释器首先创建一个浮点对象,其值为82.4。然后调用字符串构造函数,它返回一个值为'82.4'的字符串。左侧的'a'仅是该字符串对象的标签。原始的浮点对象已被垃圾回收,因为没有更多的引用。

在Scheme中,所有东西都以类似的方式被视为对象。我不确定Common Lisp。我会避免用C / C ++概念来思考。当我试图理解Lisps的美丽简洁时,它们让我变得非常缓慢。


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