Coffee Break Clojure, Vol.1

上一篇 我们了解了 Lisp 中「代码即数据」的本质,知道了不求值(Eval)的列表就是数据,被求值的列表就是代码,而代码可以像数据一样经过任意的变换之后再求值。我们还捋了捋列表、形式、对象和原子的概念,可以说,读者已经掌握 Lisp 语法的精髓了。这里除了括号和待求值的一堆括号,别无他物。

了解了语法,在深入 Clojure 的语义(semantics)之前,先来准备些趁手工具。

读取-求值-打印-循环

正式开始之前,你需要先安装 Java 和 Clojure (因为 Clojure 运行在 JVM 之上),之后就可以在终端输入 clojure,按下回车之后便会看到新的命令提示符:

Clojure 1.12.6
user=>

在 Lisp 的世界里,这一般被称作 REPL,全称是:Read-Eval-Print-Loop(读取-求值-打印-循环)。顾名思义,它读取用户输入,求值,打印这个值,然后循环。有趣的是,在 Common Lisp 等方言中,你可以用一行代码启动最原始的 REPL:

(loop (print (eval (read))))

Clojure 不能这样写,因为 loop 的用法不同。不过把 loop 换成 while 就好了,效果差不多。试试看,在你的 REPL 里输入这段代码:

(while true (println (eval (read))))

然后你就会在 REPL 里获得一个简陋的子 REPL:输入一段代码,按下回车,求值的结果就会显示在下一行。

不难发现,REPL 和 Shell 非常类似,前者允许用户用命令行界面与编程语言交互,后者允许用户用命令行界面与操作系统交互。或者说,Shell 语言就是用来跟操作系统交互的编程语言,而 Shell 程序就是 Shell 语言的 REPL。

现在,按下 Ctrl + D 退出子 REPL,回到真正的 REPL 里。

在开启你的 Clojure/Lisp 旅程之前,务必和 REPL 打好关系。我记得我刚开始写 Clojure 的时候,还像个麻瓜一样写完代码,保存,然后执行整个文件甚至整个项目。这很折磨,而且让 Clojure 感觉上就是个语法更小众的普通编程语言。

REPL 不仅仅是跟 Clojure 交互的媒介,还是与你自己的代码交互的媒介,也可以是用户与你的代码交互的媒介。

Lisp 常见的编程实践叫作交互式编程(Interactive Programming),允许程序员一边写代码一边与已经写好的代码交互。原理其实很简单,每写好一段代码,都把代码发送到 REPL 里求值,函数定义、变量和各种状态被加载到 REPL 中,就可以使用之前已经定义好的符号继续编程。

最原始的交互式编程是:电脑上一边打开编辑器,另一边打开终端运行 REPL,每写一段代码就复制粘贴到 REPL 中。不过这太不优雅了,Emacs 的 SLIME 和 Neovim 的 Conjure 允许程序员直接在编辑器内部对代码求值,它们用某种协议把代码发送给 REPL 进程(或 REPL 服务器),随后 REPL 把结果返回给编辑器。

这有什么用呢?比方说你想要临时测一测你写的某个函数是否正常工作,或者看看某个库提供的函数返回的具体的数据格式,你不需要手动把他们的依赖库以及你已经在文件里定义好的变量手动复制到 REPL 里,也不需要手动执行整个程序,只需要按几个快捷键,结果就是呈现在你的编辑器里,显示在你的代码旁边。

如果你写过前端,你一定对热重载(Hot Relaod)不陌生。REPL 就是 Lisp 通用的热重载机制。不需要重启系统,每个改动都可以直接加载到 REPL 里。

REPL 作为用户界面也大有用处。在 1988 年,运行在太空中的一个 Lisp 程序陷入了死锁,名叫 Ron Garret 的 Lisp 程序员正是用 REPL 查询程序状态并发送指令,解决了运行在二百四十万公里以外的某个程序的 Bug。正如程序员可以一边写代码一边运行刚写完的代码,用户也可以通过 REPL 与正在运行的程序交互。

对话式编程

