是什么让Lisp难以阅读?

Lisp 的括号为什么比 C 系语法更费眼。作者用格式塔邻近律等认知科学的观察来解释,C 风格靠逗号和空格把参数分组,Lisp 里每个 token 都被同样的空格隔开,间距几乎不携带分组信息,读起来自然要多花解析成本。

中文
复制

或者说,在你设计下一门语言时,括号该放在哪儿

Lisp 系编程语言因括号密集的语法而臭名昭著。许多人——包括我自己——都觉得这种语法比 Python、Java 或 C 的写法更难读。Lisp 难读的印象如此根深蒂固,以至于人们多次尝试创造新写法来帮助 Lisp 推广。然而,尽管有这些尝试,尽管 Lisp 几乎和计算机科学本身一样古老,我至今仍未遇到一个真正令人满意的解释,说明 Lisp 为什么会被认为可读性差。

Lisp 开发者通常认为这纯粹是熟悉度问题:Lisp 写法不如 C 写法常见,程序员自然读得少。据说一旦习惯了用 Lisp 编程,你甚至不会注意到括号。我认为事情没那么简单,深入探究或许能让我们了解如何设计出令人愉悦的语法。本文中,我将借助认知科学的一些观察,解释我的理论:为什么 Lisp 的写法——具体说是它放置括号的方式——需要更多解析成本。

如果你不熟悉 Lisp 的写法:在典型的 Lisp 程序中,每条语句、每个表达式、每次函数调用都写成同一种语法:(operation arguments...)。对比一下 Lisp(Scheme)和 JavaScript 中递归阶乘函数的定义:

(define (factorial n)
 (if (= n 1)
 n
 (* n (factorial (- n 1)))))

Scheme 风格的阶乘定义

function factorial(n) {
 if (n == 1) {
 return n;
 } else {
 return (n * factorial(n - 1));
 }
}

C 风格的阶乘定义(JavaScript)

你会注意到,Lisp 示例中没有中缀写法(n == 1)、没有关键字(else),也没有不同类型的定界符({})。在关于可读性的讨论中,这些差异往往最受关注。我同意这些写法对可读性有一定影响,但我怀疑它们的重要性被夸大了;除了熟悉度之外,我并不觉得 (- n 1) 明显劣于 (n - 1)。

C 系语言在函数调用等场景中拥有多种写法,这种语法上的多样性很可能对可读性有显著影响。我打算另写一篇文章探讨这方面。本文主要关注比较普通函数调用时仍然存在的差异,也就是:为什么 operation(arguments ...) 看起来比 (operation arguments...) 更顺眼?

人类感知中的硬件加速

要理解括号位置为什么重要,我们必须考虑到:人类感知并非对所有输入一视同仁。想亲身体验这一点,试着尽快大声说出下面这些词的显示颜色:

你会发现第二行——文字颜色与词义不符的那行——读起来更慢。这就是斯特鲁普效应。人们已经发现了多种类似的效应,它们可能阻碍或帮助视觉处理任务。我喜欢把这些效应看作感知机制内置的某种硬件加速。¹ 和编程一样,我们应该尽量发挥硬件的长处。

举例来说,看看下面同一组数据的两种可视化。我在数据里藏了两个离群点。其中一种可视化用了一个“硬件加速”技巧,让离群点更容易被发现:

第二张可视化里的离群点,你肯定能比第一张更快找出来。事实上,纯数据呈现的搜索任务大约要花 𝑂(𝑛),而硬件加速的搜索奇迹般地以 𝑂(1) 结束。好的设计会利用这一效应让界面更好检索,比如用形状不同的图标和强调色。我选择 Stroop 效应和弹出效应来说明人类的硬件加速,是因为这两种效应足够明显、容易复现。可惜认知科学的研究发现并非都这么一目了然或定义清晰。应用这类效应需要一定的创造力和主观判断。所以我并不打算主张可读性完全能由接下来要讨论的认知效应客观解释。² 我援引这些效应,是让它们充当设计原则,并为我自己的观察提供支持。

参数邻近性

