类型作为公理,或者:使用静态类型扮演上帝

2020-08-15 17:17:04

函数emptyLike(val:number|string):number|string{if(typeof val=";number";){return 0;}Else{return";";;}}。

现在,如果我们编写emptyLike(42)*10,类型检查器将再次抱怨,声称结果可能是一个字符串-它无法“计算”当我们传递一个数字时,我们总是得到一个数字。

当从这个角度来处理类型系统时,结果通常是令人沮丧的。程序员知道等价的非类型化JavaScript行为非常好,所以类型检查器被认为是顽固而愚蠢的不幸组合。更重要的是,程序员可能对类型检查器的内部操作几乎没有心理模型,因此当推断(不是显式编写)上述类型时,可能不清楚存在哪些解决方案来消除错误。

在这一点上,程序员可能会放弃。“愚蠢的类型检查器,”他们抱怨道,将emptyList的返回类型更改为Any。“如果连这一点都想不出来,真的能有那么大用处吗?”

可悲的是,这种与类型检查器的关系太常见了,尤其是渐进式类型的语言往往会造成挫折感的恶性循环:

渐进式类型系统被有意地设计为尽可能地“只处理”惯用代码,因此程序员可能不会过多地考虑类型,除非他们遇到类型错误。

此外,许多使用逐步类型化语言的程序员已经熟练地使用底层动态类型化语言进行编程,因此他们仅就动态语义而言就有程序操作的工作心智模型。它们不太可能开发类型系统静态语义的丰富心智模型,因为它们习惯于在没有类型系统的情况下进行推理。

渐进式类型化语言必须支持来自其动态类型化遗产的习惯用法,因此它们通常包括特殊的特殊情况(例如,对类型检查的特殊处理),这些特殊情况模糊了类型检查器遵循的规则,并使它们看起来半神奇。

内置类型在类型系统中深受好评,它强烈鼓励程序员接受它们的全部灵活性,但在遇到它们的极限时却几乎没有留下什么资源。

所有这些挫折都催生了使用强制转换或任何类型转换重写类型检查器的准备,这最终创建了一个自我实现的预言,在这个预言中,类型检查器很少捕捉到任何有趣的错误,因为它经常被禁用。

所有这一切的最终结果是一种失败主义的态度,充其量将类型检查器视为次要的工具便利(即花哨的自动完成提供程序),或者在最坏的情况下将其视为积极的障碍。谁能真正责怪他们呢?类型系统(当然是无意的)被设计成将它们引向这条死胡同的方式。公众对类型系统的看法归结为我们忍受的一个惊人的字面上的吹毛求疵者,而不是作为我们积极利用的工具。

在我上面说了这么多之后,可能很难想象还有其他方式来看待类型。事实上,通过打字脚本的镜头,“类型就是限制”的心态是令人难以置信的自然的,以至于它似乎不言而喻。但是,让我们暂时离开打字,关注一种不同的语言,Haskell,它鼓励了一些不同的观点。如果您不熟悉Haskell,没关系-无论您是否编写过Haskell,我都会尽量使这篇博客文章中的示例易于访问。

尽管Haskell和TypeScript都是静态键入的--而且它们的类型系统都相当复杂--但Haskell的类型系统在哲学上几乎完全不同:

Haskell没有子类型,2这意味着每个值只属于一种类型。

虽然JavaScript是围绕一小部分灵活的内置数据类型(布尔值、数字、字符串、数组和对象)构建的,但Haskell除了数字之外基本上没有幸运的内置数据类型。布尔值、列表和元组等键类型是标准库中定义的普通数据类型,与用户可以定义的类型没有什么不同。3个。

具体地说,Haskell是围绕这样一种理念构建的,即可以用多个案例定义数据类型,并且分支是通过模式匹配来完成的(稍后将对此进行更多介绍)。

让我们来看一个基本的Haskell数据类型声明。假设我们要定义一个表示季节的类型:

如果您熟悉TypeScript,这看起来可能非常类似于联合类型;如果您熟悉C系列语言,这可能会让您更多地想到枚举。两者都在正确的轨道上:这定义了一个名为Season的新类型,它有四个可能的值:Spring、Summer、Fall和冬季。

