从 Clojure 学习 Fennel

Table of Contents

翻译自fennel官方文档

Fennel 从 Clojure 中汲取了很多灵感. 如果你已经熟悉 Clojure, 那么学习 Fennel 会有一个很好的起点. 然而, 两者之间仍有许多不同之处! 本文将从 Clojure 的角度出发, 引导你了解这些差异, 并帮助你快速上手.

Fennel 和 Lua 是极简主义语言, 而Clojure不是. 很多Clojure的特性并不能简单平移, 需要一些时间来适应. 也需要习惯从不同的地方寻找解决方案. 总的来说, Fennel 的概念范围要小得多, 因此更容易学习.

1. 运行时(runtime)

Clojure 和 Fennel 都是与宿主运行时紧密集成的语言. 对于 Clojure 来说是 Java, 而对于 Fennel 则是 Lua. 然而, Fennel 的共生关系比 Clojure 更进一步.

在 Clojure 中, 每个函数(对应一个class)都实现了从 Java 调用的接口, Clojure 函数与 Java 方法是不同的. Clojure 的命名空间与 Java 包相关, 但命名空间仍然是一个独立的概念.

在 Fennel 中, 不存在这个区别. Fennel 函数就是 Lua 函数, Fennel 模块就是 Lua 模块.

Clojure 运行在 JVM 上, 但它也有自己的标准库: clojure.core 命名空间以及补充的 clojure.set 或 clojure.java.io 提供了更多的函数.

Fennel语言本身不提供任何函数; 它只提供宏和特殊形式. 由于 Lua 标准库非常精简, 通常会引入第三方库如 Lume, LuaFun 或 Penlight 来实现你可能期望语言内置的功能, 如 merge 或 keys.

还有一个实验性的 Cljlib 库, 它实现了 clojure.core 命名空间中的许多函数, 并有一组宏使编写代码对 Clojure 程序员更加熟悉, 例如添加了定义多参数函数或多方法的语法, 还提供了深度比较语义, 序列抽象和一些额外的数据结构, 如集合.

在 Clojure 中, 通常使用 Leiningen 这样的工具来引入库. 在 Fennel 中, 可以使用 LuaRocks 来处理依赖关系, 但这通常是大材小用. 通常最好将依赖项直接放入源代码仓库中.

在 Fennel 和 Lua 中, 深度依赖树非常罕见. 尽管 Lua 的标准库非常小, 但在仓库中添加一个第三方库的单个文件是非常轻量级的.

在 Clojure 中, 将 jar 文件放入 git 仓库是强烈不推荐的(有充分的理由), 但这些理由通常不适用于 Lua 库.

部署 Clojure 通常意味着创建一个 uberjar, 并使用现有的 JVM 安装来启动它, 因为 JVM 是一个相当大的软件.

Fennel 的部署方式则更加多样化; 你可以轻松创建不到 1MB 的自包含独立可执行文件, 或者创建依赖于现有 Lua 安装的脚本, 或者将代码嵌入到已经存在 VM 的更大应用程序中.

2. 函数与局部变量

Clojure 有两种作用域: 局部变量和全局变量. Fennel 对所有内容都使用词法作用域. (全局变量存在, 但它们主要用于调试和 REPL 目的; 你不会在正常代码中使用它们. )这意味着"重新加载的单位"不是 clojure.lang.Var, 而是模块.

Fennel 的 REPL 包含一个 ,reload module-name 命令来实现这一点. 在函数内部, let 用于引入新的局部变量, 就像在 Clojure 中一样.

但在顶层, 使用 local 来声明一个局部变量, 该变量在整个剩余块中有效, 而不仅仅是在 let 的主体中.

与 Clojure 一样, Fennel 使用 fn 形式来创建函数. 然而, 给它一个名字也会将其声明为局部变量, 而不是让名字纯粹是内部的, 使其更像 defn 一样使用.

使用 fn 声明的函数没有参数数量检查; 你可以用任意数量的参数调用它们. 如果你使用 lambda 声明, 当你提供太少参数时, 它会抛出异常.

Fennel 支持类似于 Clojure 的解构. 主要区别在于, Fennel 不使用 :keys, 而是使用一个符号命名键的裸 : 符号. 这种表示法的一个主要优点是, 与 :keys 不同, 相同的表示法用于构造和解构.

