Clojure

clojure.spec - 原理和概述

问题

文档不足

Clojure 是一种动态语言。其中一个含义是,代码运行不需要类型注解。虽然 Clojure 支持一些类型提示,但它们既不是强制机制,也不全面,并且仅限于向编译器传递信息以帮助生成高效代码。Clojure 通过 JVM 本身获得更丰富的类型集的运行时检查。

然而,始终是 Clojure 的指导原则,并得到社区的广泛认可和实践,即简单地将信息表示为数据。因此,Clojure 系统的重要属性由数据的形状和其他预测性属性表示和传达,而由于运行时类型是无法区分的异构 map 和向量,因此在任何地方都没有捕获或检查这些属性。

文档字符串可用于与人类使用者进行沟通,但程序或测试无法利用它们,即它们的功能很有限。用户转向各种库,例如 SchemaHerbert,以获得更强大的规范。

Map 规范应仅针对键集

大多数用于指定结构的系统将键集的规范(例如 map 中的键、对象中的字段)与由这些键指定的值的规范混淆。即,在这种方法中,map 的模式可能会说:a-key 的类型是 x-type,而:b-key 的类型是 y-type。这是导致僵化和冗余的主要来源。

在 Clojure 中,我们通过动态组合、合并和构建 map 来获得强大的功能。我们经常处理可选和部分数据、由不可靠的外部来源生成的数据、动态查询等。这些 map 表示同一键的不同集合、子集、交集和并集,并且通常应该对在任何位置使用的同一键具有相同的语义。定义每个子集/并集/交集的规范,然后冗余地陈述每个键的语义,这既是一种反模式,在最动态的情况下也无法实现。

手动解析和错误报告不够完善

许多用户,尤其是初学者,对手工编写的解析和解构代码生成的错误消息感到沮丧和困惑,尤其是在宏中,宏存在两个执行上下文(宏在编译时运行,其扩展在运行时运行,两者都可能因用户错误而失败)。这导致了对“宏语法”的需求,但实际上宏只是数据→数据的函数,任何用于数据验证和解构的解决方案都应该对它们以及对任何其他函数一样有效。即,宏是上述问题的一个实例。

生成式测试和健壮性

最后,在所有语言中,无论动态与否,测试对于质量都至关重要。许多关键属性没有被常见的类型系统捕获。但是手动测试的有效性/工作量比率非常低。基于属性的生成式测试(如 test.check 中为 Clojure 实现的那样)已被证明比手动编写的测试强大得多。

然而,基于属性的测试需要定义属性,这需要额外的努力和专业知识才能生成,并且在函数级别上,与函数规范有很大的重叠。许多有趣的函数级属性可以通过结构+预测性规范来捕获。理想情况下,规范应该与生成式测试集成,并“免费”提供某些类别的生成式测试。

需要一种标准方法

简而言之,Clojure 没有用于规范和测试的标准、表达性、强大且集成的系统。

clojure.spec 旨在提供它。

目标

沟通

Species - 外观、形式、种类、类型,相当于 spec(ere)观察、看待
               + -iēs 抽象名词后缀

Specify - species + -ficus -fic (制作)

规范关乎事物“外观”,但最重要的是,它本身是被观察的对象。规范应该易于阅读,由程序员已经使用的“词语”(谓词函数)组成,并集成在文档中。

统一不同上下文中的规范

数据结构、属性值和函数的规范都应该相同,并位于全局命名空间目录中。

最大化规范工作成果的利用

编写规范应启用自动

  • 验证

  • 错误报告

  • 解构

  • 检测

  • 测试数据生成

  • 生成式测试生成

最小化侵入性

不要要求人们例如以不同的方式定义他们的函数。对 docmacroexpand 的少量修改将允许独立编写的规范修饰 fn/macro 行为,而无需重新定义。

解耦 map/键/值

将 map(键集)规范与属性(键→值)规范分开。鼓励并支持命名空间关键字到值规范的属性粒度规范。将键组合成集合(以指定 map)变得正交,并且即使在没有 map 规范存在的情况下,也可以进行检查,即即使在没有 map 规范存在的情况下,也可以检查属性(键值)。

启动并推动关于语义变更和兼容性的讨论

当程序员重新定义事物并保持名称不变时,他们会遭受巨大的痛苦。然而,有些更改是兼容的,而有些是破坏性的,大多数工具无法区分。使用诸如集合成员关系和正则表达式之类的结构,可以确定兼容性,并提供兼容性检查工具(同时将通用谓词相等性排除在范围之外)。

指南

错误在所难免