在TypeScript中,我们将使用字符串并集表示此类型,如下所示:

在这里,Season是一种类型,可以是这四个字符串中的一个,但不能是其他类型。

这里,春、夏、秋和冬本质上被定义为整数0、1、2和3的全局别名,而类型枚举季节本质上是int的别名。

所以在TypeScript中,值是字符串,而在C中,值是数字。他们在哈斯克尔有什么?Well…。他们就是这样。

哈斯克尔声明凭空发明了四个全新的常量:春、夏、秋和冬。它们不是数字的别名,也不是符号或字符串。编译器没有公开任何关于它在运行时选择如何表示这些值的内容;这是一个实现细节。在Haskell中,Spring现在是一个不同于所有其他值的值,即使不同模块中的某人也使用了Spring这个名称。Haskell类型声明让我们扮演上帝的角色,从无到有地创造一些东西。

由于这些值完全是唯一的、抽象的常量,我们实际上可以对它们做些什么呢?答案是一件事,而且恰好是一件事:我们可以在他们身上分支。例如,我们可以编写一个函数,该函数将季节作为参数,并返回圣诞节是否在此期间发生:

包含圣诞节::季节->;Bool包含圣诞节季节=春季的案例季节->;假夏天->;True--南半球秋天->;假冬天->;True--北半球。

大体上来说,case表达式非常类似于C风格的switch语句(尽管它们可以做的比这个简单的示例显示的要多得多)。使用CASE,如果需要,我们还可以定义从完全唯一的季节常量到其他类型的转换:

SeasonToString::Season-&>;String seasonToString Season=Case Season of Spring-&>;";Spring";Summer-&&>;";夏季";秋季-&>;";秋季";冬季-&>;";冬季";

我们也可以反其道而行之,将字符串转换为季节,但是如果我们尝试这样做,就会遇到一个问题:比如芝士蛋糕这样的字符串,我们应该返回什么呢?在其他语言中,我们可能抛出错误或返回NULL,但是Haskell没有NULL,并且错误通常是为真正的灾难性故障保留的。我们还能做些什么呢?

一个特别幼稚的解决方案是创建一个名为MaybeASeason的类型,它有两种情况-它可以是有效季节,也可以不是NotASeason:

数据可能ASeason=IsASeason Season|NotASeason Strong ToSeason::String->;MaybeASeason Strong ToSeason SeasonString=case seasonString of";Spring";->;IsASeason Spring";夏季";->;IsASeason夏季";秋季&34;->;IsASeason秋季";冬季";->;

这显示了C样式枚举没有的Haskell数据类型的一个特性:它们不仅是常量,还可以包含其他值。MaybeASeason可以是五个不同的值之一:IsASeason Spring、IsASeason Summer、IsASeason Fall、IsASeason冬季或NotASeason。

这有点不错,因为我们不需要像在Haskell那样用IsASeason包装我们所有的季节价值。但是请记住,Haskell没有子类型-每个值必须恰好属于一种类型-因此Haskell代码需要IsASeason包装器来区分值为MaybeASeason而不是季节。

太棒了!但是,如果约束更复杂:如果您需要一个包含偶数个元素的数组,该怎么办呢?不幸的是,对于这一点并没有真正的诀窍。在这一点上,您可能开始希望类型系统支持一些非常奇特的东西,比如精化类型,这样您就可以编写如下内容:

但是TypeScript不支持这样的东西,所以现在你被卡住了。您需要一种方式来限制函数的域,这种方式类型系统没有任何特殊支持,因此您的结论可能是“我想类型系统就是做不到这一点。”人们倾向于将此称为“与类型系统的极限相撞”。

但如果我们换个角度呢?回想一下,在Haskell中,列表不是内置数据类型,它们只是标准库中定义的普通数据类型:4。

如果您没有编写任何Haskell,这个类型一开始可能会有点混乱,因为它是递归的。所有这些都是List Int类型的有效值:

CONS的递归性质使我们的用户定义数据类型能够保存任意数量的值:在以最终的NIL结束列表之前,我们可以拥有任意数量的嵌套Conses。