;; clojure
(defn my-function [{:keys [msg abc def]}]
  (println msg)
  (+ abc def))

(my-function {:msg "hacasb" :abc 99 :def 523})
;; fennel
(fn my-function [{: msg : abc : def}]
  (print msg)
  (+ abc def))
(my-function {:msg "hacasb" :abc 99 :def 523})

与 Clojure 一样, 普通的局部变量不能赋予新值. 然而, Fennel 有一个特殊的 var 形式, 允许你声明一种特殊的局部变量, 可以使用 set 赋予新值.

Fennel 也使用 #(foo) 表示法作为匿名函数的简写. 有两个主要区别; 第一个是它使用 $1, $2 等而不是 %1, %2 来表示参数.

其次, 虽然 Clojure 在这种简写中需要括号, 但 Fennel 不需要. Fennel 中的 #5 相当于 Clojure 的 (constantly 5).

;; clojure
(def handler #(my-other-function %1 %3))
(def handler2 (constantly "abc"))
;; fennel
(local handler #(my-other-function $1 $3))
(local handler2 #"abc")

Fennel 没有 apply; 需要将参数解包到函数调用形式中:

;; clojure
(apply add [1 2 3])
;; fennel
(add (table.unpack [1 2 3])) ; 在旧版 Lua 中使用 unpack 而不是 table.unpack

在 Clojure 中, 你可以使用未记录的 &env 映射在编译时访问作用域信息. 在 Fennel 和 Lua 中, 环境在运行时是一等公民.

3. 表(table)

Clojure 提供了丰富的数据结构, 适用于各种情况. Lua(以及 Fennel)只有一种数据结构: 表. 在底层, 具有连续整数键的表当然出于性能原因使用数组实现, 但表本身并不"知道"它是序列表还是类似映射的表.

当遍历表时, 由用户决定, 用 ipairs 遍历序列表, 使用 pairs 遍历类似映射的表. 请注意, 可以在序列上使用 pairs; 只是不会按顺序得到结果.

另一个主要区别是表是可变的(mutable). 可以使用元表在 Lua 运行时上实现不可变数据结构, 但除了固有的不可变性惩罚之外, 还有显著的性能开销.

使用 LuaFun 库可以在可变表上获得不可变操作, 而不会产生太多开销.

Lua 运行时的分代垃圾回收仍然是开发中功能, 因此生成大量垃圾的纯函数式方法在fennel上要避免.

与 Clojure 一样, 任何值都可以作为键. 然而, 由于表是可变数据, 两个具有相同值的表不会像 Baker 那样彼此相等, 因此将作为不同的键. Clojure 的 :keyword 表示法在 Fennel 中用作字符串的语法; 没有单独的关键字类型.

请注意, Fennel 中的 nil 与 Clojure 中的 nil 有很大不同; 在 Clojure 中, 它有许多不同的含义(nil punning), 但在 Fennel 中, 它始终表示值的缺失.

因此, 表不能包含 nil. 尝试将 nil 放入表中等同于从表中删除该值, 你永远不必担心" 表中不包含此键"与"表中此键包含 nil 值" 之间的区别.

在顺序表中将键设置为 nil 不会移动所有其他元素, 并且会在表中留下一个"洞". 在序列上使用 table.remove 来避免这些洞.

表不能像函数一样调用(除非你设置了一个特殊的元表), :keyword 风格的字符串也不能. 如果字符串键是静态已知的, 你可以使用 tbl.key 表示法; 如果不是, 则在无法解构的情况下使用 . 形式: (. tbl key).

;; clojure
(dissoc my-map :abc)
(when-not (contains? my-other-map some-key)
  (println "no abc"))
;; fennel
(set my-map.abc nil)
(when (= nil (. my-other-map some-key))
  (print "no abc"))

4. 动态作用域

如前所述, Clojure 有两种作用域: 词法作用域和动态作用域. Clojure 的全局变量可以在动态作用域中声明, 使用 def 及其派生形式支持的特殊元数据属性, 稍后可以使用 binding 宏进行更改:

;; clojure
(def ^:dynamic /foo/ 32)
(defn bar [x]
  (println (+ x /foo/)))
(println (bar 10)) ;; => 42
(binding [/foo/ 17]
  (println (bar 10))) ;; => 27
(println (bar 10)) ;; => 42

Fennel 没有动态作用域. 相反, 我们可以使用表的可变性来更改持有的值, 稍后可以动态查找:

;; fennel
(local dynamic {:foo 32})
(fn bar [x]
  (print (+ dynamic.foo x)))
(print (bar 10)) ;; => 42
(set dynamic.foo 17)
(print (bar 10)) ;; => 27

与 Clojure 的 binding 相比, binding 仅在 binding 宏创建的作用域内将全局变量绑定到给定值, 而这里的表修改是永久性的, 表值必须手动恢复.

在 Clojure 中, 类似于变量, 可以定义动态函数:

;; clojure
(defn ^:dynamic /fred/ []
  "Hi, I'm Fred!")
(defn greet []
  (println (/fred/)))
(greet) ;; prints: Hi, I'm Fred!
(binding [/fred/ (fn [] "I'm no longer Fred!")]
  (greet)) ;; prints: I'm no longer Fred!

在 Fennel 中, 我们可以简单地将函数定义为表的一部分, 要么通过将匿名函数分配给表键, 如上面的变量示例所示, 要么通过在 fn 特殊形式中用点分隔函数名和表名:

;; fennel
(local dynamic {})
(fn dynamic.fred []
  "Hi, I'm Fred!")
(fn greet []
  (print (dynamic.fred)))
(greet) ;; prints: Hi, I'm Fred!
(set dynamic.fred (fn [] "I'm no longer Fred!"))
(greet) ;; prints: I'm no longer Fred!

另一种选择是使用 var 特殊形式(special form). 我们可以定义一个持有 nil 的变量, 在某个函数中使用它, 然后将其设置为其他值:

;; fennel
(var foo nil)
(fn bar []
  (foo))
(set foo #(print "foo!"))
(bar) ;; prints: foo!
(set foo #(print "baz!"))
(bar) ;; prints: baz!

这也可以用于像 Clojure 的 declare 这样的前向声明.

5. 迭代器

在 Clojure 中, 我们有"一切都是序列"的概念. Lua 和 Fennel 不是显式的函数式语言, 而是有"一切都是迭代器"的概念.

<Lua 编程>一书详细解释了迭代器. each 特殊形式消耗迭代器并逐步遍历它们, 类似于 doseq 的工作方式.

;; clojure
(doseq [[k v] {:key "value" :other-key "SHINY"}]
  (println k "is" v))
;; fennel
(each [k v (pairs {:key "value" :other-key "SHINY"})]
  (print k "is" v))

在遍历映射时, Clojure 要求你通过解构来分离键/值对, 但在 Fennel 中, 迭代器将它们作为单独的值提供给你.

由于 Fennel 没有函数, 它依赖于宏来实现诸如 mapfilter 的功能. 与 Clojure 的 for 类似, Fennel 有一对操作迭代器并生成表的宏.

icollect 遍历一个迭代器, 并允许主体返回一个值, 该值被放入一个顺序表中返回. collect 宏类似, 它也返回一个表, 只是主体应返回两个值, 返回的表是键/值对而不是顺序的. 这两个宏的主体都允许返回 nil 以从结果表中过滤掉该条目.

;; clojure
(for [x [1 2 3 4 5 6]
      :when (= 0 (% x 2))]
  x) ; => (2 4 6)

(into {} (for [[k v] {:key "value" :other-key "SHINY"}]
           [k (str "prefix:" v)]))
; => {:key "prefix:value" :other-key "prefix:SHINY"}
;; fennel
(icollect [i x (ipairs [1 2 3 4 5 6])]
  (if (= 0 (% x 2)) x)) ; => [2 4 6]

(collect [k v (pairs {:key "value" :other-key "SHINY"})]
  (values k (.. "prefix:" v)))
; => {:key "prefix:value" :other-key "prefix:SHINY"}

请注意, 使用 icollect 过滤值不会导致表中出现间隙; 每个值都会被添加到表的末尾.

所有这些形式都接受迭代器. 尽管基于表的 pairsipairs 是最常见的迭代器, 但其他迭代器如 string.gmatchio.lines 甚至自定义迭代器也同样适用.

表不能是惰性的(再次强调, 除了通过元表的巧妙设计), 因此在某种程度上, 迭代器承担了惰性的角色.

如果你想要 Clojure 的序列抽象, Cljlib 库提供了 Clojure 的 mapv, filter 和其他函数, 这些函数使用类似的序列抽象实现, 用于普通表, 具有将表转换为顺序表的线性运行时成本.

实际上, 使用 Cljlib 可以将大多数 Clojure 数据转换几乎直接移植到 Fennel, 尽管它们的性能特征会有很大差异.

6. 模式匹配

遗憾的是, Clojure 没有将模式匹配作为语言的一部分. Fennel 通过实现 case 宏解决了这个问题. 有关详细信息, 请参阅参考文档. 由于 if-let 只是模式匹配的一种简化形式, Fennel 省略了它, 转而支持 case.

;; clojure
(if-let [result (calculate-thingy)]
  (println "Got" result)
  (println "Couldn't get any results"))
;; fennel
(case (calculate-thingy)
  result (print "Got" result)
  _ (print "Couldn't get any results"))

7. 模块

Fennel 中的模块是一等公民; 也就是说, 它们只不过是具有特定加载机制的表. 这与 Clojure 中的命名空间不同, 命名空间具有一些类似映射的属性, 但它们并不是真正的数据结构.

Clojure 要求你将命名空间名称中的破折号替换为文件名中的下划线; Fennel 允许你以与模块一致的方式命名文件.

在 Clojure 中, 全局变量默认是公开(public)的. 在 Fennel 中, 所有定义都是文件局部的, 但将局部变量包含在放置在文件末尾的表中将导致它被导出, 以便其他代码可以使用它.

这使得在一个地方查看模块导出的所有内容变得容易, 而不必阅读整个文件.

;; clojure
(ns my.namespace)

(def ^:private x 13)
(defn add-x [y] (+ x y))
;; fennel
(local x 13)
(fn add-x [y] (+ x y))

{: add-x}

模块通过 require 加载, 通常使用 local 绑定, 但它们也经常在绑定时进行解构.

;; clojure
(require '[clojure.pprint :as pp])
(require '[my.namespace :refer [add-x]])

(defn show-something []
  (pp/pprint {:a 1 :b (add-x 13)}))
;; fennel
(local fennel (require :fennel))
(local {: add-x} (require :my.module))

(fn show-something []
  (print (fennel.view {:a 1 :b (add-x 13)})))

8.

在任何 Lisp 中, 宏都是一个函数, 它接受一个输入形式并返回另一个形式以在其位置进行编译. Fennel 使这一点更加明确; 宏作为函数从特殊的宏模块加载, 这些模块在编译作用域中加载. 它们通过 import-macros 引入:

;; macros.fnl

{:flip (fn [arg1 arg2] `(values ,arg2 ,arg1))}
;; otherfile.fnl
(import-macros {: flip} :macros)

(print (flip :abc :def))

Fennel 使用更传统的 , 而不是 ` 来进行反引用. 在引用形式的末尾, 可以使用 table.unpackunpack 代替 ~@.

也可以使用 macro 内联定义宏, 而不必创建单独的宏模块, 但这些宏不能从模块中导出, 因为它们在运行时不存在; 它们也不能与其他宏交互.

列表和符号在 Fennel 中是严格的编译时概念.

9. 错误处理

在 Lua 和 Fennel 中, 有两种表示失败的方式. error 函数有点像在 Clojure 中抛出 ex-info, 只是我们使用 pcallxpcall 来调用处于"受保护" 状态的函数, 这将防止错误导致进程崩溃. 这些不能像 JVM 上的异常那样链接或无缝重新抛出.

有关详细信息, 请参阅教程.

10. 其他

Fennel 中没有 cond, 因为如果给定超过三个参数, if 的行为与 cond 完全相同.

函数可以返回多个值. 这可能会导致令人惊讶的行为, 但这超出了本文档的范围. 可以在尾部位置使用 values 形式来返回多个值.

诸如 +or 等运算符是特殊形式, 必须在编译时固定参数数量. 这意味着你不能做类似 (apply + [1 2 3]) 的事情, 也不能调用 (* ((fn [] (values 4 5 6)))), 尽管后者对于函数而不是特殊形式是有效的.

Author: 青岛红创翻译

Created: 2025-10-25 Sat 17:20