我们并不(也无法)生活在一个不会犯错的世界中。相反,我们会定期检查我们是否犯了错误。亚马逊不会通过 UPS<Trucks<Boxes<TV>>> 向您发送电视。因此,您偶尔可能会收到微波炉,但供应链不会因正确性证明而负担过重。相反,我们在边缘进行检查并运行测试。

表达性 > 证明

没有理由将我们的规范限制在我们能够证明的内容上,但这主要是类型系统所做的。我们希望就我们的系统传达和验证更多内容。这超出了结构/表示类型和标记,扩展到例如缩小域或详细说明输入之间或输入与输出之间关系的谓词。此外,我们最关心的属性通常是运行时值,而不是某些静态概念。因此,spec 不是类型系统。

名称很重要

所有程序都使用名称,即使类型系统没有使用,它们也捕获了重要的语义。Int x Int x Int 就足够了(是长度/宽度/高度还是高度/宽度/深度?)。因此,spec 不会具有未标记的序列组件或未标记的联合绑定。当spec需要与用户讨论规范时,例如在错误报告中,以及反之亦然,例如当用户希望覆盖规范中的生成器时,这一点的实用性就变得很明显。当所有分支都被命名时,您可以使用路径来讨论规范的部分内容。

全局(命名空间)名称更重要

Clojure 支持命名空间关键字和符号。请注意,这里我们只是在谈论命名空间限定的名称,而不是 Clojure 命名空间对象。这些名称被悲惨地低估了,并带来了重要的优势,因为它们始终可以在字典/数据库/map/集合中无冲突地共存。spec 将允许(仅)命名空间限定的关键字和符号来命名规范。使用命名空间键进行信息 map 的人(我们希望看到这种做法发展壮大)可以将这些属性的规范直接注册在这些名称下。这从根本上改变了 map 的自描述,尤其是在动态上下文中,并鼓励组合和一致性。

不要进一步添加/重载 Clojure 的(具体化)命名空间

不会附加到 var、元数据等。所有函数都具有命名空间名称,这些名称可以用作其相关数据(例如 spec)的键,这些数据存储在其他位置。

代码即数据(而非反之)

在 Lisp(以及 Clojure)中,代码即数据。但数据在您为其定义语言之前不是代码。该领域中的许多 DSL 都是针对模式的数据表示。但预测性规范具有开放且庞大的词汇表,并且大多数有用的谓词已经存在并且作为核心和其他命名空间中的函数而广为人知,或者可以编写为简单的表达式。必须“数据化”(可能重命名)所有这些谓词几乎没有价值,并且理解精确语义的成本很高。spec 而是利用原始谓词和表达式本身就是数据的事实,并捕获该数据以用于在文档和错误报告中与用户进行沟通。是的,这意味着 clojure.spec 的更多表面积将是宏,但规范绝大多数是由人们编写的,并且在组合时也是手动编写的。

集合(map)关乎成员关系,仅此而已

如上所述,定义键值细节的映射是一种基本的关注点混合,不会被支持。映射规范详细说明了必需/可选键(即集合成员资格方面),而关键字/属性/值语义是独立的。映射检查分为两个阶段,首先检查必需键的存在,然后检查键/值的一致性。即使在运行时存在的(命名空间限定的)键不在映射规范中,也可以执行后一个阶段。这对组合和动态性至关重要。

信息型与实现型

不可避免地,人们会尝试使用规范系统来详细说明实现决策,但这样做会损害自身利益。最好和最有用的规范(以及接口)都与纯粹的信息方面相关。只有信息规范才能在网络上和跨系统工作。我们始终会优先考虑信息方法,并在发生冲突时优先选择它。

保持简单

在这个领域,底层概念很少,我们会努力坚持这些概念。只有少数不同的结构概念——少量原子类型、序列、集合和映射。不出所料,这些是Clojure数据类型,并且只有针对这些类型提供基本操作。同样,也有一些数学工具可以用来讨论这些概念——集合逻辑用于映射,正则表达式用于序列——这些工具具有有价值的属性。我们更倾向于使用这些工具而不是临时解决方案。

基于test.check构建,但不要求了解它

spec 的生成式测试基础将利用 test.check 并且不会重新发明轮子。但是spec用户不需要了解 test.check,除非他们想编写自己的生成器或用他们自己的进一步基于属性的测试来补充spec生成的测试。不应该存在对 test.check 的生产运行时依赖关系。

特性

概述

谓词规范

