什么是Clojure Spec以及您可以使用它做什么

2020-09-11 22:40:38

Pixelated Noise是一家软件咨询公司,我们一直在寻找有趣的项目来帮助我们。如果您有未满足的软件开发需求,我们将很高兴收到您的消息。

这是我在2017-12-13年度AthensClojure Meetup上的一次演讲的博客版本,会议由Skroutz亲切主持。演讲的录像带是可用的(演讲是用希腊语进行的,但有英文字幕)。这篇博客文章并不是演讲的确切文本,我在适当的地方添加了链接和更多信息(加上不在聊天记录中的奖金部分)。因为演讲已经进行了一段时间了,有些信息将会过时。

本文基本上分为两部分:一部分介绍了SPEC的基本概念和机制,也提供了一些信息,以便非专业人士可以了解SPEC如何适应更广泛的情况;还有一部分探索了一些超越基本用法的更有趣的用法,您可以如何利用它?这部分介绍了SPEC的基本概念和机制,并提供了一些信息,以便非专业人士了解SPEC是如何适应更广泛的情况的;还有一部分探讨了一些超越基本用法之外的更有趣的用法,这部分介绍了SPEC的基本概念和机制,并提供了一些信息,以便非专业人士了解如何使用SPEC。

Clojure是一种动态语言,它不强制执行函数的参数类型或返回值。这是这种语言的一个特点,在内部和其他语言社区都招致了批评,过去可能是阻碍采用的一个因素。

在某种程度上,规范是对此的响应,但它可能是社区没有预料到的响应,因为它没有采用传统的静态检查类型的方法。在最基本的层面上,规范是一种声明性语言,它描述数据、它们的类型、它们的形状。Spec遵循Clojure的一般原理,即它的所有功能在运行时都是可用的,您可以使用它、反思它、生成它-当编译器检查整个代码库是否有错误时,在执行之前没有额外的步骤。

规范仍然是alpha,所以Required中的名称空间包含.alpha来表示这一点。以下规范定义了用户名实体,并说明它必须是字符串:

绳子?是存在于Clojure core中的一个简单函数,它是您向其传递字符串并返回TRUE或FALSE的预测函数,具体取决于传递的值是否为字符串。

一旦您定义了一个规范,它最简单的用法就是通过调用Valid?并传递thpec的名称和一个值来询问某些东西是否有效:

许多情况都包含在内置谓词中,但这并不意味着我们不能使用自己的谓词。如果我们需要一个规范来检查数字是否大于5,我们可以简单地编写一个类似下面的匿名函数,然后正常使用它,就好像它本身就是一个规范一样:

因此,我们编写了一个规范,它可以验证我们的数据。让我们把这个画成一个图:左边看起来像蓝图的东西是一个规范,右边的大括号代表的是Clojure数据(因为Clojure中的数据通常是地图,而地图是用大括号写的)。将中间奇怪的箭头理解为";验证";:

这不是一个很好的图表,但我正在努力为本文的其余部分建立视觉语言。

规格也可以通过将更多的基本规格组合并嵌套在一起来应用于收藏。在这里,我们定义了一个名为username的实体,该实体由一组username组成:

对于如此简单的东西,您通常不会将其定义为单独的实体,因为s/coll-of可以在您的程序中临时使用。

地图更有趣一些。其他技术,如羽毛模式(这在某种程度上是验证Clojure中数据的事实方式),要求您定义映射中必须出现的键以及与键对应的值的数据类型。结果定义看起来有点像您通常在面向对象语言中看到的严格定义的类。Spec非常刻意地摆脱了这种心态:贴图不像对象,它们不是固定的,也不一定存在于这种形状中。相反,地图只是碰巧是一些命名值的聚合。

一种使用s/key编写的映射规范,它没有定义映射值的类型,我们只定义组成映射的现有实体。

映射内的键的名称必须与在其他地方定义的规范的名称相同。

在本例中,我们定义了一些单值规范,如用户名、密码、上次登录和评论,它们被聚合到由::user规范定义的映射中。

