从历史上看,我已经努力找到一个简洁的,简单的方法来解释练习类型驱动的设计意味着什么。太多,当有人问我“你是怎么想起这种方法的?”我发现我不能给他们一个令人满意的答案。我知道它不仅仅是在愿景中来找我 - 我有一个迭代的设计过程,不需要采取薄薄的空气中的“正确”的方法,但我并没有成功地向他人沟通该过程。
然而,大约一个月前,我在静态和动态类型的语言中经历了解析json的差异,我反映了Twitter,最后,我意识到了我正在寻找的东西。现在我有一个单身,Snappy口号,封装了什么类型的驱动设计对我来说意味着什么,更好,但它只有三个字长:
好吧,我会承认:除非你已经知道什么类型的驱动设计,否则我的吸引人的口号可能对你来说并不意味着这一切。幸运的是,这就是这个博客帖子的其余部分。我将正是在血腥细节中的意思解释 - 但首先,我们需要练习一下一般的一厢情愿。
关于静态系统的一个美妙的事情是,他们可以使他们能够实现,有时甚至容易,以回答“它可以写这个功能是可能的?”对于一个极端的例子,请考虑以下Haskell类型签名:
是否有可能实现foo?溯源,答案是否,因为void是一个不包含值的类型,所以任何功能都不可能产生类型void的值。 1示例非常无聊,但如果我们选择更现实的例子,问题会更有趣:
此函数从列表中返回第一个元素。是否有可能实施?它肯定没有听起来它做了什么非常复杂的,但如果我们试图实现它,编译器将不会满足:
警告:[-wincomplete-patterns]模式匹配在“头”的等式中是非详尽无遗的:模式不匹配:[]
此消息有助于指出,我们的功能是部分的,这就是说它没有为所有可能的输入定义。具体地,当输入是[],空列表时未定义。这是有道理的,因为如果列表是空的,则无法返回列表的第一个元素 - 没有元素要返回!因此,显着的,我们学习该功能也无法实现。
对于来自动态键入的背景的人来说,这似乎令人困惑。如果我们有一个列表,我们可能非常希望得到它的第一个元素。事实上,Haskell的“获取名单的第一个元素”的操作并非不可能,它只是需要一点额外的仪式。有两种不同的方法来修复头部功能,我们将从最简单的方式开始。
正如所建立的那样,如果列表为空,则头部是部分的,因为如果列表是空的,则没有元素:我们已经提出了一个我们不可能实现的承诺。幸运的是,对该困境有一种简单的解决方案:我们可以削弱我们的承诺。由于我们不能保证来电者列表的一个元素,我们必须练习一点期望管理:如果我们可以,我们会尽力回归一个元素,但我们保留了任何返回的权利。在Haskell,我们使用可能类型的可能性表示:
这为我们提出了我们需要实现头部的自由 - 它允许我们在发现我们无法生成毕竟没有产生类型的值时返回任何内容:
问题解决了,对吧?暂时,是的...但是这个解决方案有隐藏的成本。
当我们实施头时,返回可能是无疑的方便。但是,当我们想要实际使用它时,它变得明显不太方便!由于头部总是有可能返回任何东西,因此负担落在呼叫者身上,以处理这种可能性,有时,降压的传递可能会令人难以置信的令人沮丧。要查看为什么,请考虑以下代码:
getconfigurationDirectories :: io [filepath] getconfigurationdirectories = do configdirsstring< - getenv" config_dirs"让configdirslist = split',' configdirsstring何时(null configdirslist)$ throwe $ usererror" config_dirs不能为空" pure configdirslist main :: io()main = do configdirs< - getconfigurationDirectories案例Head CommendIns的只是Cachedir - > InitializeCache Cachedir什么都没有 - >错误"永远不会发生;已经检查的configdirs是非空的"
当getConfigurationDirectories从环境中检索文件路径列表时,它主动检查列表是非空的。但是,当我们用头部使用头部获取列表的第一个元素时,可能仍然需要我们处理我们所知道永远不会发生的事情的事情!这有几个原因非常糟糕:
首先,这只是烦人。我们已经检查了该列表是非空的,为什么我们必须将代码与另一个冗余支票混乱?
其次,它具有潜在的性能成本。虽然在该特定示例中冗余检查的成本是微不足道的,但是可以想象一个更复杂的场景,其中冗余检查可以加起来,例如它们在紧密的循环中发生。
最后,最糟糕的是,这段代码是等待发生的错误!如果修改了GetConfigurationDirectories以停止检查列表是否为空,故意或无意中,该怎么办?程序员可能不记得更新主要,突然间“不可能”的错误变得不仅可能,而且可能。
对此冗余检查的需求基本上迫使我们在我们的类型系统中打孔。如果我们可以静态证明无所事事,那么修改到GetConfigurationDirectories,停止检查列表是否为空,无效,验证并触发编译时失败。但是,按照写作,我们被迫依靠测试套件或手动检查来捕获错误。
显然,我们的修改版本的头部留下了一些需要的东西。不知何故,我们希望它更智能:如果我们已经检查了该列表是非空的,则应无条件地返回第一个元素而不强迫我们处理我们所知道的情况是不可能的。我们怎样才能这样做?
上一节说明了,通过削弱返回类型所做的承诺,我们可以将该部分类型签名转换为总体。但是,由于我们不想这样做,只剩下一件事可以更改:参数类型(在这种情况下,[a])。我们可以加强参数类型,而不是削弱返回类型,而是消除首先在空名单上调用头部的可能性。
为此,我们需要一种表示非空列表的类型。幸运的是,来自data.list.nonuspty的现有的非空类型正好。它具有以下定义:
请注意,非空A实际上只是一个普通的,可能是空的[a]的元组。这通过将列表的第一个元素与列表的尾部分开存储来方便地绘制非空列表:即使[A]组件是[],必须始终存在组件。这使得头部完全微不足道实现:2
与之前不同,GHC接受此定义而无需投诉 - 此定义是完全的,而不是部分。我们可以更新我们的程序以使用新的实现:
getconfigurationDirectories :: IO(nonempty filepath)getconfigurationDirectories = do configdirsstring< - getenv" config_dirs"让configdirslist = split',' configdirsstring case nonempty configdirslist只是nonemptyconfigdirslist - >纯粹的nonemptyconfigdirslist nother - > Throwio $ userError" config_dirs不能为空" main :: io()main = do configdirs< - getconfigurationDirectories initializecache(head configdirs)
请注意,主要的冗余支票现在完全消失了!相反,我们在GetConfigurationDirectories中完成一次检查一次。它使用来自data.list.nonusemy的非空函数来构造来自[a]的非空的A.NONEMPTY,其具有以下类型:
也许仍在那里,但这一次,我们在我们的程序中很早就处理了什么意思:在我们已经完成输入验证的同一个地方就在同一个地方。一旦检查已通过,我们现在有一个非空的文件符值,它保留(在类型系统中!)列表确实是非空的知识。换句话说,您可以考虑类型的非空闲A值,就像[a]类型的值,加上列表非空的证明。
通过加强头部参数的类型而不是削弱其结果的类型,我们完全从上一节中删除了所有问题:
代码没有冗余检查,因此不能有任何性能开销。
此外,如果GetConfigurationDirectories更改以停止检查列表是非空的,则其返回类型也必须更改。因此,主要将无法进行TypeCreck,在我们运行该计划之前提醒我们解决问题!
更重要的是,通过用nonempty撰写头部从新的行为恢复头脑的旧行为是微不足道的:
请注意,逆不正确:无法从旧版本获取新版本的头部。总而言之,所有轴上的第二种方法都是优越的。
您可能想知道上面的示例与此博客文章的标题有关。毕竟,我们只检查了两种不同的方法来验证列表是非空的 - 没有解析视线。这种解释没有错,但我想提出另一个视角:在我的脑海中,验证和解析之间的区别几乎完全是如何保留的信息。考虑以下功能:
ValidatenOnusempty :: [a] - > io()validatenonempty(_:_)= pure()validatenonempty [] = throwio $ userError"列表不能为空" Parsenonempty :: [a] - > io(nonempty a)parsenonempty(x:xs)= pure(x:| xs)parsenonempty [] = throwio $ usererror"列表不能为空"
这两个函数几乎相同:检查所提供的列表是否为空,如果是,则它们中止了具有错误消息的程序。差异完全在返回类型中:ValidAtenOnempty始终返回(),不包含信息的类型,但帕索纳纽瓦斯返回Nonempty A的输入类型,这些输入类型保留了类型系统中所获得的知识。这两个函数都检查了同样的事情,但帕索纳纽马克拨备了来电者访问它所学到的信息,而ValidAtenHempty刚刚抛弃它。
这两个功能优雅地说明了静态系统的作用的两种不同的视角:ValidatenOnempty obeys足够的TypeChecker,但只有Parsenempley充分利用它。如果你看到为什么ParsenAnempty是优选的,你明白Mantra“解析,不要验证的意思。尽管如此,你仍然是对帕索纳克的名字持怀疑态度。它真的解析了什么,还是它只是验证它的输入并返回结果?虽然对解析或验证某些东西的精确定义是值得简言的,但我认为Parsennempty是一个真正的解析器(尽管是一个特别简单的解析器)。
考虑:什么是解析器?实际上,解析器只是消耗较少结构输入的功能,并产生更多结构化输出。通过其本质,解析器是一个部分函数 - 域中的某些值与范围中的任何值都不对应 - 因此所有解析器必须具有一些故障概念。通常,对解析器的输入是文本,但这绝不是一个要求,帕索纳姆空质量是一个完美的哼唱者解析器:它将列表解析为非空列表,通过用错误消息终止程序来解析为非空列表。
在这种灵活的定义下,解析器是一个令人难以置信的强大工具:它们允许在程序和外部世界之间的边界上对输入进行拨出检查,并且一旦执行这些检查,他们从不需要再次检查! Haskellers很清楚这一电源,他们定期使用许多不同类型的解析器:
AESON库提供了一种解析器类型,可用于将JSON数据解析为域类型。
像持久性和postgreSQL-Simply的数据库库有一个机制,用于解析在外部数据存储中的解析值。
仆人生态系统围绕Parsing Haskell数据类型从路径组件,查询参数,HTTP标头等内构建。
所有这些库之间的共同主题是它们坐在Haskell应用程序和外部世界之间的边界上。这个世界在产品和总和中没有说话,但在字节的溪流中,因此没有必要进行一些解析。在对数据行事之前,对前面进行解析,可以走很长的路要避免许多类别的错误,其中一些甚至可能是安全漏洞。
对前面解析所有内容的这种方法的一个缺点是它有时需要在实际使用之前长时间解析值。以一种动态类型的语言,这可以使解析和处理逻辑同步一点棘手而没有广泛的测试覆盖,其中大部分可能是费力的维护。但是,对于静态类型系统,问题变得非常简单,如上面的非空白示例所示:如果解析和处理逻辑超出同步,则程序将无法编译。
希望在这一点上,您至少销售了解析优于验证的想法,但您可能会挥之不去的疑虑。验证是否真的如此糟糕,如果类型系统将强迫您迫使您最终进行必要的检查?也许错误报告将有点差,但有点冗余检查不能伤害,对吧?
不幸的是,它并不是那么简单。 ad-hoc验证导致语言 - 理论安全场呼叫猎枪解析的现象。在2016年的论文中,宝贝七炮塔:Langsec错误的分类以及如何开除它们,其作者提供以下定义:
Shotgun解析是一个编程反图案,解析和输入验证代码与在输入时的处理代码抛出云的处理代码和传播,并希望没有任何系统的理由,其中一个或另一个人会捕获所有的“坏”案例。
Shotgun解析必然剥夺了拒绝无效输入而不是处理它的能力的程序。输入流中的晚期错误将导致已处理的某些部分无效输入,结果是程序状态难以准确预测。
换句话说,不解析其所有输入前面的程序运行了在输入的有效部分作用的风险,发现不同的部分是无效的,并且突然需要回滚它已经执行的任何修改以便保持一致性。有时这是可能的 - 例如在RDBMS中回滚事务 - 但通常它可能不是。
它可能不会立即显而易见的是霰弹枪解析与验证 - 毕竟,如果您完成所有验证,请减轻霰弹枪解析的风险。问题是基于验证的方法使得非常困难或无法确定一切是否实际验证,如果可能实际上发生了一些所谓的“不可能”的情况。整个程序必须假设在任何地方提出异常,这不仅可能是必要的。
解析通过将程序分为两个阶段解析和执行来避免此问题 - 仅在第一个阶段发生无效输入导致的故障。执行期间的剩余故障模式的集合最小,并且可以通过它们所需的温柔照顾来处理。
到目前为止,这个博客帖子一直是销售播放的东西。 “你,亲爱的读者,应该是解析!”它说,如果我妥善完成了我的工作,至少有些人卖掉了。然而,即使你了解“什么”和“为什么”,你也可能对“如何”觉得特别有信心。
假设您正在编写一个接受代表键值对的元组列表的函数,并且您突然意识到如果列表具有重复键,则不确定您该怎么办。一个解决方案是编写一个函数,该函数是列表中没有任何重复项:
但是,这个检查很脆弱:它很容易忘记。因为它的返回值未使用,因此可以始终省略它,并且需要它仍然可以typecheck的代码。更好的解决方案是选择一种数据结构,该数据结构可以通过施工禁止重复键,例如地图。调整函数的类型签名以接受地图而不是元组列表,并根据您的正常实现它。
完成后,您的新功能的呼叫站点可能会无法进行TypeCreck,因为它仍然被传递了元组列表。如果呼叫者通过其中一个参数给出了值,或者从其他函数的结果收到它,您可以继续将类型从列表更新到映射,一直呼叫链。最终,您将达到创建值的位置,或者您可以找到实际应允许重复的地方。此时,您可以插入调用CheckNoduplignKeys的修改版本:
CheckNoduplicateKeys ::(MonadError Apperror M,EQ K)=> [(k,v)] - > m(map k v)
现在,无法省略检查,因为它的结果实际上是该程序所必需的!
使用使非法状态不足的数据结构。使用您合理的最精确的数据结构来模拟您的数据。如果使用当前使用的编码来解除特定可能性太难过,请考虑备用编码,可以更轻松地表达您要关心的属性。不要害怕重构。
尽可能将证明的负担推动,但没有进一步。将您的数据达到您尽快所需的最精确的表示。理想情况下,这应该发生在系统的边界,在任何数据采取行动之前。 3.
如果一个特定的代码分支最终需要一条数据的更精确表示,则一旦选择分支,就会将数据解析为更精确的表示。明智地使用和类型允许您的数据类型反映和适应控制流程。
换句话说,在您希望拥有的数据表示上写入功能,而不是您给出的数据表示。然后设计过程成为桥接间隙的运动,通常通过从两端工作,直到它们在中间的某个地方遇到。不要害怕在你去的时候迭代调整设计的部件,因为你可以在重构过程中学习新的东西!
让您的数据类型通知您的代码,不要让您的代码控制数据类型。避免诱惑只需在某处粘在一个唱片中,因为您目前正在编写的功能是必需的。不要害怕重构代码使用正确的数据表示 - 类型系统将确保您已介绍了需要更改的所有位置,并且稍后可能会节省头疼。
处理返回m()的函数,深度怀疑。有时这些是真正必要的,因为它们可能表现出没有有意义的结果的命令效果,但如果该效果的主要目的是提高错误,那就可能有更好的方法。
不要害怕在多次通过中解析数据。避免霰弹枪解析只意味着您在完全解析之前不应该在输入数据上行事,而不是您不能使用一些INP
......