我假定你已经安装了 Clojure 并且启动了 REPL。为了让 Clojure 写起来更舒服,我们可以让编辑器与 REPL 集成,再安装能够辅助我们在括号之间快速游走而不会 Lost In Stupid Parentheses 的插件。

如果你是 Emacs 用户,我相信你们的检索能力,你们大多数应该完全有能力自己配置环境,而且对 (Emacs) Lisp 应该很熟悉了。我只需要给你们指指路—— CIDER 。不过,我不详细写 Emacs 的原因其实是:我不用 Emacs 写代码。

如果你是 Neovim 用户,接下来我会一步一步讲解如何在 Neovim 中配置 Clojure 环境。

如果你是 VS Code 用户,Get your text-editing browser out of my house!

尽管 Clojure 自带的命令行就够用了,但 Clojure 生态中最常用也最容易上手的工具是 Leiningen 。你可以把它和 JavaScript 的 NPM 对比,不过不会隔三岔五就受到供应链攻击。Leiningen 的标语是:

With a focus on project automation and declarative configuration, it gets out of your way and lets you focus on your code. 聚焦项目自动化和声明式配置,为你扫清道路,让你专注你的代码。

事实也的确如此。你可以用非常简短易记的命令,让 Leiningen 帮你创建项目、运行项目、运行测试编译项目等等。不过我们暂时不必创建项目,我们可以用 Leinigen 启动 REPL。

lein repl 

这和直接运行 Clojure 有什么区别呢?

nREPL server started on port 52293 on host 127.0.0.1 - nrepl://127.0.0.1:52293
REPL-y 0.5.1, nREPL 1.3.0
Clojure 1.12.2
OpenJDK 64-Bit Server VM 26.0.2.1
 Docs: (doc function-name-here)
 (find-doc "part-of-name-here")
 Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
 Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

user=>

可以看到,执行 lein repl 之后,我们不仅启动了 Clojure,还启动了 nREPL 服务器,n 代表 Network,我们可以通过网络连接到这个 nREPL 服务器。

此外,我们可以看到服务器的地址是 nrepl://127.0.0.1:52293,协议头部分是 nrepl://。nREPL 本身并不是服务器,而是一套通信协议。

一般而言,服务器本身是个 Clojure REPL,而客户端可以终端也可以是任意的应用程序,当然,这里我们的客户端就是编辑器(或者说 IDE)。

重点并不是网络连接,而是通信协议。有了它,编辑器就能自动调用 API 和 REPL 交互,不需要我们手动输入表达式进去了,只需要在编辑器里照常写代码。

现在 REPL 和 nREPL 服务器都启动了,怎么让编辑器和它对话呢?

在这个教程中,我用到的是 Conjure 。你可以到根据你的 Neovim 使用的插件管理器到 文档 里复制对应的配置。如果是 lazy.nvim ,就用这个:

-- lazy.nvim
return { "Olical/conjure" }
;; lazy.nvim with Fennel 
[:Olical/conjure]

Conjure 的很多快捷键要用到局部 Leader 键(local leader key),这取决于你的配置,可以检查你的配置文件中 vim.g.maplocalleader 这个变量的值。我的 local leader 是 \ 键,接下来我也会用这个键表示 local leader。

安装完成后,创建一个 .clj 文件,用 Neovim 打开。

nvim hello.clj 
;; hello.clj
(defn hello [string]
 (println "Hello" string))
截图,左侧是运行 lein repl 的终端,显示 nREPL 启动了;右侧是 Neovim,Neovim 界面的右上角弹出提示表示已经连接到 nREPL

打开这个文件的时候,你应该能看到右上角出现了 nREPL 连接的提示。如果没有,你需要确保 Neovim 的执行目录和 Leiningen 的执行目录一致,因为 lein repl 启动 nREPL 的时候会在当前目录生成一个 .nrepl-port 文件,内容为 nREPL 服务器监听的端口。Conjure 需要读取到这个文件才能知道怎么跟服务器通信。

❯ cat .nrepl-port
52947

一旦显示连接成功,就可以开始使用 nREPL 了。

