暗无天日

=============>DarkSun的个人博客

为什么 Lisp 统治元编程

有人在 DEV.to 上做了一张表,按元编程和宏能力给 18 种语言打分(满分 19)。结果排名前六的语言里有五个是 Lisp 方言。

这不是巧合。

数据说话

从评分表里提取 Lisp 系和非 Lisp 系的平均分:

类别 语言数 平均分
Lisp 系(Racket、CL、Clojure、Arc、Coalton、Hackett、Scheme、Carp) 8 14.9
非 Lisp 系(Elixir、Julia、Nim、Rust、Scala、Ruby 等) 10 10.0

Lisp 系语言在四个评分维度中,有三个维度碾压非 Lisp 系:运行时元编程、宏特性、生态中的宏工具。唯一的"弱项"是编译期能力——但这不是因为 Lisp 做不到,而是很多 Lisp 方言根本不需要编译期魔法,它在运行时就能做到同等甚至更多的事。

根本原因:代码即数据

Lisp 的核心优势只有一个:程序的源代码本身就是数据结构。

一段 Elisp 代码 (+ 1 2) 在解析器看来就是一个列表,第一个元素是符号 + ,第二个是数字 1 ,第三个是数字 2 。不需要额外的解析步骤,不需要 AST 转换,不需要语法糖展开——代码已经是数据了。

这意味着 Lisp 宏可以直接操作代码,就像操作列表一样自然:

;; 定义一个宏:记录表达式执行耗时的宏
(defmacro with-timer (&rest body)
  `(let ((start (current-time)))
     ,@body
     (message "Elapsed: %.2f seconds"
              (float-time (time-subtract (current-time) start)))))

;; 使用时就像普通语法
(with-timer
  (sleep-for 1)
  (message "done"))
Elapsed: 1.00 seconds

with-timer 接收的是 未求值的代码 ,不是值。它把代码当列表拼接,生成新的代码返回给求值器。整个过程不需要解析字符串、不需要构建 AST——因为输入本来就是列表。

非 Lisp 语言的困境

非 Lisp 语言要做同样的事,必须先解决一个问题:怎么把源代码变成可操作的数据?

  • Rust 的过程宏(procedural macro)需要先用 syn 库把 TokenStream 解析成 AST,操作完再用 quote 库生成 TokenStream。整个过程等价于"先解析,再操作,再序列化"
  • C++ 的模板元编程在类型系统里做图灵完备的计算,代价是编译错误信息能塞满整个终端
  • Python 的装饰器只能包装函数,不能创造新的语法结构。想操纵 AST?得自己写 import ast 解析器
  • Ruby 是个有趣的反例。它以元编程著称—— method_missingdefine_methodclass_eval 让 Rails 之类的框架写出了极其优雅的 DSL。但它在评分表里只有 8 分(和 C++ 的 7 分差不多)。为什么?因为 Ruby 的元编程全部是运行时的:开放类、动态方法定义、幽灵方法。它不能创造新的语法结构,不能在编译期做代码变换。Ruby 的 DSL 能力来自语言的动态性,而非宏系统。原文章也指出:Ruby 的元编程评分和 C++ 接近,但没有人会认为 C++ 比 Ruby 更适合元编程——这说明纯运行时元编程的能力被评分表低估了,但也说明运行时元编程和编译期宏是两个不同维度
## Python 的装饰器:只能包装,不能创造新语法
import time

def with_timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"Elapsed: {time.time() - start:.2f}s")
        return result
    return wrapper

@with_timer
def my_task():
    time.sleep(1)
    print("done")

装饰器能做到"在函数调用前后加逻辑",但做不到"定义一种全新的控制流结构"。宏可以。

Emacs Lisp 中的实际威力

Emacs Lisp 可能是日常使用中最频繁接触 Lisp 宏的机会。Emacs 的很多核心功能就是宏实现的:

  • dolistdotimes 都是宏,不是内置特殊形式。它们接收未求值的代码,展开成 while 循环。如果你需要一种新的迭代方式,可以自己写一个。
  • cl-defstruct 内部用宏生成构造函数、访问器、谓词函数。一个宏调用生成几十行代码。
  • use-package 整个就是一个宏驱动的 DSL。 :init:config:bind 这些"关键字"不是语言特性,是宏通过模式匹配识别的符号。
;; use-package 的 :bind 关键字不是语言内置的语法
;; 是宏在展开时识别 :bind 符号并生成 keymap 代码
(use-package magit
  :bind (("C-x g" . magit-status)))

这就是 Lisp 宏的精髓:你可以用宏 发明 语言本身没有的语法。 use-package 的作者不需要修改 Elisp 解释器,只需要写一个宏。

为什么不是所有语言都走这条路

如果宏这么好,为什么大多数语言不采用 Lisp 风格的宏?

答案和评分表里 C++ 和 Python 的低分一样:它们的语法不是数据。

Lisp 的 S 表达式看起来"全是括号",但这恰恰是它的元编程优势的来源——统一的语法结构意味着代码天然就是可操作的数据树。Python 的缩进、C++ 的花括号、Rust 的尖括号,每一种语法糖都在增加"从代码到数据"的转换成本。

选择了一种"好看"的语法,就放弃了把代码当数据的能力。这不是审美问题,是结构性的取舍。

小结

评分表的结论可以用一句话概括:元编程能力的上限取决于语言语法的数据化程度。Lisp 的 S 表达式是最彻底的"代码即数据"——没有语法糖的干扰,宏可以直接操作程序结构,而不需要先解析它。这就是为什么 18 种语言里 8 种是 Lisp 方言,而且它们占据了排行榜的前列。

Lisp : 元编程 : : Emacs-Lisp : 编程语言