Clojure

常见问题解答

读取器和语法

关键字会被缓存和驻留。这意味着关键字在程序中的任何地方都会被重复使用(减少内存),并且对相等性的检查实际上变成了对同一性的检查(速度很快)。此外,关键字是可以调用的,可以在映射中查找自身,因此这使得从映射集合中提取特定字段的常见模式成为可能。

读取器页面中,关键字被定义为“类似符号”,而符号“以非数字字符开头”,因此最初的意图是:1 是无效的。事实上,它之所以能够被读取,完全是由于关键字正则表达式中存在一个bug。这个 bug 在 1.6 alpha 版本中得到了修复,但我们很快发现这些关键字在许多实际项目中都有使用。为了避免破坏现有的工作代码,该更改被回滚,并且这种形式将继续得到支持。(在代码和/或文档中,仍然有一些未解决问题需要澄清。)

请注意,名称空间关键字的名称以数字开头一直无法读取或有效,例如 :foo/1。但是,像 ::1 这样的自动解析关键字可以读取,但不能从打印到读取来进行往返转换。

一般来说,最好避免使用以数字开头的关键字,除非是在一个狭窄且受控的范围内。

keyword 函数可用于根据用户数据或其他输入源以编程方式创建关键字。类似地,namespacename 函数可用于将关键字分解成组件。程序通常使用此功能创建用作标识符或映射键的关键字,而无需将这些数据打印和读取回来。

由于这种用例(以及出于性能方面的考虑),在 keyword(或 symbol)的输入上不会执行任何验证检查,这使得可以创建在打印时无法读回为关键字的关键字(由于空格或其他不允许的字符)。如果这对您很重要,则应在创建关键字之前先验证关键字输入。