试着先把光标移动到第一行第一列,也就是 defn 的左括号,按下 \ee,也就是先按下 local leader key,再按下两次 E

Neovim 界面,左侧是前文出现过的 hello 函数代码,光标位置在左上角。按下快捷键之后光标右侧出现了 => #‘user/hello 文本,右侧是 nREPL 的记录,也有相同的文本。这个文本是执行 defn 的结果

光标所在行出现了 => #'user/hello,右侧的 nREPL 历史记录里也有——这是刚才 Eval 这个表达式的返回值。

不知道你还记不记得,在 上一篇 中我写过,'quote 的简写。此处的 #'var 的简写,和 quote 一样是特殊形式(即在编译器内部实现和定义的形式,可以理解为编译器特殊对待的对象)。说实话,var 不太好理解,也不必深究,只需要知道他表示某个命名空间下的符号就好。

user 便是一个命名空间,是在没有定义命名空间时的默认值,而 hello 是我们刚刚用 defn 定义的函数。#'user/hello 更准确地描述了这个符号的身份,让编译器知道应该去哪里找到它。

总而言之,它是定义函数时会得到的返回值。

接下来把光标移动到第二行的第一个括号,按下 \ee

Neovim 界面,左侧是前文出现过的 hello 函数代码,光标位置在 println 上,光标右侧出现了 => nil 文本,右侧除了 nil 之外,还显示:;; (out) Hello world!

此时我们只对 (println "Hello world!") 求值,得到了 nil。右侧的 nREPL 历史记录里还有一条以 (out) 开头的注释,内容正是我们要打印的 Hello world!。可见,println 的返回值是 nil(也就是 null,表示空值),而输出到 stdout 或者别的什么输出流里的 Hello world 只是求值 println 产生的副作用

做个小练习,换行,写 (hello) 这段代码,然后把光标移到上面按下 \ee,看看会发生什么。

\ee 的作用是对当前鼠标所在位置的形式(form)进行求值,另一个常用快捷键是 \eb,意思是 Eval Buffer,会把当前这整个 Buffer 的代码全部发送给 nREPL。如果你对整个文件都做了相当多的修改,可以用 \eb 快速告知 nREPL。如果你的光标在一个嵌套的形式里面(比如 (println "Hello World")),但你想要对最外层的形式求值(比如 (defn ...)),可以按 \er(Eval Root),这样就不用移动光标了。

如果你觉得右边打开的窗口太小,想要查看完整的历史记录,可以用 \ls(Log Split),可以打开水平分割的一个 Buffer,在里面显示所有的历史记录;或者用 \lv 垂直分割。更多操作可以用命令 :help conjure 打开使用手册查询。

结构化编辑

普通的编辑器一般只会把代码当文本来看,最多给括号开闭和缩进层级做高亮显示。操作上,双击可以复制整个词、三击可以复制整段话,也只是对词句的操作。这对 Lisp 代码来说可不是好事,一层又一层的括号令人望而却步,而编辑器却帮不上忙。

Emacs 有 paredit ,而 Neovim 也有对应的 实现 。如果不需要改键位的话,直接用默认配置就好:

-- lazy.nvim 
return {
 "julienvincent/nvim-paredit",
 config = function()
 require("nvim-paredit").setup()
 end
}
;; lazy.nvim with Fennel 
{1 :julienvincent/nvim-paredit
 :config (fn []
 ((. (require :nvim-paredit) :setup)))}

nvim-paredit 基于 Tree-sitter,如果你发现后面提到的快捷键用不了,可能需要手动启动 Tree-sitter:

-- init.lua 
vim.api.nvim_create_autocmd("FileType", {
 pattern = {"clojure"}, 
 callback = function ()
 vim.treesitter.start()
 end
}) 
;; init.fnl
(vim.api.nvim_create_autocmd
 "FileType" {"pattern" ["clojure"]
 "callback" (lambda [] (vim.treesitter.start))})

Paredit 的各种操作解释起来有点困难,有个 动画演示教程 ,不过是英文的,而且用的是 Emacs 的快捷键。我就讲一些我常用的快捷键,剩下的请读者自行探索。

