(spec/keys :req [::x ::y (or ::secret (and ::user ::pwd))] :opt [::z])
Clojure 是一种动态语言。其中一个含义是,代码运行不需要类型注解。虽然 Clojure 支持一些类型提示,但它们既不是强制机制,也不全面,并且仅限于向编译器传递信息以帮助生成高效代码。Clojure 通过 JVM 本身获得更丰富的类型集的运行时检查。
然而,始终是 Clojure 的指导原则,并得到社区的广泛认可和实践,即简单地将信息表示为数据。因此,Clojure 系统的重要属性由数据的形状和其他预测性属性表示和传达,而由于运行时类型是无法区分的异构 map 和向量,因此在任何地方都没有捕获或检查这些属性。
大多数用于指定结构的系统将键集的规范(例如 map 中的键、对象中的字段)与由这些键指定的值的规范混淆。即,在这种方法中,map 的模式可能会说:a-key 的类型是 x-type,而:b-key 的类型是 y-type。这是导致僵化和冗余的主要来源。
在 Clojure 中,我们通过动态组合、合并和构建 map 来获得强大的功能。我们经常处理可选和部分数据、由不可靠的外部来源生成的数据、动态查询等。这些 map 表示同一键的不同集合、子集、交集和并集,并且通常应该对在任何位置使用的同一键具有相同的语义。定义每个子集/并集/交集的规范,然后冗余地陈述每个键的语义,这既是一种反模式,在最动态的情况下也无法实现。
许多用户,尤其是初学者,对手工编写的解析和解构代码生成的错误消息感到沮丧和困惑,尤其是在宏中,宏存在两个执行上下文(宏在编译时运行,其扩展在运行时运行,两者都可能因用户错误而失败)。这导致了对“宏语法”的需求,但实际上宏只是数据→数据的函数,任何用于数据验证和解构的解决方案都应该对它们以及对任何其他函数一样有效。即,宏是上述问题的一个实例。
最后,在所有语言中,无论动态与否,测试对于质量都至关重要。许多关键属性没有被常见的类型系统捕获。但是手动测试的有效性/工作量比率非常低。基于属性的生成式测试(如 test.check 中为 Clojure 实现的那样)已被证明比手动编写的测试强大得多。
然而,基于属性的测试需要定义属性,这需要额外的努力和专业知识才能生成,并且在函数级别上,与函数规范有很大的重叠。许多有趣的函数级属性可以通过结构+预测性规范来捕获。理想情况下,规范应该与生成式测试集成,并“免费”提供某些类别的生成式测试。
Species - 外观、形式、种类、类型,相当于 spec(ere)观察、看待 Specify - species + -ficus -fic (制作) |
规范关乎事物“外观”,但最重要的是,它本身是被观察的对象。规范应该易于阅读,由程序员已经使用的“词语”(谓词函数)组成,并集成在文档中。
我们并不(也无法)生活在一个不会犯错的世界中。相反,我们会定期检查我们是否犯了错误。亚马逊不会通过 UPS<Trucks<Boxes<TV>>>
向您发送电视。因此,您偶尔可能会收到微波炉,但供应链不会因正确性证明而负担过重。相反,我们在边缘进行检查并运行测试。
没有理由将我们的规范限制在我们能够证明的内容上,但这主要是类型系统所做的。我们希望就我们的系统传达和验证更多内容。这超出了结构/表示类型和标记,扩展到例如缩小域或详细说明输入之间或输入与输出之间关系的谓词。此外,我们最关心的属性通常是运行时值,而不是某些静态概念。因此,spec 不是类型系统。
所有程序都使用名称,即使类型系统没有使用,它们也捕获了重要的语义。Int x Int x Int
就足够了(是长度/宽度/高度还是高度/宽度/深度?)。因此,spec 不会具有未标记的序列组件或未标记的联合绑定。当spec需要与用户讨论规范时,例如在错误报告中,以及反之亦然,例如当用户希望覆盖规范中的生成器时,这一点的实用性就变得很明显。当所有分支都被命名时,您可以使用路径来讨论规范的部分内容。
Clojure 支持命名空间关键字和符号。请注意,这里我们只是在谈论命名空间限定的名称,而不是 Clojure 命名空间对象。这些名称被悲惨地低估了,并带来了重要的优势,因为它们始终可以在字典/数据库/map/集合中无冲突地共存。spec 将允许(仅)命名空间限定的关键字和符号来命名规范。使用命名空间键进行信息 map 的人(我们希望看到这种做法发展壮大)可以将这些属性的规范直接注册在这些名称下。这从根本上改变了 map 的自描述,尤其是在动态上下文中,并鼓励组合和一致性。
在 Lisp(以及 Clojure)中,代码即数据。但数据在您为其定义语言之前不是代码。该领域中的许多 DSL 都是针对模式的数据表示。但预测性规范具有开放且庞大的词汇表,并且大多数有用的谓词已经存在并且作为核心和其他命名空间中的函数而广为人知,或者可以编写为简单的表达式。必须“数据化”(可能重命名)所有这些谓词几乎没有价值,并且理解精确语义的成本很高。spec 而是利用原始谓词和表达式本身就是数据的事实,并捕获该数据以用于在文档和错误报告中与用户进行沟通。是的,这意味着 clojure.spec
的更多表面积将是宏,但规范绝大多数是由人们编写的,并且在组合时也是手动编写的。
如上所述,定义键值细节的映射是一种基本的关注点混合,不会被支持。映射规范详细说明了必需/可选键(即集合成员资格方面),而关键字/属性/值语义是独立的。映射检查分为两个阶段,首先检查必需键的存在,然后检查键/值的一致性。即使在运行时存在的(命名空间限定的)键不在映射规范中,也可以执行后一个阶段。这对组合和动态性至关重要。
不可避免地,人们会尝试使用规范系统来详细说明实现决策,但这样做会损害自身利益。最好和最有用的规范(以及接口)都与纯粹的信息方面相关。只有信息规范才能在网络上和跨系统工作。我们始终会优先考虑信息方法,并在发生冲突时优先选择它。
基本思想是,规范只不过是谓词的逻辑组合。在底层,我们讨论的是您习惯使用的简单布尔谓词,例如 int?
或 symbol?
,或者您自己构建的表达式,例如 #(< 42 % 66)
。spec 添加了逻辑运算符,如 spec/and
和 spec/or
,它们以逻辑方式组合规范,并提供深度报告、生成和一致性支持,并且在 spec/or
的情况下,提供带标签的返回值。
映射键集的规范提供了对必需和可选键集的规范。通过使用 keys
并传递 :req
和 :opt
关键字参数(映射到键名称的向量)来生成映射的规范。
:req
键支持逻辑运算符 and
和 or
。
(spec/keys :req [::x ::y (or ::secret (and ::user ::pwd))] :opt [::z])
spec 与其他系统之间最明显的差异之一是,在该映射规范中没有指定值(例如 ::x
可以取什么值)的地方。spec 的(强制)观点是,与命名空间限定关键字(如 :my.ns/k
)关联的值的规范应该在该关键字本身下注册,并在出现该关键字的任何映射中应用。这样做有很多好处
它确保了应用程序中所有使用该关键字的地方的一致性,因为所有使用都应该共享一个语义
它同样确保了库与其使用者之间的一致性
它减少了冗余,因为否则许多映射规范将需要对 k 进行匹配声明
即使没有映射规范声明这些键,也可以检查命名空间限定关键字规范
最后一点在动态构建、组合或生成映射时至关重要。为每个映射子集/联合/交集创建规范是不可行的。它还有助于快速检测不良数据——在引入时检测而不是在使用时检测。
当然,许多现有的基于映射的接口使用非命名空间限定键。为了支持将它们连接到正确命名空间限定和可重用的规范,keys
支持 :req
和 :opt
的 -un
变体
(spec/keys :req-un [:my.ns/a :my.ns/b])
这指定了一个映射,该映射需要无限定键 :a
和 :b
,但分别使用名为 :my.ns/a
和 :my.ns/b
的规范(如果已定义)对其进行验证和生成。请注意,这无法像命名空间限定关键字那样向无限定关键字赋予相同的功能——生成的映射不是自描述的。
序列/向量的规范使用一组标准的正则表达式运算符,以及正则表达式的标准语义
cat
- 谓词/模式的串联
alt
- 一组谓词/模式中的一种选择
*
- 谓词/模式的零个或多个出现
+
- 一个或多个
?
- 一个或零个
&
- 获取正则表达式运算符并使用一个或多个谓词进一步约束它
这些可以任意嵌套以形成复杂的表达式。
请注意,cat
和 alt
要求其所有组件都具有标签,并且每个组件的返回值都是一个映射,其键对应于匹配的组件。通过这种方式,spec 正则表达式充当解构和解析工具。
user=> (require '[clojure.spec.alpha :as s])
(s/def ::even? (s/and integer? even?))
(s/def ::odd? (s/and integer? odd?))
(s/def ::a integer?)
(s/def ::b integer?)
(s/def ::c integer?)
(def s (s/cat :forty-two #{42}
:odds (s/+ ::odd?)
:m (s/keys :req-un [::a ::b ::c])
:oes (s/* (s/cat :o ::odd? :e ::even?))
:ex (s/alt :odd ::odd? :even ::even?)))
user=> (s/conform s [42 11 13 15 {:a 1 :b 2 :c 3} 1 2 3 42 43 44 11])
{:forty-two 42,
:odds [11 13 15],
:m {:a 1, :b 2, :c 3},
:oes [{:o 1, :e 2} {:o 3, :e 42} {:o 43, :e 44}],
:ex {:odd 11}}
定义规范的主要操作是 s/def、s/and、s/or、s/keys 和正则表达式运算符。有一个 spec
函数可以接收谓词函数或表达式、集合或正则表达式运算符,还可以接收一个可选的生成器,该生成器将覆盖谓词隐含的生成器。
但是请注意,def、and、or、keys
规范函数和正则表达式运算符都可以直接接收和使用谓词函数和集合——不需要将它们包装在 spec
中。只有当您想覆盖生成器或指定嵌套的正则表达式从新开始(而不是包含在同一模式中)时,才需要 spec
。
为了使规范能够通过名称重用,它必须通过 def
注册。def
接收一个命名空间限定关键字/符号和一个规范/谓词表达式。按照约定,数据的规范应在关键字下注册,属性值应在其属性名称关键字下注册。注册后,可以在任何规范/谓词在任何spec操作中被调用的地方使用该名称。
函数可以通过三个规范完全指定——一个用于参数,一个用于返回值,一个用于将参数与返回值关联的函数操作。
函数的参数规范始终是一个正则表达式,它将参数指定为列表,即传递给函数的 apply
的列表。通过这种方式,单个规范可以处理具有多个参数个数的函数。
返回值规范是单个值的任意规范。
(可选的)函数规范是参数和返回值之间关系的进一步规范,即函数的功能。它将在(例如在测试期间)传递一个包含 {:args conformed-args :ret conformed-ret}
的映射,并且通常包含关联这些值的谓词——例如,它可以确保输入映射的所有键都存在于返回的映射中。
您可以通过一次调用 fdef
来完全指定函数的所有三个规范,并通过 fn-specs
调用这些规范。
您可以使用 instrument
选择性地检测函数和命名空间,它用包装后的函数版本替换函数变量,该版本测试 :args
规范。unstrument
将函数恢复为其原始版本。您可以使用 gen/sample
生成交互式测试的数据。
spec API 的许多部分都要求使用“谓词”或“preds”。这些参数可以由以下内容满足:
谓词(布尔)函数
集合
规范的已注册名称
规范(spec
、and
、or
、keys
的返回值)
正则表达式运算符(cat
、alt
、*
、+
、?
、&
的返回值)
请注意,如果要在正则表达式中嵌套独立的正则表达式谓词,则必须将其包装在对 spec
的调用中,否则它将被视为嵌套模式。
conform
是使用规范的基本操作,它同时执行验证和一致性/解构。请注意,一致性是“深层”的,并且贯穿所有规范和正则表达式操作、映射规范等。由于 nil
和 false
是合法的一致值,因此当值无法变得一致时,conform 会返回 :clojure.spec.alpha/invalid
。可以改用 valid
? 作为完全布尔谓词。