如果我们想在Haskell中定义一个EvenList类型,我们可能最终会按照之前的思路思考,我们需要一些奇特的类型系统扩展,这样我们就可以限制List以排除包含奇数个元素的列表。但这是在关注我们想要排除…的负面空间。相反,如果我们把重点放在我们想要包括的积极空间上,会怎么样?

我这么说是什么意思?嗯,我们可以定义一个全新的类型,就像List一样,但是我们不可能包含奇数个元素:

现在,在这一点上,你可能会意识到这有点傻。我们不需要为此发明全新的数据类型!我们可以只创建一个配对列表:

现在,像CONS(1,2)(CONS(3,4)Nil)这样的值将是EvenList Int类型的有效值,我们不必重新发明列表。但同样,这是一种基于思考的方法,而不是考虑我们想要排除哪些值,而是考虑如何组织我们的数据,使这些非法值甚至无法构建。

这就是哈斯克勒口头禅的精髓,“让非法国家变得不可代表”,可悲的是,它经常被曲解。更容易想到的是“嗯,我想把这些州定为非法的,我怎么才能加上一些事后的限制来排除它们呢?”事实上,这就是为什么精化类型真的很棒的原因,当它们可用时,一定要使用它们!但是,在类型级别检查完全任意的属性通常并不容易,有时您需要更多地跳出框框进行思考。

到目前为止,在这篇博客文章中,我已经用几种不同的方式反复触及了几个不同的想法:

与其考虑如何限制,不如考虑如何正确构造。

我们可以使用“具有多种可能性的数据类型”这一令人难以置信的简单框架来表示许多不同的数据结构。

独立来看,这些想法可能看起来不是很相关,但实际上,它们都是哈斯克尔数据建模学派的必备要素。现在我想探索如何将它们统一到一个单一的框架中,使其看起来不那么神奇,而更像是一个迭代的设计过程。

在Haskell中,当您定义数据类型时,实际上是在定义一组新的、自包含的公理和推理规则。那是相当抽象的,所以让我们把它说得更具体一些。再次考虑列表类型:

如果您有一个列表,并且您在开头添加了一个元素,则结果也是一个列表。

公理为零,推理规则为CONS。每个列表5都是通过从公理Nil开始,然后是推理规则CONS的一些应用来构建的。

我们可以在设计EvenList类型时采用类似的方法。公理是一样的:

但是我们的推理规则必须保持不变量,即列表始终包含偶数个元素。我们可以通过始终一次添加两个元素来实现这一点:

如果您有一个元素数为偶数的列表,并且在开头添加了两个元素,则结果也是一个元素数为偶数的列表。

我们也可以通过同样的推理过程来得出一个表示非空列表的类型。该类型只有一个推理规则:

如果您有一个列表,并且在开头添加了一个元素,则结果是一个非空列表。

当然,不仅仅是使用列表也可以做到这一点。一个特别经典的例子是自然数的构造性定义:

如果你有一个自然数,那么它的后继者(即那个数加一)也是一个自然数。

这是两个Peano公理,在Haskell中可以表示为以下数据类型:

使用此类型时,Zero表示0,Succ Zero表示1,Succ(Succ Zero)表示2,依此类推。就像EvenList允许我们用偶数个元素表示任何列表,但使其他值甚至无法表示一样,这个Natural类型允许我们表示所有自然数,而其他数字(例如,负整数)是不可能表示的。

当然,现在所有这一切都取决于我们对自己发明的价值观的理解!我们选择将0解释为0,将Succ n解释为n+1,但这种解释并不是Natural定义的固有含义--它都在我们的头脑中!我们可以选择将Succ n解释为n-1,在这种情况下,我们将只能表示非正整数,或者我们可以将0解释为1,将Succ n解释为n*2,在这种情况下,我们只能表示2的幂。

我发现人们有时会觉得这种方法很麻烦,或者至少是违反直觉的。Succ(Succ Zero)真的是2吗?它看起来当然不像是我们习惯写的数字。当有人认为“我需要一个大于或等于零的数字的数据类型”时,他们会使用编程语言Number或int中的类型,而不是想发明递归数据类型。无可否认,这里定义的Natural类型不太实用:它是自然数的一种非常低效的表示。

但是在不太做作的情况下,这种方法是实用的,而且实际上它非常有用!。

.