Clojure

读取器

Clojure 是一种同像语言,这是一个花哨的术语,描述了 Clojure 程序由 Clojure 数据结构表示的事实。这是 Clojure(和 Common Lisp)与大多数其他编程语言之间的一个非常重要的区别——Clojure 是根据数据结构的求值定义的,**而不是**根据字符流/文件的语法定义的。Clojure 程序操作、转换和生成其他 Clojure 程序非常常见且容易。

也就是说,大多数 Clojure 程序最初都是以文本文件形式存在的,而 *读取器* 的任务是解析文本并生成编译器将看到的的数据结构。这不仅仅是编译器的一个阶段。读取器和 Clojure 数据表示本身在许多与使用 XML 或 JSON 等相同的上下文中都有效用。

可以说读取器以字符为基础定义语法,而 Clojure 语言以符号、列表、向量、映射等为基础定义语法。读取器由函数read表示,它从流中读取下一个形式(而不是字符),并返回该形式表示的对象。

由于我们必须从某个地方开始,因此本参考从求值开始的地方开始,即读取器形式。这不可避免地需要讨论数据结构,其描述细节以及编译器的解释将在后面介绍。

读取器形式

符号

  • 符号以非数字字符开头,可以包含字母数字字符以及 *、+、!、-、_、'、?、<、> 和 =(最终可能允许其他字符)。

  • '/' 具有特殊含义,它可以在符号中间使用一次,用于分隔命名空间和名称,例如 my-namespace/foo。'/' 本身表示除法函数。

  • '. ' 具有特殊含义 - 它可以在符号中间使用一次或多次,用于指定完全限定的类名,例如 java.util.BitSet,或在命名空间名称中。以 '.' 开头或结尾的符号由 Clojure 保留。包含 / 或 . 的符号被称为“限定”符号。

  • 以 ':' 开头或结尾的符号由 Clojure 保留。符号可以包含一个或多个不重复的 ':'。

字面量

  • 字符串 - 用“双引号”括起来。可以跨越多行。支持标准 Java 转义字符。

  • 数字 - 通常按照 Java 的方式表示

    • 整数可以无限长,在范围内时将读取为 Long,否则将读取为 clojure.lang.BigInts。带有 N 后缀的整数始终读取为 BigInts。八进制表示法允许使用 0 前缀,十六进制表示法允许使用 0x 前缀。如果可能,可以使用基数从 2 到 36 的任何基数指定它们(请参阅Long.parseLong());例如 2r1010100528r520x2a36r1642 都是相同的 Long。

    • 浮点数读取为 Double;带有 M 后缀的读取为 BigDecimal。

    • 支持比率,例如 22/7

  • 字符 - 以反斜杠开头:\c\newline\space\tab\formfeed\backspace\return 生成相应的字符。Unicode 字符用 \uNNNN 表示,如 Java 中所示。八进制数用 \oNNN 表示。

  • nil 表示“无/无值” - 表示 Java null 并测试逻辑假值

  • 布尔值 - truefalse

  • 符号值 - ##Inf##-Inf##NaN

  • 关键字 - 关键字类似于符号,但

    • 它们可以并且必须以冒号开头,例如 :fred。

    • 它们不能在名称部分包含 '.',也不能命名类。

    • 与符号一样,它们可以包含命名空间 :person/name,其中可能包含 '.'。

    • 以两个冒号开头的关键字会在当前命名空间中自动解析为限定关键字

      • 如果关键字未限定,则命名空间将为当前命名空间。在 user 中,::rect 读取为 :user/rect

      • 如果关键字已限定,则将使用当前命名空间中的别名解析命名空间。在将 x 作为 example 的别名的命名空间中,::x/foo 解析为 :example/foo

列表

列表是用括号括起来的零个或多个形式:(a b c)

向量

向量是用方括号括起来的零个或多个形式:[1 2 3]

映射

  • 映射是用花括号括起来的零个或多个键/值对:{:a 1 :b 2}

  • 逗号被视为空格,可用于组织键值对:{:a 1, :b 2}

  • 键和值可以是任何形式。

映射命名空间语法

在 Clojure 1.9 中添加