回到记法,语法的核心目标是表明 token 应该如何分组。格式塔心理学描述过若干种我们天生倾向感知的分组方式。其中最具体的一条是格式塔邻近律。这条“定律”说的是:彼此靠近的物体会被感知为相关。例如下面又是同一份数据的两种呈现。想办法把数据点分成两组:第二张可视化清楚地暗示数据由沿 x 轴划分的两组构成。邻近律所描述的“硬件加速”让这种划分比在原始数据中更容易被感知到。由于括号和逗号的位置制造了间距,C 风格记法比 Lisp 风格记法更常符合邻近律。只有一个参数的 C 风格函数调用不需要空格。这让这类调用被感知为一个视觉整体。相比之下,多参数调用用逗号和空格分隔每个参数。Lisp 风格调用没有间距变化;每个函数和参数都由同样的空格分隔。于是间距几乎不传递分组信息。比如,看下面两个用不同记法写成的相同函数调用。第三和第四个样本是前两个的模糊版本,用来突出邻近效应:

(AAAAA (BBBBB (CCCCC)) (DDDDD) (FFFFF (GGGGG (HHHHH))))

Lisp

AAAAA(BBBBB(CCCCC()), DDDDD(), FFFFF(GGGGG(HHHHH())))

C

(AAAAA (BBBBB (CCCCC)) (DDDDD) (FFFFF (GGGGG (HHHHH))))

Lisp(模糊)

AAAAA(BBBBB(CCCCC()), DDDDD(), FFFFF(GGGGG(HHHHH())))

C(模糊) 注意 C 片段模糊后由三块组成。这些块传达了代码的大致结构,即便在余光中也是如此。在 Lisp 样本里,七个标识符模糊成各自独立的块;余光中没有任何分组迹象。

分隔符邻近性

另外两条与括号分组相关的格式塔定律是闭合律和对称律。闭合律指出,一个形状的碎片会通过在心里补全碎片之间的空隙而被感知为单一实体。也就是说,两个括号构成一个视觉分组,因为它们组成一个椭圆。对称律则简单地说,对称的组件(比如分隔符)会被感知为属于同一视觉分组。 闭合律:通过补全各组件之间的空隙,感知到一个单一形状(圆)。 对称律:对称的形状被感知为属于同一组(涵盖邻近律)。 要从这些分组效应中获益,配对的括号形状必须清晰可辨。而大多数时候其实并非如此。视网膜上感光受体分辨率最高的部分只覆盖大约两度的视野。这意味着在距显示器 70 厘米处,只有直径约 2.5 厘米的区域足够清晰,能舒适地阅读。你的大脑把这段模糊文字藏了起来,但如果你试着不移动眼睛去读后面几个词,就能看到它。

Lisp 把函数名放在左括号之后,括号之间的距离因此更大,也就更不容易被眼睛当成一个整体形状扫过去。这个距离有时相当可观,因为 Lisp 的函数名往往很长,比如 make-string-output-stream 和 call-with-current-continuation。举个例子,对比下面 C 和 Lisp 两种写法下的调用。我在左括号周围加了一层径向模糊,用来夸张地模拟眼睛在余光区域的分辨率衰减:

(long-function-name argument)

backdrop-filter: blur(0.11rem);"> Lisp 风格

long-function-name(argument)

backdrop-filter: blur(0.11rem);">

C 风格

在 C 代码片段里,从开括号就能一眼看到闭括号,借助闭合律和邻近律,一次扫视即可完成配对。Lisp 代码片段则要求你一直记着开括号,直到闭括号进入视野。

如果把条件、循环这类语言原语的写法拿来对比,Lisp 中括号之间的距离还要更大:

(if condition consequent alternative)

Lisp 风格

if (condition) {consequent} {alternative}

C 风格

C 风格的 if 分隔符更多,却更好读,因为分隔符彼此挨得近得多。

有些语言允许把最后一个参数的分隔符写在函数括号之后。这种替代写法同样让成对的分隔符靠得更近:

repeat(5, { body })

Kotlin 通用写法

repeat(5) { body }

Kotlin「尾随 lambda」写法

text(fill: red, [hello world])

Typst 通用写法

text(fill: red)[hello world]

Typst 尾随写法

就我的经验而言,分隔符靠得更近的写法用得频繁得多。我把它当作一条经验证据:程序员确实更希望括号彼此靠近。

闭括号

举个例子,对比下面两种闭括号的排版方式。左边把所有闭括号堆在同一行,形成一坨难以计数的括号。右边让每个括号各占一层缩进。这种排版下,不用数也能看出开括号和闭括号数量是否匹配。

(f (g (h (i (j)))))

Lisp 排版

(f
 (g
 (h
 (i
 (j)
 )
 )
 )
)

Java 排版

再看一个更贴近真实场景的程序:

(define (factorial n)
 (if (= n 1)
 n
 (* n (factorial (- n 1)))))

Lisp 排版

