从 Clojure 学习 Fennel
Table of Contents
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 没有函数, 它依赖于宏来实现诸如 map 和 filter 的功能. 与 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 过滤值不会导致表中出现间隙; 每个值都会被添加到表的末尾.
所有这些形式都接受迭代器. 尽管基于表的 pairs 和 ipairs 是最常见的迭代器, 但其他迭代器如 string.gmatch 或 io.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.unpack 或 unpack 代替 ~@.
也可以使用 macro 内联定义宏, 而不必创建单独的宏模块, 但这些宏不能从模块中导出, 因为它们在运行时不存在; 它们也不能与其他宏交互.
列表和符号在 Fennel 中是严格的编译时概念.
9. 错误处理
在 Lua 和 Fennel 中, 有两种表示失败的方式. error 函数有点像在 Clojure 中抛出 ex-info, 只是我们使用 pcall 和 xpcall 来调用处于"受保护" 状态的函数, 这将防止错误导致进程崩溃. 这些不能像 JVM 上的异常那样链接或无缝重新抛出.
有关详细信息, 请参阅教程.
10. 其他
Fennel 中没有 cond, 因为如果给定超过三个参数, if 的行为与 cond 完全相同.
函数可以返回多个值. 这可能会导致令人惊讶的行为, 但这超出了本文档的范围. 可以在尾部位置使用 values 形式来返回多个值.
诸如 + 和 or 等运算符是特殊形式, 必须在编译时固定参数数量. 这意味着你不能做类似 (apply + [1 2 3]) 的事情, 也不能调用 (* ((fn [] (values 4 5 6)))), 尽管后者对于函数而不是特殊形式是有效的.