读取器获取文本(Clojure 源代码)并返回 Clojure 数据,随后会编译和评估这些数据。读取器宏告诉 Clojure 读取器如何读取不是典型 s 表达式的内容(例如,引用 ' 和匿名函数 #())。读取器宏可用于定义读取器读取的全新语法(例如:JSON、XML 或其他格式)——这比常规宏(在编译时发挥作用)具有更强大的语法功能。

但是,与 Lisp 不同,Clojure 不允许用户扩展此读取器宏集。这避免了创建其他用户无法读取的代码的可能性(因为他们没有相应的读取器宏)。Clojure 通过标记文字提供了部分读取器宏的功能,允许您创建通用的可读数据,并且仍然是可扩展的。

另请参阅Clojure 历史论文中关于此的部分(搜索“读取器宏”)。

_ 在 Clojure 中作为符号没有任何特殊含义。但是,使用 _(或以 _ 开头)表示不会在表达式中使用的绑定是一种约定。这种情况的一个常见示例是在顺序解构中跳过不需要的值。

(defn get-y [point]
  (let [[_ y] point]   ;; x-value of point is unused, so mark it with _
    y))

?! 在 Clojure 中作为函数名的一部分没有任何特殊含义。但是,使用尾随 ? 表示谓词函数,使用尾随 ! 表示具有副作用的函数是一种约定。更具体地说,尾随 ? 表示谓词严格返回布尔结果(truefalse)。尾随 ! 最初旨在指示该函数具有副作用,这将使其在ref 事务(在软件事务内存意义上)内部使用不安全。在实际应用中,! 的使用不太一致,有时会以更广泛的方式来指示任何类型的副作用行为。

#() 始终扩展为在您提供的表达式周围包含括号,因此在这种情况下,它会产生 (fn [x] ([x])),当调用向量时,它会失败。相反,使用向量函数 #(vector %)vector,这是正在描述的函数。

集合、序列和转换器

大多数 Clojure 数据结构操作(包括 conj(连接))旨在为用户提供性能预期。使用 conj,预期是在进行此操作效率最高的位置进行插入。列表(作为链接列表)只能在前面进行常数时间插入。向量(索引)设计为在后面扩展。作为用户,在选择使用哪种数据结构时,您应该考虑这一点。在 Clojure 中,向量使用频率要高得多。

如果您的目标是专门“添加到集合的前面”,那么要使用的适当函数是 cons,它始终添加到前面。但是请注意,这将生成一个序列,而不是原始集合类型的实例。

通常,您应该将 Clojure 核心函数划分为以下两类

  • 数据结构函数 - 获取数据结构并返回该数据结构的修改版本(conj、disj、assoc、dissoc 等)。这些函数始终首先获取数据结构。

  • 序列函数 - 获取“可序列化的”并返回可序列化的。[通常我们试图避免承诺返回值实际上是 ISeq 的实例——这在某些情况下允许进行性能优化。]例如 map、filter、remove 等。所有这些函数都最后获取可序列化的。

听起来您正在使用后者,但期望前者的语义(这对 Clojure 新手来说是一个常见问题!)。如果要应用序列函数但对输出数据结构有更多控制权,则有多种方法可以做到这一点。

  1. 使用数据结构等价物,如 mapv 或 filterv 等——这是一个非常有限的集合,可让您执行这些操作,但返回数据结构而不是可序列化的。(mapv inc (filterv odd? [1 2 3]))

  2. 将序列转换的结果通过 into 倒回数据结构:(into [] (map inc (filter odd? [1 2 3])))

  3. 使用转换器(可能与 into 一起使用)——这与 #2 的效果大致相同,但组合的转换可以更有效地应用,而不会创建任何序列——只有最终结果会被构建:(into [] (comp (filter odd?) (map inc)) [1 2 3])。当处理更大的序列或更多的转换时,这会在性能方面产生重大差异。

请注意,所有这些都是急切转换——它们在您调用它们时生成输出向量。原始序列版本 (map inc (filter odd? [1 2 3])) 是惰性的,并且只会根据需要生成值(在幕后进行分块以获得更高的性能)。两者都没有对错之分,但它们在不同的情况下都有用。

主要集合操作数放在前面。这样,人们就可以编写 -> 及其同类,并且它们的位置独立于它们是否具有可变参数。在 OO 语言和 Common Lisp 中(slot-valuearefelt)存在这种传统。

思考序列的一种方法是,它们是从左到右读取,并从右到左馈送。

<- [1 2 3 4]

大多数序列函数都使用和生成序列。因此,将其可视化的一种方法是作为一个链

map <- filter <- [1 2 3 4]

并且思考许多 seq 函数的一种方法是,它们以某种方式进行参数化

(map f) <- (filter pred) <- [1 2 3 4]

因此,序列函数将其源(s)放在最后,任何其他参数放在它们前面,并且 partial 允许像上面一样直接进行参数化。在函数式语言和 Lisp 中存在这种传统。

请注意,这与将主要操作数放在最后并不相同。一些序列函数具有多个源(concat、interleave)。当序列函数是可变的时,通常是在它们的源中。

改编自Rich Hickey 的评论

在执行一系列转换时,序列会在每次转换之间创建一个中间(缓存)序列。转换器创建一个单一的复合转换,该转换以一次急切遍历输入的方式执行。这些是不同的模型,两者都很有用。

转换器的性能优势

  • 源集合迭代 - 当用于可归约输入(集合和其他内容)时,避免创建不必要的输入集合序列 - 有助于节省内存和时间。

  • 中间序列和缓存值 - 由于转换在单次遍历中发生,因此您消除了所有中间序列和缓存值的创建 - 同样,有助于节省内存和时间。当输入集合的大小或转换的数量增加时,前一项和这一项的组合将开始获得巨大的优势(但对于较小的数量,分块序列可能出奇地快,并且会进行竞争)。

转换器的设计/使用优势

  • 转换组合 - 一些用例在将转换组合与转换应用分离时,会具有更清晰的设计。转换器支持这一点。

  • 急切性 - 对于急切处理转换(并可能遇到任何错误)比惰性更重要的用例,转换器非常适用。

  • 资源控制 - 因为您可以更好地控制何时遍历输入集合,所以您也知道何时处理完成。因此,更容易释放或清理输入资源,因为您知道何时发生这种情况。

序列的性能优势

  • 惰性 - 如果您只需要部分输出(例如,用户正在决定使用多少),那么惰性序列通常可以通过延迟处理来提高效率。特别是,序列可以对中间结果进行惰性处理,但转换器使用拉取模型,该模型会急切地生成所有中间值。

  • 无限流 - 由于转换器通常会被急切地使用,因此它们与无限值流不太匹配。

序列的设计优势

  • 消费者控制 - 从 API 返回一个序列,可以让您将输入+转换组合成可以赋予消费者控制权的内容。转换器对此不太适用(但在输入和转换分离的情况下会更适用)。

核心函数

在某个时间点,元数据的使用比现在更麻烦(私有 defn 的语法为#^{:private true}),并且defn- 似乎值得作为一个“简单”版本创建。元数据支持得到了改进并变得“可堆叠”,这使得独立元数据的更容易组合。与其为所有 def 表单创建私有变体,不如在需要时简单地使用^:private元数据,例如在def或其他 def 表单上。

当使用partial(或其他高阶函数组合器,如compjuxt等)时,引用的任何 var 都会在调用partial之前被评估为函数对象,因此它捕获了任何引用的函数 var 的值,而不是 var 本身。例如:(partial my-fn 100)my-fn评估为#'my-fn的当前函数值,然后使用它调用partial。如果在 REPL 中重新绑定了my-fn var,则先前的partial函数将“看不到”这些更改,因为它只拥有函数,而不是 var。

如果您发现这在交互式开发中是一个问题,您可以插入一层间接层。一种选择是使用 var 引用#'myfn,或者您可以使用单独的fndefn重新包含 var 取消引用。或者,您可以在 partial 的位置使用fn或匿名函数字面量。

一般来说,这在正在运行的应用程序中不是问题(因为 var 通常不会被重新绑定),但可能会在交互式 REPL 开发中发生。

Spec

spec 处于 alpha 阶段是为了表明 API 可能会发生变化。spec 从 Clojure 核心分离出来,以便可以独立于主 Clojure 版本更新 spec。在某个时间点,spec 的 API 将被认为是稳定的,届时将删除 alpha 版本。spec 的下一个版本正在alpha.spec开发中。

这个问题没有唯一的正确答案。对于数据规范,通常将它们放在自己的命名空间中很有用,该命名空间可能与数据规范中使用的限定符匹配,也可能不匹配。将限定符与命名空间匹配允许在规范内部和在其他命名空间中的别名中使用自动解析的关键字,但也将它们交织在一起,使重构变得更加复杂。

对于函数规范,大多数人要么将它们放在应用于它们的函数之前或之后,要么放在一个单独的命名空间中,该命名空间可以在需要时选择性地被需要(用于测试或验证)。在后一种情况下,Clojure 核心遵循使用 foo.bar.specs 来保存 foo.bar 中函数的函数规范的模式。

正则表达式运算符(cat、alt、*、+、?等)始终描述顺序集合中的元素。它们本身并不是规范。当在规范上下文中使用时,它们会被强制转换为规范。嵌套的正则表达式运算符组合形成对同一顺序集合的单个正则表达式规范。

要验证嵌套集合,请使用s/spec包装内部正则表达式,在正则表达式运算符之间强制执行规范边界。

Instrument 的目的是验证函数是否根据其参数规范被调用。也就是说,函数是否被正确调用?此功能应在开发过程中使用。

检查函数是否正确运行是测试时活动,这应该使用check函数来检查,该函数实际上将使用生成的 args 调用函数,并在每次调用时验证 ret 和 fn 规范。

是的,设置 Java 系统属性-Dclojure.spec.skip-macros=true,在宏展开期间将不会检查任何宏规范。

Spec 的总体理念是“开放”规范,其中映射可以包含超出在 s/keys 规范中指定为必需或可选的键。实现受限键集的一种方法是s/and添加一个额外的约束。

(s/def ::auth
  (s/and
    (s/keys :req [::user ::password])
    #(every? #{::user ::password} (keys %))))

目前,不可以。这正在考虑用于 spec 的下一个版本。

状态和并发

这些中的每一个实际上都解决了不同的用例。

  • Reducers 最适合在计算对现有内存中数据(在映射或向量中)的转换时进行细粒度的并行数据处理。通常,当您有数千个小型数据项需要计算并且有多个内核可以完成工作时,它最有效。任何被描述为“容易并行”的内容。

  • Futures 最适合将工作推送到后台线程并在以后获取它(或并行执行 I/O 等待)。它更适合大型块状任务(在后台获取大量数据)。

  • core.async 主要用于组织应用程序的子系统或内部结构。它具有通道(队列)来将值从一个“子进程”(go 块)传送到另一个“子进程”。因此,您实际上是在获得关于如何分解程序的并发性和架构优势。您只能在 core.async 中获得的杀手级功能是能够等待来自多个通道的 I/O 事件,以获取其中任何一个的第一个响应(通过 alt/alts)。Promise 也可以用来在独立的线程/子进程之间传递单个值,但它们只能传递一次。

  • 像 pmap、java.util 队列和执行器以及 claypoole 等库正在执行粗粒度的“任务”并发。这里与 core.async 有些重叠,core.async 具有非常有用的转换器友好的管道功能。

这在使用futurepmapagent-send或其他调用这些函数的函数的程序上下文中被问得最多。当这样的程序完成时,在退出之前会有 60 秒的暂停。要解决此问题,请在程序退出时调用shutdown-agents

Clojure 使用两个内部线程池来服务 futures 和 agent 函数执行。这两个池都使用非守护线程,并且只要任何非守护线程处于活动状态,JVM 就不会退出。特别是,服务 futures 和 agent 发送调用池使用具有 60 秒超时的 Executor 缓存线程池。在上述场景中,程序将等待后台线程完成其工作,并且线程过期后才能退出。

如果默认情况下包含读取操作,则 STM 会更慢(因为更多的事务需要可序列化性)。但是,在许多情况下,不需要包含读取操作。因此,用户可以在必要时选择接受性能损失,并在不需要时获得更快的性能。

命名空间

不(尽管这很典型)。一个命名空间可以通过使用load加载辅助文件并在这些文件中使用in-ns来保留命名空间(clojure.core 就是以这种方式定义的)来拆分为多个文件。此外,可以在单个文件中声明多个命名空间(尽管这很不寻常)。

ns 是一个执行许多操作的宏

  • 创建一个新的内部 Namespace 对象(如果它还不存在)

  • 将该命名空间设为新的当前命名空间(*ns*

  • 自动引用来自 clojure.core 的所有 var 并导入来自 java.lang 的所有类

  • 根据指定需要/引用其他命名空间和 var

  • (以及其他可选内容)

ns 不会像您建议的那样返回函数或任何可调用的内容。

虽然 ns 通常放在 clj 文件的顶部,但它实际上只是一个普通的宏,可以在 repl 中以相同的方式调用。它也可以在单个文件中使用多次(尽管这对大多数 clj 程序员来说会很奇怪,并且在 AOT 中可能无法按预期工作)。

编译器

任何已直接链接的内容都将看不到对 var 的重新定义。例如,如果您重新定义了 clojure.core 中的内容,则核心的其他使用该 var 的部分将看不到重新定义(但是您在 REPL 中新编译的任何内容都会看到)。在实践中,这通常不是问题。

对于您自己的应用程序的部分,您可能希望仅在构建和部署到生产环境时启用直接链接,而不是在 REPL 中进行开发时使用它。或者,您可能需要使用 ^:redef 标记应用程序的部分,如果您希望始终允许重新定义或 ^:dynamic 用于动态 var。

Java 和互操作

使用 $ 分隔外部类和内部类名称。例如:java.util.Map$Entry是 Map 内部的 Entry 内部类。

原语类型可以在装箱类的静态 TYPE 字段中找到,例如:Integer/TYPE

Java 将尾随 varargs 参数视为数组,可以通过传递显式数组从 Clojure 调用它。

示例

;; Invoke static Arrays.asList(T... a)
(java.util.Arrays/asList (object-array [0 1 2]))

;; Invoke static String.format(String format, Object... args)
(String/format "%s %s, %s" (object-array ["March" 1 2016]))

;; For a primitive vararg, use the appropriate primitive array constructor
;; Invoke put(int row, int col, double... data)
(.put o 1 1 (double-array [2.0]))

;; Passing at least an empty array is required if there are no varargs
(.put o 1 1 (double-array []))

;; into-array can be used to create an empty typed array
;; Invoke getMethod(String name, Class... parameterTypes) on a Class instance
(.getMethod String "getBytes" (into-array Class []))

Java 9 添加了一个模块系统,允许代码被划分为模块,其中模块外部的代码无法调用模块内部的代码,除非该代码已被模块导出。Java 中受此更改影响的领域之一是反射访问。当 Clojure 遇到一个 Java 交互调用,并且没有目标对象或函数参数的足够类型信息时,它会使用反射。例如

(def fac (javax.xml.stream.XMLInputFactory/newInstance))
(.createXMLStreamReader fac (java.io.StringReader. ""))

这里 faccom.sun.xml.internal.stream.XMLInputFactoryImpl 的一个实例,它是 javax.xml.stream.XMLInputFactory 的扩展。在 java.xml 模块中,javax.xml.stream 是一个导出的包,但 XMLInputFactoryImpl 是该包中公共抽象类的内部实现。这里对 createXMLStreamReader 的调用将是反射的,并且 Reflector 将尝试根据实现类调用该方法,该类在模块外部不可访问,从而产生

WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by clojure.lang.Reflector (file:/.m2/repository/org/clojure/clojure/1.10.0/clojure-1.10.0.jar) to method com.sun.xml.internal.stream.XMLInputFactoryImpl.createXMLStreamReader(java.io.Reader)
WARNING: Please consider reporting this to the maintainers of clojure.lang.Reflector
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release

这里首先要注意的是这是一个警告。从 Java 9 到所有当前版本都允许进行调用,并且代码将继续工作。

有几个潜在的解决方法

  • 也许最好的方法是向导出的类型提供类型提示,以便调用不再是反射的

(.createXMLStreamReader ^javax.xml.stream.XMLInputFactory fac (java.io.StringReader. ""))
  • 从 Clojure 1.10 开始,使用 --illegal-access=deny 关闭非法访问。然后,Java 反射系统将向 Clojure 提供必要的反馈,以检测通过不可访问的类进行调用不是一种选择。Clojure 将找到公共调用路径,并且不会发出警告。

  • 使用 JVM 模块系统标志(--add-exports 等)强制导出内部包以避免警告。不推荐此方法。

如果难以从警告中判断反射发生的位置,添加以下标志可能会有帮助

--illegal-access=debug

例如,通过 Clojure CLI,使用 -J 选项(或作为 deps.edn 中别名下的 :jvm-opts 的一部分)

clj -J--illegal-access=debug

设计和使用

由于其专注于不可变数据,因此通常不会高度重视数据封装。由于数据是不可变的,因此无需担心其他人修改值。同样,由于 Clojure 数据旨在直接操作,因此提供对数据的直接访问比将其包装在 API 中更有价值。

所有 Clojure var 都是全局可用的,因此命名空间中函数的封装也没有太多内容。但是,能够将 var 标记为私有(对于函数使用 defn-,或对于值使用带 ^:privatedef)对于开发人员来说是一种方便的方法,可以指示 API 的哪些部分应被视为供使用,哪些部分是实现的一部分。

依赖和 CLI

否。Clojure CLI 专注于 a) 构建类路径和 b) 启动 Clojure 程序。它不(也不会)创建工件、部署工件等,尽管它们可以通过工具和库来促进这些操作。

tools.deps 旨在为依赖项解析和类路径构建提供编程构建块。clj/clojure 将这些构建块包装成命令行形式,可用于运行 Clojure 程序。您可以组合这些部分来执行许多其他操作。

否。现在存在其他工具可以执行此操作,或者可以构建在现有功能之上,但这并非初始目标的一部分。

如果您不需要任何额外的依赖项,只需将 #!/usr/bin/env clojure 作为第一行。请注意,clojure 不会自动调用 -main 函数,因此请确保您的文件不仅仅定义函数。您可以在 *command-line-args* 中找到命令行参数。

如果您确实需要额外的依赖项,请尝试以下方法,由 Dominic Monroe 提供,将您需要的任何依赖项替换为 funcool/tubax

#!/bin/sh

"exec" "clojure" "-Sdeps" '{:deps {funcool/tubax {:mvn/version "0.2.0"}}}' "$0" "$@"

;; Clojure code goes here.

贡献

归纳起来有两个原因

  1. 保护 Clojure 免受未来可能阻碍企业采用它的法律挑战。

  2. 使 Clojure 能够在不同的开源许可证下重新许可,如果这样做有利的话。

签署贡献者协议授予 Rich Hickey 您贡献的联合所有权。作为交换,Rich Hickey 保证 Clojure 将始终在由 自由软件基金会开源计划 批准的开源许可证下提供。

这是 Adobe EchoSign 的一个特殊情况,特定于其电子邮件帐户已与 Adobe EchoSign 帐户关联的用户。在这种情况下,EchoSign 将在主题行中使用您现有个人资料中的公司名称,而不是表单上签署的个人姓名。别担心!这没有任何影响 - 该协议已签署并作为电子邮件附件提供。

Rich Hickey 更喜欢评估附加到 JIRA 票证的补丁。这并非为了增加贡献者的难度或出于法律原因,而是出于工作流程偏好。有关更多详细信息,请参阅 开发页面

链接 到 2012 年 10 月 Rich Hickey 在 Clojure Google 群组中的相关帖子。

未来构想

人们经常要求 Clojure 的“原生”版本,即不依赖 JVM 的版本。ClojureScript 自托管是当前的一种途径,但可能仅适用于一部分用例。可以使用 GraalVM 项目创建独立的二进制可执行文件。使用 Graal 生成的原生镜像启动速度极快,但与完整的 JVM 相比,可能优化性能的机会更少。

但是,这两者都不太可能是人们在要求“Clojure 的原生版本”时所设想的,即语言的版本不依赖于 JVM,并且直接编译为原生可执行文件,可能是通过类似 LLVM 的东西。Clojure 利用了 JVM 的大量性能、可移植性和功能,并且严重依赖于诸如世界一流的垃圾收集器之类的东西。构建“Clojure 原生”将需要大量工作才能使 Clojure 的版本速度更慢(可能慢得多)、可移植性更差,并且功能显著减少(因为 Clojure 库严重依赖于 JDK)。Clojure 核心团队没有计划开展这项工作,但这将是一个令人惊叹的学习项目,我们鼓励您尝试!

原文作者:Alex Miller