基本思想是,规范只不过是谓词的逻辑组合。在底层,我们讨论的是您习惯使用的简单布尔谓词,例如 int?symbol?,或者您自己构建的表达式,例如 #(< 42 % 66)spec 添加了逻辑运算符,如 spec/andspec/or,它们以逻辑方式组合规范,并提供深度报告、生成和一致性支持,并且在 spec/or 的情况下,提供带标签的返回值。

映射

映射键集的规范提供了对必需和可选键集的规范。通过使用 keys 并传递 :req:opt 关键字参数(映射到键名称的向量)来生成映射的规范。

:req 键支持逻辑运算符 andor

(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 - 一组谓词/模式中的一种选择

  • * - 谓词/模式的零个或多个出现

  • + - 一个或多个

  • ? - 一个或零个

  • & - 获取正则表达式运算符并使用一个或多个谓词进一步约束它

这些可以任意嵌套以形成复杂的表达式。

请注意,catalt 要求其所有组件都具有标签,并且每个组件的返回值都是一个映射,其键对应于匹配的组件。通过这种方式,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}}

conform/explain

如上所示,使用规范的基本操作是 conform,它接收一个规范和一个值,并返回一致的值或 :clojure.spec.alpha/invalid(如果值不一致)。当值不一致时,您可以调用 explainexplain-data 来找出原因。

定义规范

定义规范的主要操作是 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 调用这些规范。

使用规范

文档

通过 fdef 定义的函数规范将在您对函数名称调用 doc 时出现。您可以对规范调用 describe 以获取表单形式的描述。

解析/解构

您可以直接在您的实现中使用 conform 来获得其解构/解析/错误检查功能。例如,可以在宏实现和 I/O 边界使用 conform

开发期间

您可以使用 instrument 选择性地检测函数和命名空间,它用包装后的函数版本替换函数变量,该版本测试 :args 规范。unstrument 将函数恢复为其原始版本。您可以使用 gen/sample 生成交互式测试的数据。

用于测试

您可以使用 check 对整个命名空间运行一组规范生成式测试。您可以通过调用 gen 获取规范的与 test.check 兼容的生成器。许多 clojure.core 数据谓词与其对应的生成器之间存在内置关联,并且spec 的复合运算符知道如何在这些生成器之上构建生成器。如果您对规范调用 gen 并且它无法为某些子树构建生成器,它将抛出一个描述位置的异常。您可以将返回生成器的函数传递给 spec 以便为 spec 不了解的事物提供生成器,并且您可以将覆盖映射传递给 gen 以便为规范的一个或多个子路径提供备用生成器。

运行时

除了上述解构用例之外,您还可以随时调用 conformvalid? 进行运行时检查,并且可以为打算在生产环境中运行的测试创建更轻量级的内部规范。

有关更多示例和使用信息,请参阅spec 指南API 文档

术语表

谓词

spec API 的许多部分都要求使用“谓词”或“preds”。这些参数可以由以下内容满足:

  • 谓词(布尔)函数

  • 集合

  • 规范的已注册名称

  • 规范(specandorkeys 的返回值)

  • 正则表达式运算符(catalt*+?& 的返回值)

请注意,如果要在正则表达式中嵌套独立的正则表达式谓词,则必须将其包装在对 spec 的调用中,否则它将被视为嵌套模式。

规范

specandorkeys 的返回值。

正则表达式运算符

catalt*+?& 的返回值。嵌套时,这些形成单个表达式。

conform

conform 是使用规范的基本操作,它同时执行验证和一致性/解构。请注意,一致性是“深层”的,并且贯穿所有规范和正则表达式操作、映射规范等。由于 nilfalse 是合法的一致值,因此当值无法变得一致时,conform 会返回 :clojure.spec.alpha/invalid。可以改用 valid? 作为完全布尔谓词。

explain

当一个值不符合规范时,你可以使用相同的规范和值调用explainexplain-data来找出原因。这些解释在conform期间不会生成,因为它们可能会执行额外的操作,并且对于非失败的输入或不需要报告时,没有理由承担此成本。解释的一个重要组成部分是路径explain在遍历例如嵌套映射或正则表达式模式时扩展路径,因此你获得的信息不仅仅是整个值或叶子值。explain-data将返回一个路径到问题的映射。

路径

由于规范中的所有分支点都已标记,例如映射keysoralt中的选择,以及cat的(可能省略的)元素,因此规范中的每个子表达式都可以通过一个路径(键向量)来引用,该路径命名了各个部分。这些路径用于explaingen覆盖和各种错误报告。

先前技术

规范中几乎没有任何新颖之处。请参阅上面提到的所有库,RDF,以及在各种契约系统上完成的所有工作,例如Racket的契约

我希望你发现spec实用且强大。

Rich Hickey