Clojure 的公理:什么是语言构建不出来的
问题的本质
Scheme 的哲学是极简主义:5 个特殊形式(define、if、lambda、set!、begin)+ quote,其余一切自举构建。SICP 用这一套东西从零开始讲完了整个计算机科学。
Clojure 不是 Scheme。它更务实,特殊形式多一些,但依然有一个清晰的分层:有些东西是语言必须提供的,剩下的全都可以用这些基元构建出来。
这篇回答一个问题:Clojure 的不可约基元是什么?
第一层:读取器(Reader)—— 代码即数据的根基
Clojure 程序是一段文本。读取器把文本变成数据结构。这一层比”求值”更底层。
;; 你写的文本
(+ 1 2)
;; 读取器产出的数据结构
;; 一个 list,包含三个元素:symbol + 和 number 1、2
读取器认识的数据结构字面量:
| 字面量 | 数据类型 | 说明 |
|---|---|---|
42 | number | 整数/浮点 |
"hello" | string | 字符串 |
:foo | keyword | 关键字 |
true / false / nil | boolean / nil | 布尔和空值 |
() | list | 链表(代码的基本形态) |
[] | vector | 向量 |
{} | map | 映射 |
#{} | set | 集合 |
还有读取器宏(reader macros)——在读取阶段就展开的语法糖:
'x → (quote x) ;; 阻止求值
#'x → (var x) ;; 取 var 本身
@x → (deref x) ;; 解引用
#(...) → (fn [p1__auto__] …) ;; 匿名函数
#"..." → 正则对象
这一层不可约:读取器是编译器的一部分,不是 Clojure 代码实现的。它定义了”代码长什么样”。
关键洞察:读取器产出的都是数据结构,不是 AST 节点。这就是 homoiconicity(代码即数据)的根基——(+ 1 2) 既是一段代码,也是一个包含三个元素的 list。没有中间表示。
第二层:求值器(Evaluator)—— 一个规则 + 特殊形式
读取器产出数据后,求值器对它求值。求值器的核心规则只有一条:
如果是一个 list,把第一个元素当函数/特殊形式,其余当参数。
但有些东西这个规则处理不了——比如 if 不应该同时求值两个分支,quote 应该阻止求值,def 需要操作命名空间注册表。这些东西叫特殊形式(special forms)。
Clojure 的全部特殊形式:
;; 1. def —— 全局绑定(在命名空间注册)
(def x 42)
;; 2. if —— 条件分支(只求值被选中的分支)
(if test then else)
;; 3. do —— 顺序执行多个表达式,返回最后一个
(do (println "a") (println "b") 42)
;; 4. let —— 局部绑定
(let [x 1 y 2] (+ x y))
;; 5. fn —— lambda(创建闭包)
(fn [x] (* x x))
;; 6. loop / recur —— 尾递归循环
(loop [i 0] (if (< i 10) (recur (inc i)) i))
;; 7. quote —— 阻止求值,返回数据本身
(quote (+ 1 2)) ;; => (+ 1 2)
;; 8. var —— 获取 Var 对象本身(而非其值)
(var x) ;; => #'user/x
;; 9. throw / try —— 异常
(try (throw (ex-info "err" {})) (catch :default e e))
;; 10. . —— 宿主互操作(方法调用/字段访问)
(.length "hello") ;; => 5
;; 11. new —— 创建宿主对象
(new js/Error "msg")
;; 12. set! —— 赋值(仅用于宿主互操作和 volatile)
(set! (.-title js/document) "new")
12 个特殊形式(ClojureScript 中 monitor-enter/monitor-exit 不使用)。这就是 Clojure 语言的全部公理。其余一切都是用这些基元构建的。
为什么这些不可约?
if 不可约:因为函数调用会先求值所有参数。如果你用函数模拟 if:
;; 假设的 my-if 函数(行不通!)
(defn my-if [test then else]
(cond test then :else else))
;; 问题:then 和 else 在传入函数之前就被求值了
(my-if true (println "yes") (println "no"))
;; => 打印 "yes" 和 "no" 两行!
if 必须是特殊形式,因为它需要只求值被选中的那个分支。
fn 不可约:因为函数需要捕获词法作用域(闭包),这个行为必须由运行时支持。
let 不可约:因为局部绑定需要创建新的作用域,且不先求值绑定符号。
def 不可约:因为需要在命名空间注册表中创建新的映射。
quote 不可约:因为需要阻止求值,而任何函数调用都会先求值参数。
. 不可约:因为需要直接调用宿主平台的方法,绕过 Clojure 的函数调用机制。
第三层:一切可构建的—— 用公理构建的免费赠品
宏是免费的
宏是编译时运行的函数——接收代码(数据),输出代码(数据)。展开后的代码只包含特殊形式和函数调用。
;; defn 是宏,展开成 def + fn
(defn add [a b] (+ a b))
;; 展开 ↓
(def add (fn [a b] (+ a b)))
;; when 是宏,展开成 if + do
(when test body1 body2)
;; 展开 ↓
(if test (do body1 body2))
;; cond 是宏,展开成嵌套的 if
(cond test1 val1 test2 val2 :else val3)
;; 展开 ↓
(if test1 val1 (if test2 val2 val3))
;; and 是宏,展开成嵌套的 if
(and a b c)
;; 展开 ↓
(if a (if b c nil) nil)
;; -> 线程宏
(-> x (f 1) (g 2))
;; 展开 ↓
(g (f x 1) 2)
;; ->> 线程宏
(->> x (map f) (filter g))
;; 展开 ↓
(filter g (map f x))
所有宏都不增加语言的能力——它们只消除重复、改善表达。任何宏展开后的代码,都只包含特殊形式和函数调用。
函数也是免费的
defn、map、filter、reduce、conj、assoc……这些都是函数,用 fn 和 def 定义的普通函数。它们不增加语言的能力——只是标准库。
Protocol 是免费的
(defprotocol Drawable (draw [this]))
展开后是 def + 一些函数 + 一些元数据操作。没有新的特殊形式。
defrecord 是免费的
(defrecord Circle [radius] Drawable (draw [this] ...))
展开后是 def + deftype + 一些函数。没有新的特殊形式。
多重分派是免费的
(defmulti handle :type)
(defmethod handle :click [e] ...)
展开后是 def + 一个 atom(存储分发表)+ 一些函数。没有新的特殊形式。
命名空间是免费的
(ns my-app.core (:require [foo :as f]))
展开后是对编译器命名空间注册表的操作。运行时就是几个 JS 对象。
对比:各语言的最小基元
| 语言 | 特殊形式数量 | 基本思路 |
|---|---|---|
| Scheme | ~5 | Lambda 演算,极简主义 |
| Clojure | ~12 | Lambda + 宿主平台 + 实用主义 |
| JS | 很多(无法列出) | 语法复杂,关键字几十个 |
| Python | 非常多 | 语法驱动,不是数据驱动 |
Scheme 的 5 个特殊形式:define、if、lambda、set!、begin(加上 quote)。
Clojure 比 Scheme 多了什么?
let(Scheme 用lambda模拟,Clojure 为了性能直接提供)loop/recur(Scheme 依赖正确的尾调用,JVM/JS 不保证 TCO,所以 Clojure 显式提供)def(Scheme 的define简化版,Clojure 需要和命名空间交互)try/throw(Scheme 没有内建异常)./new/set!(宿主互操作,纯 Scheme 不需要)
Clojure 比 Scheme 多出的特殊形式,几乎都来自两个选择:1) 宿主平台互操作(JVM/JS),2) 在宿主平台不支持 TCO 时显式处理尾递归。
这些不是设计上的妥协,而是务实的选择——用多几个特殊形式换来在 JVM 和 JS 上的高效运行。
最终的公理体系
不可约的(语言提供):
├── 读取器(Reader) 文本 → 数据结构
│ └── 字面量语法 (), [], {}, #{}, :, ', #', @, ...
├── 求值器(Evaluator) 数据结构 → 值
│ └── 一条规则 list 的第一个元素当函数,其余当参数
└── 特殊形式(12 个) 求值器无法用自身规则处理的东西
├── def 全局绑定
├── if 条件分支
├── do 顺序执行
├── let 局部绑定
├── fn lambda
├── loop / recur 尾递归
├── quote 阻止求值
├── var Var 引用
├── throw / try 异常
└── . / new / set! 宿主互操作
可构建的(用上面的公理构建):
├── 宏(defn, when, cond, and, or, ->, ->>, ...)
├── 函数(map, filter, reduce, conj, assoc, ...)
├── 数据结构实现(persistent vector/hamt/...)
├── defmulti / defmethod 用 atom + 函数
├── defprotocol 用函数 + 元数据
├── defrecord / deftype 用 def + fn + .
├── 命名空间 用 def + 编译器注册表
└── 整个标准库
对自己的意义
理解了这个分层之后,你在用 Clojure 时的心态会不一样:
- 遇到新语法,先问”这是宏还是特殊形式?“——99% 的情况下是宏。展开它就懂了。
- 遇到复杂抽象,知道它最终归约到哪里——都是
fn+def+if。 - 想发明新抽象,宏给你这个能力——你可以在不增加特殊形式的情况下扩展语言。
这也是 Lisp 家族最独特的力量:语言的基元极少且清晰,其余一切都可以被用户重定义、替换、或增强。这不是”灵活性”,这是”你站在和语言设计者同一个层面上”。
参考资源
- SICP — Structure and Interpretation of Computer Programs,从 Lambda 演算构建一切
- Clojure Special Forms 官方文档
- The Reader
- Clojure Evaluation