Clojure

REPL 编程:增强你的 REPL 工作流程

到目前为止,你已经了解了 REPL 的工作原理;现在我们将重点介绍如何利用 REPL 提升开发体验。你可以改进很多方面

在编辑器和 REPL 之间切换很繁琐。

大多数 Clojure 程序员在日常开发中并不使用基于终端的 REPL:他们使用编辑器中的 REPL 集成,可以将表达式直接写入编辑器缓冲区,并通过一个热键在 REPL 中进行评估。有关更多详细信息,请参阅下面的 编辑器集成 部分。

我想进行一些 Clojure 小实验,但在默认的 CLI 工具中编写代码很痛苦。

如上所述,一种解决方案是使用 编辑器集成。请注意,一些编辑器,如 **Nightcode**,专为 Clojure 提供“开箱即用”的体验而设计。

但是,如果设置编辑器对你来说太复杂,也有一些更符合人体工程学的终端 REPL 客户端

我需要调试从 REPL 运行的程序。

REPL 绝对可以帮助你做到这一点:请参阅下面的 调试工具和技术 部分。

我发现自己在 REPL 中为运行开发环境重复了很多手动步骤。

考虑在你的项目中创建一个“dev”命名空间(例如 myproject.dev),在其中定义函数来自动化常见的开发任务(例如:启动本地 Web 服务器、运行数据库查询、开启/关闭电子邮件发送等)。

当我修改代码时,通常很难将这些更改反映到正在运行的程序中:我必须在 REPL 中进行很多手动操作才能实现这一点。

根据你在编写程序时所做的选择,与它们在 REPL 中的交互将变得更加或更少实用。请参阅下面的 编写 REPL 友好的程序 部分。

我想以“笔记本”格式保存我的 REPL 会话。

Gorilla REPL 就是为此目的而创建的。

我想要比 REPL 提供的更好的数据可视化。

你可能会从专门的 Clojure 编辑器中获得改进的可视化功能:请参阅下面的 编辑器集成 部分。

话虽如此,请记住,REPL 是一个功能齐全的执行环境:特别地,你可以用它来启动专用可视化工具(包括你自己开发的工具)。例如

  • **Reveal** 和 **Cognitect REBL** 是用于导航和可视化 Clojure 数据的图形工具,支持与 Clojure REPL 的双向交互。

  • **oz** 是一个用于显示数值图表 的 Clojure 库

  • **datawalk** 是一个用于交互式探索复杂 Clojure 数据结构的 Clojure 库

  • **system-viz** 是一个用于可视化运行中的 Clojure 系统组件的 Clojure 库

我想自定义我的 REPL。

你通常可以自定义 REPL 的读取、评估和打印方式,但方法取决于你的工具链。例如

  • 使用从 clojure.main 启动的 REPL 时(例如使用 clj 工具时),你可以通过启动一个“子 REPL”来自定义 REPL:请参阅 clojure.main/repl

  • 使用 nREPL.[1] 时,这可以通过编写自定义 中间件 来实现。

我想使用 REPL 连接到实时生产系统。

Clojure 套接字服务器 功能可以用于此目的。**nREPL** 和 **unrepl** 等工具可以提供更丰富的体验。

注意:你可能并不需要所有这些!

根据你的项目和个人喜好,你很可能只会使用本节中介绍的一部分工具和技术。了解这些选项的存在很重要,但不要试图一次性采用所有选项!

编辑器集成