映射字面量可以选择使用 #:ns 前缀为映射中的键指定默认命名空间上下文,其中 *ns* 是命名空间的名称,并且前缀位于映射的起始花括号 { 之前。此外,#:: 可用于自动解析具有与自动解析关键字相同语义的命名空间。

具有命名空间语法的映射字面量读取时与没有命名空间的映射相比存在以下差异

    • 没有命名空间的关键字或符号键将使用默认命名空间读取。

    • 具有命名空间的关键字或符号键不会受到影响,**但**特殊命名空间 _ 除外,它在读取期间会被移除。这允许在具有命名空间语法的映射字面量中将没有命名空间的关键字或符号指定为键。

    • 不是符号或关键字的键不受影响。

    • 值不受影响。

    • 嵌套映射字面量键不受影响。

例如,以下具有命名空间语法的映射字面量

#:person{:first "Han"
         :last "Solo"
         :ship #:ship{:name "Millennium Falcon"
                      :model "YT-1300f light freighter"}}

读取为

{:person/first "Han"
 :person/last "Solo"
 :person/ship {:ship/name "Millennium Falcon"
               :ship/model "YT-1300f light freighter"}}

集合

集合是用 # 开头的花括号括起来的零个或多个形式:#{:a :b :c}

deftype、defrecord 和构造函数调用(1.3 版及更高版本)

  • 对 Java 类、deftype 和 defrecord 构造函数的调用可以使用其完全限定的类名加上 # 并后跟一个向量来调用:#my.klass_or_type_or_record[:a :b :c]

  • 向量部分中的元素将**未经求值**传递给相关的构造函数。defrecord 实例也可以使用类似的形式创建,该形式采用映射而不是向量:#my.record{:a 1, :b 2}

  • 映射中的键值将**未经求值**分配给 defrecord 中的相关字段。defrecord 中任何没有与字面量映射中的相应条目相对应的字段都将被分配 nil 作为其值。映射字面量中的任何额外键值都将添加到生成的 defrecord 实例中。

宏字符

读取器的行为由内置构造和称为读取表的扩展系统共同驱动。读取表中的条目提供了从某些字符(称为宏字符)到特定读取行为(称为读取器宏)的映射。除非另有说明,否则宏字符不能用于用户符号中。