(NS my-project.Users(:Required[clojure.spec.alpha:as s]))(s/def::用户名字符串?)(s/def::密码字符串?)(s/def::上次登录编号?)(s/def::评论字符串?)(s/def::user(s/key:req[::username::password]:opt[::Comment::last-login])(println::username)(。Println(s/valid?::user{::username";Rich";:Password";zegure";::Comment";这是用户";:上次登录11000})。

SPEC还鼓励使用限定关键字:直到最近在Clojure中,人们还会使用带有单个冒号的关键字,但是两个冒号(::)表示关键字属于这个名称空间,在本例中是my-project.users。这是另一个经过深思熟虑的选择,它是关于创建属于特定名称空间的强名称(或完全限定名称),以便我们可以在同一地图中混合名称空间。这意味着我们可以拥有一个来自我们系统外部并具有自己的名称空间的映射,然后我们可以向该映射添加更多属于我们公司名称空间的键,而不必担心名称冲突。这也有助于数据来源,因为您知道:subsystem-a/id字段不只是一个ID-它是由SUBSYSTEM-a分配的ID。

地图规格的另一个有趣之处在于它们是开放的。例如,如果我们使用与以前完全相同的映射,具有相同的字段和名为::age的附加字段,则它仍然是有效的::user:

(NS my-project.Users(:Require[clojure.spec.alpha:as s]))(s/def::用户名字符串?)(s/def::密码字符串?)(s/def::上次登录号?)(s/def::评论字符串?)(s/def::user(s/key:req[::username::password]:opt[::Comment::last-login])(println(s/Valid?::用户{::用户名";Rich";::Password";zegure";::Comment";这是用户";:上次登录11000::26})。

之所以会发生这种情况,是因为spec不介意您是否已经定义了四个键,如果它看到第五个键,映射就不会变得无效。这样做的原因是,当我们有一个积累信息的系统时,这种积累应该不会破坏系统,使用地图的代码应该简单地忽略它不知道的键。如果您正在创建一个系统,并且您正在积累额外的选项、参数,不管它是什么-您的代码应该能够继续运行,而无需更改大量代码位置,就像您在面向对象语言或Haskell中必须做的那样。

这种积累也被术语“吸积”所描述,并在Rich Hickey的“出色的规范主题演讲”中进行了讨论。

另一方面,许多使用SPEC来验证来自他们系统之外的东西的人需要对地图更加严格,他们已经对地图的开放性做出了解释。我们稍后将讨论针对这个问题提出的解决方案。

除了验证之外,规范的另一个用法是解释,它本质上会产生错误,告诉您您的数据出了什么问题。在这种情况下,我们将尝试通过创建一个无效的用户来创建一个错误,因为该用户没有密码-一个必需的密钥:

(NS my-project.Users(:Required[clojure.spec.alpha:as s]))(s/def::用户名字符串?)(s/def::密码字符串?)(s/def::上次登录号?)(s/def::评论字符串?)(s/def::user(s/key:req[::username::password]:opt[::Comment::Last-login])(s/EXPLAIN::USER{::Rich";::Comment";这是用户";})。

我们得到一个OK-ish错误,告诉我们对于我们传递的特定地图,::user规范失败,因为它不包含::password。

Val:#:my-project.users{:username";rich";,:Comment";this is a user";}规范::my-project.user/user谓词:(包含?%:my-project.user/password)。

SPEC中一个强大的机制是序列。我们已经看到s/coll-其中包含统一类型的值(例如,一组数字),但是序列更像是数据的正则表达式。在本例中,我们有一个包含两个内容的序列,这两个内容描述食谱的配料:第一个内容是数量的数字,第二个内容是编码为关键字的单位。

对于s/cat,我们总是必须为每个位置指定一个名称。S/cat既允许验证传递的值的形状,也允许执行符合操作,这在某种程度上类似于解析或析构。如果我们传递一个包含两个元素(数字和关键字)的向量,我们将得到一个具有定义名称的映射:

通过使用其他一些让人联想到正则表达式的运算符,这种技术可以变得相当强大。例如,我们尝试创建一个非常简单的语法,它可以解析Clojure语法中非常有限的子集。具体地说,我们将尝试解析用于定义函数的Defn。S表达式包括作为符号的defn,然后是定义函数名称的符号,然后是optionaldocstring(字符串)、参数向量,最后是functionbody。

(要求';[clojure.spec.alpha:as s]';[clojure.pprint:as pp])(s/def::function(s/cat:defn#{';defn}:名称符号?:单据(s/?String?):args Vector?:Body(s/+list?))

我们的规范看起来是这样的:有一个Defn部分,它始终是符号Defn。我们使用集合作为谓词,所以如果一个值包含在集合中,它就会通过。然后我们有:名字,这是一个符号。接下来,我们使用s/?表示文档字符串,这意味着在该位置可以有零个或一个字符串。之后我们有一个参数,它是一个向量(为了简单起见,带有未定义的内容),最后是一个保存函数sbody的列表。

我们可以在一些看起来像有效Clojurefunction的数据上尝试我们的规范(请记住,我们是在Lisp中,所以代码就是数据就是代码!):

(def';(defn";这是测试函数";[x y](+x y)(pp/pprint(s/conform::函数函数-code1))。

{:Defn Defn,:name my-function,:doc";这是测试函数";,:args[x y],:body[(+x y)]}。

正确地识别了不同的部件。现在,让我们尝试遵循相同的函数,但不包含文档字符串:

这为DSL、验证宏等打开了很多可能性,而且它通常是一种非常强大的技术。这种在序列中间处理选项值的代码很难用函数的方式编写,所以Conform很有帮助。

规范是一个说明性的框架,由上边的s/key、s/cat、s/+、s/*等组成,最下面是谓词。由于这是对数据形状的声明性描述,因此我们不定义如何使用此信息。我们已经看到了验证,但是规范有足够的关于数据形状的编码知识,能够构造符合所描述的形状的数据的新实例。

因此,给定一个规格,我们可以用它制造一个发电机,然后对该发电机进行采样:

(NS My-project.Users(:Required[clojure.spec.alpha:as s][clojure.spec.gen.alpha:as gen][net.cgra.pack-prier:as ppp])(s/def::username string?)(s/def::password string?)(s/def::last-login number?)(s/def::Comment String?)(s/def::user(s/key:req[::username::密码]:opt[::Comment::Last-login]))(ppp/pprint(gen/sample(s/gen::user)5)。

({:My-project.Users/Username";";,:My-project.Users/Password";,:My-project.Users/Comment";";,:My-project.Users/Last-login 0}{:My-project.Users/Username";L";,:My-project.Users/Password";G";,:my-project.users/last-login 3.0,:my-project.users/ment";a";}{:my-project.users/username";q";,:my-project.user/password";";,:my-project.users/ment";QO";,:my-project.users/last-login 0}{:my-project.user/username";";,:my-project.users/password";";,:my-project.users/last-login 0}{:my-project.users/username";m6";,:my-project.users/password";nyX0";})。

如果您多次运行此代码,每次都会得到不同的用户映射。

拥有这样的生成器在许多情况下都很有用。一个非常简单的用例是用您喜欢的任何卷的有效数据填充数据库,并使用它进行性能测试。

我在实践中遇到的另一个用例是,在某些情况下,您想要编写单元测试,但您不想要手动编写整个Fixture。我们有一个描述大型配置结构的规范,我们使用它来生成整个配置的一个示例,然后在将其用作单元测试中的固定物之前覆盖配置的特定部分。

在某些情况下,有必要提供规范并用您自己的自定义生成器覆盖一些默认生成器,这是Gary Fredericks在这次强烈推荐的演讲中介绍的一种技术。

规范的大赢家当然是使用属性测试(也称为生成性测试)来验证函数。属性测试有点像从单元测试毕业,在单元测试中,所有的输入和预期输出都是手写的,并且认识到,在单元测试中,我们经常停留在函数方面,而只对简单的情况进行测试。属性测试迫使我们偏离快乐的道路,要求更广泛的思考,并使我们不得不更多地考虑我们的代码必须具备的属性才能正确。

为了用SPEC测试函数,您必须为函数的三个不同方面制定三个不同的规范。

第一个是:args规范,它是一种S/CAT,描述了函数的参数。这可以包括描述参数之间关系的规范。例如,参数1和参数2可能必须是连续的数字,或者如果一个参数存在,则另一个参数也必须存在,因此两者都存在或都不存在(对于必须共存的可选参数)。

然后创建一个规范来验证函数的结果值,称为:RET规范。最后还有:fn规范,它是关于参数和函数结果之间的关系的,如果存在这样的关系的话。:args、:Ret和:fn都是可选的-您不必全部定义这三个。

您可以打开函数规范(称为检测,我们说我们检测我们的函数),然后运行您的单元测试,看看是否以这种方式捕捉到任何错误。何时检测哪些函数由您决定,如果您负担得起性能方面的费用,您可以在实际的生产系统中检测您的函数,并获得不一致的报告。

属性测试的说明与规范功能的说明类似,但内容更多:

主要区别在于,您使用:args来生成多个输入示例,这些示例将传递给您的函数以生成多个输出,并且每个输出都会根据:RET规范进行验证,而且每对输入和输出(它们之间的关系)都会根据:fnspec进行验证。

让我们来看一个使用规格进行属性测试的例子。我们将创建自己的排序函数来对数字进行排序,但由于这只是一个说明性的示例,我们将仅使用Clojure的核心中的Sort来实现它:

(要求';[clojure.spec.alpha:as s]';[clojure.spec.test.alpha:as sTest]';[clojure.pprint:as pp])(Defn[coll](Sorte Coll))(s/fdef num-sorte:args(s/cat:coll(s/coll-of number?)):RET(s/coll-of number?):fn(s/和#(。:args:coll排序))#(=(->;:ret count)(->;:args:coll count)(pp/pprint(sTest/check`num-sorte))。

因此:args是一个只包含一个东西的s/cat,名为:coll,它被定义为一个数字集合,而:RET规范也是一个数字集合。:fn规范由两个谓词组成:第一个谓词表示,如果使用核心排序对参数进行排序,则返回值应该与参数相同(我们在这里作弊,但只需假装我们正在测试新的实现)。第二个属性表示返回值的长度应该与参数的长度相同-您不能对某些内容进行排序而丢失数字或获得数字。

因此,如果您运行包含test/check的最后一个表达式,它将多次运行num-sorting,其中包含具有不同长度、空列表、nil值的随机数字集合,涵盖各种边缘情况,并且它将告诉我们函数看起来是否正常:

确实,它看起来没问题::结果是真的,它运行了1000次,一切看起来都没问题。我们已经得到了一些东西,因为我们不会编写1000个单元测试。

因此,在上一节中,我们看到了快乐之路的属性测试是什么样子的,让我们来看看当事情发生时它是什么样子的。我们将更改num-sorte以执行它以前所做的一般排序,但是如果集合包含数字3,它将生成一个长度相等的新集合,但所有元素都将是888,因此它最有可能是错误的结果。规格与之前相同:

(需要[clojure.spec.alpha:as s]';[clojure.spec.test.alpha:as sTest]';(s/fdef num-sorte:args(s/cat:coll(s/coll-of number?):ret(s/coll-of number?):fn(s/coll-of number?):fn(s/and#(=(->;:ret)(=(->;:ret))(。:args:coll排序))#(=(->;:RET计数)(->;:args:coll count))。

因此,我们使用相同的规范进行测试/检查,但是我稍微修改了一下结果,使其更具可读性(我后来发现了sTest/abbrev-result函数,我本可以使用该函数来缩短结果并使其更具可读性):

(->;(sTest/check`num-ort)First:clojure.spec.test.check/ret(select-keys[:num-test:FAIL:Shrunk])(update-in[:shrunk:result-data:clojure.test.check.properties/error]#(->;ex-data(dissoc:clojure.spec.alpha/spec)(ppp/pprint:width 60)。

{:Num-test6,:FAIL[([-1 1.0625-1 3-3-0.5])],:Shrunk{:Total-Nodes-Visted 10,:Depth 3,:Result False,:Result-Data{:clojure.test.check.properties/Error{:clojure.spec.alpha/Problems[{:path[:fn],:pred(clojure.core/fn[%])(clojure.core/=(clojure.core/->。%:args:coll clojure.core/ort)),:val{:args{:coll[3]},:ret(888)},:via[],:in[]},:clojure.spec.alpha/value{:args{:coll[3]},:ret(888)},:clojure.spec.test.alpha/args([3]),:clojure.spec.test.alpha/val{。:clojure.spec.alpha/失败:检查失败}},:最小[([3])]}}。

这说明sTest/check运行了6个测试,它足以找到一个引起错误的输入示例,也就是说,当传递给函数时,某个定义的属性不满足的输入示例就足够了,这说明sTest/check运行了6个测试,并且这足以让它找到一个引起错误的输入的示例,也就是说,当传递给函数时,其中一个定义的属性不被满足。这里有一点:

…。意味着引起错误的最小输入是单元素集合[3],它产生的结果是一个只包含888个元素的列表。因此,它不仅发现我们有问题,而且还将矛头对准了我们在实现中植入的bug。

这个检测引起bug的尽可能小的输入的过程被称为收缩,它涉及到第一个Buggy输入的例子,并试图叙述。

.