(define (factorial n)
 (if (= n 1)
 n
 (* n (factorial (- n 1)))
 )
)

Java 排版

心智栈

除了这些视觉层面的因素,可读性还受人类记忆特性的影响。工作记忆最多只能存下三到五个条目。试着在脑子里把手机号倒着背一遍,你就能体会到这个上限。读一个嵌套表达式时,我们必须记住每一层表达式的上下文。这和解析器或求值器里的栈没什么两样。因此我喜欢把工作记忆看作心智栈。记法应当尽量少动用心智栈,因为它只能存下寥寥几个条目。

视觉嵌套

抛开前面提到的视觉技巧不谈,一般来说,每个开启符号都需要被记住,直到它被闭合。正是这种记忆负担让深层嵌套的表达式难以阅读。我们大约只能记住 4 个括号,这意味着超过这个层数的嵌套表达式读起来就要额外费劲。

总体而言,这件事没什么办法;复杂的表达式本来就难读。不过,某些特定形状的嵌套可以不靠视觉上的嵌套来表达。例如:

(((f a) b ) c) 

伪 Lisp

f(a)(b)(c)

伪 C

f[a][b][c]

伪 C,数组访问写法

在 C 语法中,嵌套调用可以写成把外层调用直接接在内层之后。嵌套关系由调用语法的左结合性给出。读这种写法时,我们不必记住并配对括号。(另外,C 语法中没有嵌套,开括号和闭括号挨得更近,视觉上更容易配对。)

形如 f(a)(b) 的嵌套调用在 C 风格的程序里并不常见(Scala 是例外),但嵌套数组访问 f[a][b] 却随处可见。我认为这两种写法本质上是同一回事,区别只在于方括号专门表示对数组的调用。为了和 Lisp 保持一致,下面继续用圆括号来比较。

如果举一个夸张的左嵌套表达式例子,就能看出在 Lisp 版本里配对括号比 C 语法更费劲:

(((((((function aap) noot) mies) wim) zus) jet) teun)

伪 Lisp

function(aap)(noot)(mies)(wim)(zus)(jet)(teun)

伪 C

前面几个 Lisp 例子之所以好读,是因为我们知道看到的是左嵌套表达式。如果把嵌套类型混在一起,需要更留心,就会发现左嵌套表达式的简写让非左嵌套的表达式也更好读了。

(aap noot ((mies wim) zus) (jet teun vuur))

伪 Lisp

aap(noot, mies(wim)(zus), jet(teun, vuur))

伪 C

这里,易于解析的语法 mies(wim)(zus) 释放了心智栈,用来解析外围的泛型表达式。3

阅读顺序

如果我们考虑相反的情况——表达式向右嵌套——一个新问题就浮现出来:

(h (g (f a)))

伪 Lisp

最内层的表达式 (f a) 最先求值,却最后才被读到。如果想理解代码表面语法结构之外的东西,我们可能不得不在心里先求值其中一部分。当阅读顺序与求值顺序不一致时,就像这个例子,认知负担就增加了。要么记住外层调用 h 和 g,直到它们变得相关,这会占用有限的心智栈;要么刻意反向阅读表达式,从 (f a) 开始。

第一种阅读策略——记住外围调用——在调用数量超出记忆容量时就难以为继。因此,反向阅读是理解深层嵌套表达式的唯一可行策略。这意味着理解 Lisp 代码可能要读两遍:第一遍确定表达式的形状,第二遍按符合该形状的顺序来读。

如果比较相同的程序,反向阅读的问题在 Lisp 里并不比 C 记法更严重。不过,C 风格语言中常见的编程风格不太会出现需要反向阅读的深层右嵌套。

例如,下面这个程序计算逗号分隔文件中所有正数之和。两种记法的字面读法都是:“对 ‘input.txt’ 文件按逗号切分、映射为数字、筛选正数,再求和”。

(sum
 (filter
 (split (read (open "input.txt"))
 ",")
 (lambda (it) (> it 0))))

伪 Lisp

sum(
 filter(
 split(
 read(open("input.txt"))
 ","
 ),
 lambda it: it > 0
 )
)

伪 C

C 风格记法和 Lisp 记法一样是由内而外的。只不过这类结构在 C 风格程序里不那么常见。Lisp 偏好函数式编程,C 风格语言偏好命令式编程。命令式风格下,程序天然写成文本顺序与执行顺序一致。下面两个程序都可以理解为“读取 ‘input.csv’ 文件;按逗号切分;映射为数字;筛选正数;然后求和”:

x = read(open("input.txt"))
x = split(x, ",")
x = map(x, int)
x = filter(x, lambda it: it >= 0)
x = sum(x)
return x

伪 C 代码,通过重新赋值 x 让操作按求值顺序排列。

x = read_file("input.csv")
split!(x, ",")
map!(x, int)
filter!(x, ...)
return sum(x)

伪 C 代码,通过原地修改让操作按求值顺序排列。

C 系语言往往还有对象系统以及相应的方法调用语法。使用典型的方法调用语法时,阅读顺序就被强行绑定到求值顺序上:

open("input.txt")
 .read()
 .split(",")
 .map(int)
 .filter(lambda it: it >= 0)
 .sum()

伪 C 代码

最近有几门主流语言引入了专门的语言特性,目的就是让更多函数能以方法调用语法书写。在我看来,这说明程序员偏爱与求值顺序一致的记法。

同样,Clojure 让 threading 宏流行起来,我认为它相当于 Lisp 里的方法链语法:

(~> (file->string "input.txt")
 (string-split "\n")
 (map string->number)
 (filter (lambda (it) (> it 0)))
 (apply +))

伪 Lisp 代码

以及其他考量

如前所述,还有一些语法差异,我认为它们对可读性的影响被高估了。缺少中缀记法常被拿来当作 Lisp 难读的理由。只要读者能记住优先级和结合性,中缀运算符并不需要括号。而我只对最广为人知的那些运算符有把握:+、-、*、/、^、&、|、==、``。Haskell 程序员大概可以作证,中缀运算符更多并不会让程序更好读。所以中缀运算符只在以简单算术为主体的程序里才有影响。而在函数式程序里——Lisp 偏重的正是这类程序——数字相对少见。

我也认为,为语言原语设置专门语法,其效果被高估了。if 语句的记法在 Python 里可能比在 Lisp 里更顺眼,但这本身并不是因为 if 受到了特殊对待,而是因为这种特殊对待恰好用了更顺眼的语法。

我确实认为重要的一点(也是我在本文早先版本里漏掉的一点)是,非 Lisp 语言有更多种表示函数应用的记法。也就是说,if 语句有专门记法并不重要,重要的是存在多种不同记法。这给代码带来了变化,而变化可以充当视觉路标。我打算在以后的文章里更详细地讨论这个话题。

大多数 Lisp 代码并不是写在博客软件里,而是写在专门的编辑器中——至少我希望如此。编辑器里的语法高亮可以缓解上文讨论的部分问题。例如,Visual Studio Code 默认会给配对的括号分配相互匹配的高亮颜色,让它们明显更容易配对。这一点我不纳入考虑,因为这类高亮对 Lisp 和 C 风格语言同样有效。

最后,在这篇文章中,我用「可读性」这个略显含糊的词,指的是对已经熟悉这两种语言的程序员而言的抽象可解析性。对可读性还可以有别的解读,那些解读会更偏向 Lisp 记法。比如,Lisp 语法比 C 简单得多,因此学起来更容易。没有编程背景的学生,能解析 Lisp 程序所需的时间很可能比解析 C 程序更短。此外,有些算法用函数式语言表达更好,因此可以说在 Lisp 中可读性更高。

Lisp 的语法在元编程方面也有某些优势,这超出了本文的范围。为了这些优势牺牲一种可读性,在我看来是值得的取舍。因此,本文的分析目标不是从整体上批评 Lisp 这门语言,而是指出这一取舍的若干侧面。

结论

总结一下,关于函数语法,我认为 Lisp 看起来比 C 风格语言可读性差有四个原因:

  • 前缀记法让括号之间的距离更远
  • 格式化习惯无助于追踪括号
  • 前缀记法导致更多左嵌套
  • Lisp 缺少或不利于那些让求值顺序与阅读顺序一致的特性

把这些观察反过来,就是我对设计新记法的建议:

  • 排列记法,让相关的 token(例如分隔符)尽可能靠近
  • 用代码的形状传达分组
  • 利用结合性避免分隔符嵌套
  • 设计语法和语义,让阅读顺序与求值顺序一致

这一分析表明,可以对 Lisp 类语言做一些改动,以提升其易用性。说实话,我也没能抵挡住再造一个 Lisp 的诱惑。尽管已经有数不清的 Lisp 和相应的记法,但本文中的这些观察促使我设计出一套语法与语义的组合,我相当确定它是独一无二的。这些内容会留到后续文章里讲。

最后更新:2026-9-28

来源: paultm.nl← 返回首页