引用(')

'form(quote form)

字符(\)

如上所述,生成字符字面量。示例字符字面量为:\a \b \c

以下特殊字符字面量可用于常用字符:\newline\space\tab\formfeed\backspace\return

Unicode 支持遵循 Java 约定,支持对应于底层 Java 版本。Unicode 字面量的形式为 \uNNNN,例如 \u03A9 是 Ω 的字面量。

注释(;)

单行注释,导致读取器忽略从分号到行尾的所有内容。

解引用(@)

@form ⇒ (deref form)

元数据(^)

元数据是与某些类型的对象关联的映射:符号、列表、向量、集合、映射、返回 IMeta 的标记字面量以及记录、类型和构造函数调用。元数据读取器宏首先读取元数据并将其附加到读取的下一个形式(请参阅with-meta 以将元数据附加到对象)
^{:a 1 :b 2} [1 2 3] 生成向量 [1 2 3],其元数据映射为 {:a 1 :b 2}

简写版本允许元数据为简单的符号或字符串,在这种情况下,它被视为具有键 :tag 和值 (解析后的) 符号或字符串的单条目映射,例如
^String x^{:tag java.lang.String} x 相同

此类标签可用于向编译器传达类型信息。

另一个简写版本允许元数据为关键字,在这种情况下,它被视为具有键为关键字且值为 true 的单条目映射,例如
^:dynamic x^{:dynamic true} x 相同

元数据可以进行链式操作,在这种情况下,它们会从右到左合并。

分派 (#)

分派宏会导致读取器使用来自另一个表的读取器宏,该宏由后续字符索引

  • #{} - 请参阅上面的集合

  • 正则表达式模式 (#"pattern")

    正则表达式模式在读取时被读取并编译。生成的object的类型为java.util.regex.Pattern。正则表达式字符串不遵循与字符串相同的转义字符规则。具体来说,模式中的反斜杠被视为自身(并且不需要用额外的反斜杠进行转义)。例如,(re-pattern "\\s*\\d+") 可以更简洁地写成 #"\s*\d+"

  • Var-quote (#')

    #'x(var x)

  • 匿名函数字面量 (#())

    #(…​)(fn [args] (…​))
    其中args由参数字面量的存在决定,参数字面量的形式为 %、%n 或 %&。% 是 %1 的同义词,%n 指定第 n 个参数(从 1 开始),%& 指定剩余参数。这不是 fn 的替代品 - 惯用的用法是用于非常短的一次性映射/过滤函数等。#() 形式不能嵌套。

  • 忽略下一个表单 (#_)

    读取器完全跳过 #_ 后面的表单。(这比 comment 宏(产生 nil)更彻底地删除)。

语法引用(`,注意,“反引号”字符)、解引用(~)和解引用拼接(~@)

对于除符号、列表、向量、集合和映射之外的所有表单,`x 与 'x 相同。

对于符号,语法引用会在当前上下文中解析符号,生成一个完全限定的符号(即命名空间/名称或 fully.qualified.Classname)。如果符号未限定命名空间且以 '#' 结尾,则会解析为一个具有相同名称的生成符号,并在其后附加 '_' 和唯一的 ID。例如,x# 将解析为 x_123。语法引用表达式中对该符号的所有引用都解析为相同的生成符号。

对于列表/向量/集合/映射,语法引用建立相应数据结构的模板。在模板中,未限定的表单的行为就像递归语法引用一样,但可以通过限定为解引用或解引用拼接来免除表单进行此类递归引用,在这种情况下,它们将被视为表达式并分别由其值或值序列替换。

例如

user=> (def x 5)
user=> (def lst '(a b c))
user=> `(fred x ~x lst ~@lst 7 8 :nine)
(user/fred user/x 5 user/lst a b c 7 8 :nine)

读取表目前无法访问用户程序。

可扩展数据表示法 (edn)

Clojure 的读取器支持 可扩展数据表示法 (edn) 的超集。edn 规范正在积极开发中,并通过以语言中立的方式定义 Clojure 数据语法的子集来补充本文档。

标记字面量

标记字面量是 Clojure 对 edn 标记元素 的实现。

当 Clojure 启动时,它会在类路径的根目录中搜索名为 data_readers.cljdata_readers.cljc 的文件。每个这样的文件都必须包含一个 Clojure 符号映射,如下所示

{foo/bar my.project.foo/bar
 foo/baz my.project/baz}

每对中的键是 Clojure 读取器将识别的标记。这对中的值是 Var 的完全限定名称,读取器将调用该名称来解析标记后面的表单。例如,给定上面的 data_readers.clj 文件,Clojure 读取器将解析此表单

#foo/bar [1 2 3]

通过在向量 [1 2 3] 上调用 Var #'my.project.foo/bar。数据读取器函数在读取器将其作为正常的 Clojure 数据结构读取后调用表单。对于您自己的数据读取器函数,您应该通过抛出具有提供错误信息的 RuntimeException 实例来报告错误。

没有命名空间限定符的读取器标记保留给 Clojure 使用。默认读取器标记在 default-data-readers 中定义,但可以在 data_readers.clj / data_readers.cljc 中或通过重新绑定 *data-readers* 来覆盖。如果找不到标记的数据读取器,则将在 *default-data-reader-fn* 中绑定的函数将与标记和值一起调用以生成值。如果 *default-data-reader-fn* 为 nil(默认值),则会抛出 RuntimeException。

如果提供了 data_readers.cljc,则使用与任何其他带有读取器条件的 cljc 源文件相同的语义读取。

内置标记字面量

Clojure 1.4 引入了瞬时UUID标记字面量。瞬时格式为 #inst "yyyy-mm-ddThh:mm:ss.fff+hh:mm"。注意:此格式的某些元素是可选的。有关详细信息,请参阅代码。默认读取器将默认将提供的字符串解析为 java.util.Date。例如

(def instant #inst "2018-03-28T10:48:00.000")
(= java.util.Date (class instant))
;=> true

由于 *data-readers* 是一个可以绑定的动态 var,因此您可以用不同的读取器替换默认读取器。例如,clojure.instant/read-instant-calendar 将字面量解析为 java.util.Calendar,而 clojure.instant/read-instant-timestamp 将其解析为 java.util.Timestamp

(binding [*data-readers* {'inst read-instant-calendar}]
  (= java.util.Calendar (class (read-string (pr-str instant)))))
;=> true

(binding [*data-readers* {'inst read-instant-timestamp}]
  (= java.util.Timestamp (class (read-string (pr-str instant)))))
;=> true

#uuid 标记字面量将被解析为 java.util.UUID

(= java.util.UUID (class (read-string "#uuid \"3b8a31ed-fd89-4f1b-a00f-42e3d60cf5ce\"")))
;=> true

默认数据读取器函数

如果在读取标记字面量时找不到数据读取器,则会调用 *default-data-reader-fn*。您可以设置自己的默认数据读取器函数,并且提供的 tagged-literal 函数可用于构建可以存储未处理字面量的对象。tagged-literal 返回的对象支持 :tag:form 的关键字查找

(set! *default-data-reader-fn* tagged-literal)

;; read #object as a generic TaggedLiteral object
(def x #object[clojure.lang.Namespace 0x23bff419 "user"])

[(:tag x) (:form x)]
;=> [object [clojure.lang.Namespace 599782425 "user"]]

读取器条件

Clojure 1.7 引入了一种新的扩展名 (.cljc),用于可由多个 Clojure 平台加载的可移植文件。管理特定于平台的代码的主要机制是将该代码隔离到最少数量的命名空间中,然后为这些命名空间提供特定于平台的版本 (.clj/.class 或 .cljs)。

在无法隔离代码的不同部分或代码大部分可移植且只有少量特定于平台的部分的情况下,1.7 还引入了读取器条件,这些条件仅在 cljc 文件和默认 REPL 中受支持。应谨慎使用读取器条件,仅在必要时使用。

读取器条件是一种新的读取器分派表单,以 #?#?@ 开头。两者都包含一系列交替的功能和表达式,类似于 cond。每个 Clojure 平台都有一个众所周知的“平台功能” - :clj:cljs:cljr。读取器条件中的每个条件都按顺序检查,直到找到与平台功能匹配的功能。读取器条件将读取并返回该功能的表达式。每个未选分支上的表达式都将被读取但会被跳过。众所周知的 :default 功能将始终匹配,并且可用于提供默认值。如果没有任何分支匹配,则不会读取任何表单(就像没有读取器条件表达式一样)。

非官方 Clojure 平台的实现者应使用限定关键字作为其平台功能,以避免名称冲突。未限定的平台功能保留给官方平台使用。

以下示例将在 Clojure 中读取为 Double/NaN,在 ClojureScript 中读取为 js/NaN,在任何其他平台中读取为 nil

#?(:clj     Double/NaN
   :cljs    js/NaN
   :default nil)

#?@ 的语法完全相同,但表达式预计会返回一个可以拼接到周围上下文的集合,类似于语法引用中的解引用拼接。不支持在顶层使用读取器条件拼接,这将抛出异常。一个例子

[1 2 #?@(:clj [3 4] :cljs [5 6])]
;; in clj =>        [1 2 3 4]
;; in cljs =>       [1 2 5 6]
;; anywhere else => [1 2]

readread-string 函数可以选择地将选项映射作为第一个参数。当前的功能集和读取器条件行为可以在选项映射中使用这些键和值设置

  :read-cond - :allow to process reader conditionals, or
               :preserve to keep all branches
  :features - persistent set of feature keywords that are active

从 Clojure 测试 ClojureScript 读取器条件的示例

(read-string
  {:read-cond :allow
   :features #{:cljs}}
  "#?(:cljs :works! :default :boo)")
;; :works!

但是,请注意,Clojure 读取器始终也会注入平台功能 :clj。有关平台无关的读取,请参阅 tools.reader

如果读取器使用 {:read-cond :preserve} 调用,则读取器条件和未执行的分支将作为数据保存在返回的表单中。读取器条件将作为支持关键字检索(用于带有 :form:splicing? 标志的键)的类型返回。读取但跳过的标记字面量将作为支持关键字检索(用于带有 :form:tag 键的键)的类型返回。

(read-string
  {:read-cond :preserve}
  "[1 2 #?@(:clj [3 4] :cljs [5 6])]")
;; [1 2 #?@(:clj [3 4] :cljs [5 6])]

以下函数也可用于这些类型的谓词或构造函数
reader-conditional? reader-conditional tagged-literal? tagged-literal