首先是 Slurp 和 Barf,这是两个拟声词,分别是吞咽食物的声音和打嗝的声音,可以理解为「吞」和「吐」。这两个操作都有方向,可以向前吞,或向后吞。吞和吐的对象是列表里的元素。

;; 原本的代码
(list some) elements

;; slurp forwards(向前吞)之后的代码 
(list some elements)

;; barf backwards(向后吐)之后的代码
list (some elements)

;; slurp backwards(向后吞)之后的代码
(list some elements)

;; barf forwards(向前吐)之后的代码 
(list some) elements

前和后的区分可能有点反直觉,用英文的 forwards 或者用「前进」这个词来理解可能会清晰一点。代码前进的方向就是一直往后写的方向,所以向前吐是往代码右边吐参数,而向后吞是把代码左边的形式放进第一个元素的位置。nvim-paredit 中,向前吞的快捷键是 >),向后吞的快捷键是 <(;向前吐的快捷键是 <),向后吐的快捷键是 >(。说实话有点难记,多练练就好,或者改成别的键。

也很常用且相对好理解的是 Drag(拖拽),按下 >e 可以把当前元素往前拖,<e 则是往后拖。把 e 换成 p 可以同时一对(pair)元素(有不少 Lisp 元素都是成对出现的,之后会讲到),换成 f 可以拖动一个形式(form)。

Slurp、Barf 和 Drag 是最常用的操作,写 Lisp 代码的时候经常需要改变代码的层级。比方说你写了这样一段代码:(+ a b)。现在你想要对它减去 c,与其把光标移动到括号前面,输入一个左括号,然后删掉编辑器自动补全的右括号,输入 -,然后再把光标移动回括号后面,输入要减去的值和右括号,不如这么做:

;; | 表示光标位置
;; 原本的代码 
(+ a b)| 

;;直接输入 (- c)
(+ a b)(- c)|

;; 按下 <(,向后吞
((+ a b)- c)|

;; 按下两次 b,光标回退到 (+ a b) 的右边 
((+ a b)|- c)

;; 按下 >f,把当前形式往后拖
(-(+ a b) c)

如果熟练掌握结构化编辑,编辑 Lisp 代码的速度会超过编辑一般代码的速度,不过就像刚从全拼转成双拼或者别的输入法时会觉得自己不会打字了一样,paredit 的快捷键需要一段时间适应。

除了编辑,可能还需要一些快速移动的按键:

  1. 默认的 Vim 和 Neovim,在 NORMAL 模式里按下 w(word)会跳转到下一个词的开头,按下 e(end)会跳转到下一个词的末尾,按下 b(back)会返回上一个词;装了 paredit 之后,用 WEB 可以分别跳转到下一个元素的开头、末尾,和上一个元素的开头。
  2. 在 VISUAL 模式中,按下 af(或者连起来:vaf,其中 v 是切换到 VISUAL 模式),可以选中当前形式(包含括号);如果换成 if,就只会选中形式内部的元素,不包含外部的括号。把 f(form)换成 e(element)可以选中元素,换成 F 可以选中顶层的形式而非当前形式。

如果不能马上掌握也不必气馁,接触新的编辑方式就是很难。即便不用 Paredit,也能写 Lisp 代码,不过能学会的话会舒服很多。


最后

好啦,希望你已经能够在电脑上运行 Clojure 程序,并且没那么害怕用一大堆括号编程了。

下一篇文章将重点关注形式(form)的诸多种类:函数(function form)、宏(macro form)和特殊形式(special form)。除了基本的整数、浮点数和字符串,我们还会见到符号(symbol)类型。


  1. Conjure 还支持 Python、JavaScript、PHP、R、Lua、Rust 等语言。
  2. 如果你不知道 Tree-sitter 是什么,其实大部分编辑器都有这个东西。编程语言一般都有自己的 Tree-sitter,用来把某门语言的代码解析成分析用的 AST,可以分析代码结构,最常用来做代码高亮。
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论