所有 主要的 Clojure 编辑器 都支持在 REPL 中评估代码而无需离开当前代码缓冲区的方式,从而减少了程序员需要进行的上下文切换次数。下面是它的外观(此示例中使用的编辑器是 Cursive

Editor REPL integration

提示:你可以将某些表达式包装在 (comment …​) 块中,以便在加载文件时不会意外地进行评估

;; you would NOT want this function to get called by accident.
(defn transfer-money!
  [from-accnt to-accnt amount]
  ...)

(comment
  (transfer-money! "accnt243251" "accnt324222" 12000)
  )

从编辑器中 REPL 集成中期待什么?

以下是 REPL 集成提供的一些常见编辑器命令。所有主要的 Clojure 编辑器都支持大多数命令

  • 将光标前的表单发送到 REPL:在当前文件的命名空间中,在 REPL 中评估光标前的表达式。这对于在当前命名空间的上下文中进行实验很有用。

  • 将顶层表单发送到 REPL:在当前文件的命名空间中,评估光标当前所处的最大表达式,通常是 (defn …​)(def …​) 表达式。这对于在命名空间中定义或重新定义 Var 很有用。

  • 在 REPL 中加载当前文件。这对于避免 手动加载库 很有用。

  • 将 REPL 的命名空间切换到当前文件:这对于避免键入 (in-ns '…​) 很有用。

  • 内联显示评估结果:在当前表达式旁边显示其评估结果。

  • 用评估结果替换表达式:将编辑器中的当前表达式替换为其评估结果(由 REPL 打印)。

调试工具和技术

虽然传统的调试器可以与 Clojure 一起使用,但 REPL 本身就是一个强大的调试环境,因为它允许你检查和更改正在运行的程序的流程。在本节中,我们将学习一些利用 REPL 进行调试的工具和技术。

使用 prn 打印运行中的值

(prn …​) 表达式可以添加到代码中的关键位置,以打印中间值

(defn average
  "a buggy function for computing the average of some numbers."
  [numbers]
  (let [sum (first numbers)
        n (count numbers)]
    (prn sum) ;; HERE printing an intermediary value
    (/ sum n)))
#'user/average
user=> (average [12 14])
12 ## HERE
6

提示:你可以将 prn 与 (doto …​) 宏结合使用,即 (doto MY-EXPR prn),使添加 prn 调用更不容易侵入

(defn average
  "a buggy function for computing the average of some numbers."
  [numbers]
  (let [sum (first numbers)
        n (count numbers)]
    (/
      (doto sum prn) ;; HERE
      n)))

更进一步:“间谍”宏

一些 Clojure 库提供了比 prn 更强大的版本,它们更具信息量,因为它们还会打印有关包装表达式的信息。例如

  • **tools.logging** 日志记录库提供了一个 spy 宏来记录表达式的代码及其值

  • **spyscope** 库允许你使用非常轻量级的语法插入这些打印调用。

更进一步:跟踪库

跟踪 库,例如 **tools.trace** 和 **Sayid**,可以帮助你检测代码的更大部分,例如通过自动打印给定命名空间中的所有函数调用,或给定表达式中的所有中间值。

实时拦截和保存值

有时,你想对中间值做的不仅仅是打印它们:你想保存它们,以便在 REPL 中对它们进行进一步的实验。这可以通过在值出现的表达式中插入一个 (def …​) 调用来实现

(defn average
  [numbers]
  (let [sum (apply + numbers)
        n (count numbers)]
    (def n n) ;; FIXME remove when you're done debugging
    (/ sum n)))
user=> (average [1 2 3])
2
user=> n
3

这种“内联定义”技术在 Michiel Borkent 的这篇博文中 有更详细的描述。

重现表达式的上下文

在 REPL 中进行调试时,我们通常希望手动重现程序自动执行的操作,即在函数体内评估一些表达式。为此,我们需要重新创建目标表达式的上下文:实现这一点的一个技巧是使用 def 定义与表达式使用的局部变量具有相同名称和值的 Var。下面的“物理学”示例说明了这种方法

(def G 6.67408e-11)
(def earth-radius 6.371e6)
(def earth-mass 5.972e24)

(defn earth-gravitational-force
  "Computes (an approximation of) the gravitational force between Earth and an object
  of mass `m`, at distance `r` of Earth's center."
  [m r]
  (/
    (*
      G
      m
      (if (>= r earth-radius)
        earth-mass
        (*
          earth-mass
          (Math/pow (/ r earth-radius) 3.0))))
    (* r r)))

;;;; calling our function for an object of 80kg at distance 5000km.
(earth-gravitational-force 80 5e6) ; => 616.5217226636292

;;;; recreating the context of our call
(def m 80)
(def r 5e6)
;; note: the same effect could be achieved using the 'inline-def' technique described in the previous section.

;;;; we can now directly evaluate any expression in the function body:
(* r r) ; => 2.5E13
(>= r earth-radius) ; => false
(Math/pow (/ r earth-radius) 3.0) ; => 0.48337835316173317

这种技术在 Stuart Halloway 的文章 REPL 调试:无需堆栈跟踪 中有更详细的描述。**scope-capture** 库旨在自动执行保存和重新创建表达式上下文的繁琐任务。

关于 REPL 调试的社区资源

  • Clojure 工具箱 提供了一个 Clojure 调试库列表。

  • Clojure 的力量:调试 是 Cambium Consulting 的一篇文章,它提供了一系列在 REPL 上进行调试的技术。

  • 从头开始学习 Clojure 由 Aphyr 撰写,包含一篇关于调试的章节,介绍了 Clojure 调试技术以及通用的调试方法。

  • Stuart Halloway 在其文章 REPL 调试:无需堆栈跟踪 中演示了如何利用 REPL 上的快速反馈循环来缩小错误原因,而不使用任何错误信息。

  • Eli Bendersky 撰写了一些关于调试 Clojure 代码的笔记

  • 用科学方法进行调试 是 Stuart Halloway 在一次会议上发表的演讲,他提倡在调试中采用科学方法。

编写 REPL 友好的程序

虽然 REPL 上的交互式开发赋予了程序员很多权力,但也带来了新的挑战:程序必须设计得适合 REPL 交互,这是编写代码时需要注意的新约束。2

如果要全面讲解这个主题,会超出本指南的范围,因此我们仅提供一些提示和资源,以指导您进行自己的研究和问题解决。

REPL 友好的代码可以重新定义。当代码通过 Var(例如通过 (def …​)(defn …​) 定义)调用时,更容易重新定义代码,因为 Var 可以重新定义而不会影响调用它的代码。以下示例说明了这一点,该示例以固定的时间间隔打印一些数字

;; Each of these 4 code examples start a loop in another thread
;; which prints numbers at a regular time interval.

;;;; 1. NOT REPL-friendly
;; We won't be able to change the way numbers are printed without restarting the REPL.
(future
  (run!
    (fn [i]
      (println i "green bottles, standing on the wall. ♫")
      (Thread/sleep 1000))
    (range)))

;;;; 2. REPL-friendly
;; We can easily change the way numbers are printed by re-defining print-number-and-wait.
;; We can even stop the loop by having print-number-and-wait throw an Exception.
(defn print-number-and-wait
  [i]
  (println i "green bottles, standing on the wall. ♫")
  (Thread/sleep 1000))

(future
  (run!
    (fn [i] (print-number-and-wait i))
    (range)))

;;;; 3. NOT REPL-friendly
;; Unlike the above example, the loop can't be altered by re-defining print-number-and-wait,
;; because the loop uses the value of print-number-and-wait, not the #'print-number-and-wait Var.
(defn print-number-and-wait
  [i]
  (println i "green bottles, standing on the wall. ♫")
  (Thread/sleep 1000))

(future
  (run!
    print-number-and-wait
    (range)))

;;;; 4. REPL-friendly
;; The following works because a Clojure Var is (conveniently) also a function,
;; which consist of looking up its value (presumably a function) and calling it.
(defn print-number-and-wait
  [i]
  (println i "green bottles, standing on the wall. ♫")
  (Thread/sleep 1000))

(future
  (run!
    #'print-number-and-wait ;; mind the #' - the expression evaluates to the #'print-number-and-wait Var, not its value.
    (range)))

注意派生 Var。如果 Var b 是根据 Var a 的值定义的,那么每次重新定义 a 时,都需要重新定义 b;最好将 b 定义为一个 0 元函数,该函数使用 a。示例

;;; NOT REPL-friendly
;; if you re-define `solar-system-planets`, you have to think of re-defining `n-planets` too.
(def solar-system-planets
  "The set of planets which orbit the Sun."
  #{"Mercury" "Venus" "Earth" "Mars" "Jupiter" "Saturn" "Uranus" "Neptune"})

(def n-planets
  "The number of planets in the solar system"
  (count solar-system-planets))


;;;; REPL-friendly
;; if you re-define `solar-system-planets`, the behaviour of `n-planets` will change accordingly.
(def solar-system-planets
  "The set of planets which orbit the Sun."
  #{"Mercury" "Venus" "Earth" "Mars" "Jupiter" "Saturn" "Uranus" "Neptune"})

(defn n-planets
  "The number of planets in the solar system"
  []
  (count solar-system-planets))

话虽如此,派生 Var 变得过时的问题可以通过以下方法得到有效缓解

  1. 要么确保 Var 不跨不同文件派生,并在进行更改时注意重新加载整个文件;

  2. 要么使用 clojure.tools.namespace 等工具,这些工具可以跟踪已更改的文件并按顺序重新加载它们。

REPL 友好的代码可以重新加载。确保重新加载命名空间不会改变正在运行的程序的行为。如果 Var 需要精确定义一次(这应该非常少见),请考虑使用 defonce 定义它。

在处理包含多个命名空间的代码库时,以正确的顺序重新加载相应的命名空间可能会变得很困难:tools.namespace 库旨在帮助程序员完成这项任务。

程序状态和源代码应保持同步。您通常希望确保您的程序状态反映您的源代码,反之亦然,但这并非自动的。重新加载代码通常还不够:您还需要相应地转换程序状态。Alessandra Sierra 在她的文章 我的 Clojure 工作流程,重新加载 及其演讲 组件适度结构 中阐述了这个问题。

这促使了状态管理库的创建:

  • Component,它提倡将程序状态表示为一个名为系统的受管理的 Clojure 记录映射。

  • System 是基于 Component 的库,它提供了一组现成的组件。

  • Mount 采用了一种与 Component 截然不同的方法,选择使用 Var 和命名空间作为状态的支持基础结构。3

  • Integrant 是一个更新的库,它共享 Component 的方法,同时解决了一些它所认为的限制。


1。在撰写本文时(2018 年 3 月),nREPL 是 REPL-编辑器集成的最流行工具链
2。类似的现象发生在著名的自动化测试技术中:虽然测试可以为程序员带来很多价值,但它需要额外的注意才能编写“可测试”的代码。与测试一样,REPL 也不应该在编写 Clojure 代码时被忽视。
3。在撰写本文时,Clojure 社区关于这两种方法的相对优劣存在争议。