Haskell程序是在两个世界的交界处构建的:一个是类型,另一个是值。值是可操作的运行时实体,在语法上由代码中的术语表示。为此,类型对术语进行分类。它们提供了强大的抽象来组织数据、确定数据应该如何存储在内存中、通过各种操作,等等。从表面上看,这些领域之间的区别很简单:类型指的是数据类型,它们要么是内置的(如Integer和String),要么是用户声明的,例如:
相比之下,值由那些类型分类的术语表示(例如1和";ABC";由标准库的Integer和String类型描述,或者在我们的用户声明类型SpringFlingQueen的情况下是Cady和Regina)。因此,术语1表示仅当程序运行时才存在于内存中的值。
除了这两个世界之外,第三个更黑暗、更难以捉摸的黑社会也潜伏在哈斯克尔计划的阴影中。哈斯克尔类型的人可能像精英高中集团中没有灵魂的阿尔法女性一样不可预测和阴险。虽然令人望而生畏,但理解这三个领域之间的协同作用为类型级编程奠定了基础,这是一个值得学习的主题,因为它增强了整个Haskell的直觉。
就像违反直觉的青少年社交动态一样,掌握哈斯克尔需要经历一个陡峭的学习曲线,因为它潜在的复杂性。很难理解语言的类型类层次结构、类别理论根源或编译器行为的错综复杂。然而,对类型系统的深入理解让程序员可以超越混乱的代码,并应用一个可识别的模板来理解它。
这个话题也很难掌握,因为它既博大又微妙。基于类型级编程的想法分散在许多分散在Haskell生态系统各个角落的互不相连的资源中。关于该主题的权威参考文献(例如相关的图书馆文档)往往假定熟悉非专家可能不了解的特定领域的术语。由于关于这个主题的通俗易懂的材料很少,我尝试将类型级编程分解为对其组成部分的解释。虽然我不会在下面的任何单独部分深入探讨,但我希望提供一个有价值的起点,让读者可以更详细地探索想法。
如上所述,Haskell程序分为两个领域:类型级和值级。类型级是指在编译期间由静态类型检查阶段分析的程序部分。因为每个表达式都分配了一个类型,所以会根据Haskell的类型系统检查代码,以确保它符合GHC指定的正确性标准。
如果程序类型正确,程序就会编译。但是,一旦编译过程终止,此类型信息就会消失,在运行时只留下值。这样,类型和值可以通过阶段化区别更好地区分,类型是编译时实体,值是运行时实体。
例如,如果一个函数接受一个字符串,只要它是一个字符串,类型检查器就不会关心该字符串是否具有由";abc";或";123";或";get表示的值。类型信息在运行时被丢弃,只留下字符串的值(例如,GET IN LOSE";)。但是,在某些情况下,我们希望类型系统关心这些值是什么,并区分它们。我们将在下面探讨的DataKinds扩展允许我们通过将更多关于程序的信息推入类型系统来做到这一点。这允许我们在类型级别为字符串的值添加意义,将它们从通常严格的运行时存在状态转移到编译时阶段。该技术允许我们在程序行为的逻辑开发和抽象中静态地使用更多信息。如果字符串的特定值不符合我们指定的规则,我们还可以添加可能会停止编译的结果,或者在这些结果上定义类。
让我们在GHCi中检查前面提到的SpringFlingQueen数据类型。当我们使用:t(:type的便捷简写形式)查询它的一个构造函数的类型时,我们看到它确实是SpringFlingQueen的类型:
现在考虑文字1、2、3。以下是将num类参数化的类型的值:
在GHCi中键入:t 1将得到num p=>;p,这表明文字(如1)可以是任何多态类型p,只要num类型类具有该类型的实例(例如,Integer和Float都有num实例,因此都是可以用来实例化p的有效类型)。由于类型推断,这是可能的。
就像类型对术语进行分类一样,类型对类型进行分类,因此经常被描述为“类型的类型”,或者被称为“上一级”。“star”语法(即,*)表示种类。尽管最近的句法发生了变化,但在这篇文章中还是大胆地使用了这个词。理解类体系的前提是必须理解三对观念之间的差异:
数据构造函数与类型构造函数:数据构造函数创建值,而类型构造函数创建类型。类型构造函数接受一个或多个类型参数,并在提供足够的参数时生成数据类型。这意味着通过当前处理,可以部分应用类型构造函数。例如,列表类型构造函数[]可以接受单个类型参数(例如。字符串)表示列表的元素(即[字符串])。[String]只是[]String的语法糖,其中类型[]应用于String。
完全应用程序与部分应用程序部分应用的类型,就像部分应用的函数一样,是缺少某些数据构造函数的类型。例如,考虑列表类型[]。[]的种类是*->;*。一个完全应用的列表有数据构造函数,比如[Int],它的种类是*。
有人居住的类型与无人居住的类型:一种类型的居民正是该类型的值。这意味着居住类型指的是包含具体值的类型,例如术语1::int表示的值。这表明类型Int由1表示的值驻留。相比之下,无居留类型指的是不抽象值的类型构造函数。例如,void是无人居住的,因为它没有数据构造函数,因此不能用来构造有效项。虽然void乍看起来似乎毫无意义,但它是表示容器为空的一种有用的方式(例如,[void],它由术语[]和该术语所表示的值表示)。
这些想法与仁慈系统有何关系?那么,所有完全应用的运行时值都是种类*并且它们是有居民的。您可以通过在GHCi中键入:k Int和:k string来确认这一点。然而,反之亦然-仅仅因为一个类型是驻留的,并不一定意味着它是完全应用的(例如,[]不是完全应用的,但是它是驻留的,它的种类签名是*->;*)。相反,所有部分应用的类型都是无人居住的(因为它们不对应于某个值),但并不是所有的无人居住类型都是部分应用的。例如,VOID不是部分应用的,但它是无人居住的。
种类签名可以使用XKindSignatures扩展在GHCi中手动指定。尝试通过调查各种类型的种类签名来扩展上表。
就像存在高阶函数(将其他函数作为参数的函数)一样,也存在更高类型的类型(将其他类型构造函数作为参数的类型构造函数)。类型构造函数(如[])是一种一级类型,但也是一种更高级的类型,因为它们接受另一个要具体化的类型构造函数:
我们看到Functor接受一个类型构造函数*->;*并返回一个约束。让我们使用:INFO更仔细地检查Functor:
λ:信息函数类函数(f::*->;*)其中fmap::(a->;b)->;f a->;f b(<;$)::a->;f b->;F a{-#Minimal FMAP#-}--在“GHC.Base”实例函数器(A)中定义--在“Data.Base”实例函数器((,)a,b,c)--在“Data.Orphans”实例函数器((,,)a b)--在“Data.Orphans”实例函数器[]--在“GHC.Base”实例函数器中定义--在“GHC.Base”实例函数器IO--在中定义。)r)--在‘GHC.Base’实例函数器((,)a)中定义--在‘GHC.Base’中定义。
我们看到Functor允许像Maybe和[]这样的类型构造函数有Functor实例,但不允许Int或String。这是因为可能和[]是*->;*,而Int有Kind*。类似地,要么有*->;*-->;*,这就是为什么上面的实例使用表示某种*->;*的类型参数参数化的原因:要么是a。
在函数器示例中,我们大致了解了上面的约束类型。约束类型是种类的另一种形式,表示=>;箭头左边的类型类约束。这允许程序员使用-XConstraintKinds扩展将约束的能力扩展到更多类型。
参数多态性在Haskell中无处不在;它允许我们在类型级别进行抽象。使用可以是任何类型的类型变量(如a)而不是Int等具体类型,我们可以定义更通用的数据和函数,从而在代码中提供更高的可重用性。种类多态性的工作原理与此类似,但在种类级别上。使用PolyKinds扩展,我们能够定义在各种类型上工作的函数和类型,而不是那些实现绑定到特定种类签名的函数和类型。Typeable的经典示例被广泛用于演示为什么它很有用:
类可类型(t::*)其中Typeof::t->;TypeRepclass Typeable1(t::*->;*)其中TypeOf1::t a->;TypeRepclass Typeable2(t::*->;*->;*)其中TypeOf2::t a b->;TypeRep
上面的类是为多个奇偶性定义的,每个类指定一种满足特定奇异性的签名:*、*->;*或*->;*->;*。默认情况下,类型变量a和b都是种类*,这就是为什么Typeable1有一个类型变量,Typeable2有两个类型变量的原因。适应不同类型的参数是很酷的,但是这种实现需要一遍又一遍地键入不同的定义。它还要求程序员参考提供的种类签名,以确定我们可以与Typeable与Typeable1和Typeable2一起使用的适当类型,以确保善意:
实例Typeable Int where typeof_=TypeRepinstance Typeable1[]where typeOf1_=TypeRepinstance Typeable2 WHERE typeOf2_=TypeRep。
基本的编程直觉告诉我们,不仅每种类型的单独实现都是不可持续和乏味的,而且它还增加了我们的代码的表面积,从而增加了出错的可能性。如果我们对所有种类都有一个定义呢?PolyKinds扩展让我们可以做到这一点。这使我们可以将这三个类统一到单个表示中:
{-#language PolyKinds#-}数据代理t=代理类可类型化(t::k),其中typeof::proxy t->;TypeRep。
我们现在可以灵活地将任何类型级别的实体传递给typeof,而不仅仅是像Int或*->;*像[]或*->;*这样的类型*的实体。这是因为代理人对所有人都很友善。K->;*。这允许我们为各种类型实例化单个可键入类:
Instance Typeable Int where typeof_=TypeRepinstance typeable[]where typeof_=TypeRepinstance typeable where typeof_=TypeRep。
DataKinds语言扩展允许我们通过提供类型级别的文字在类型级别对数据进行推理。通常,使用:k查询诸如1或";foo";这样的文字类型是不起作用的。这是因为这些实体处于价值级别:
λ:k 1<;交互式>;:1:1:错误:非法类型:‘1’可能您打算使用DataKinds
如错误所示,为了能够检查值的类型,我们需要启用DataKinds扩展。回想一下,1存在于值级别。Haskell的类型系统不关心编译时的值,因为它们只是运行时实体。但是,在启用XDataKinds的情况下,调查文字类型(如1或";foo";)可以得出以下结果:
整数1属于NAT类型,字符串";foo";属于种类符号。NAT和Symbol是由GHC.TypeLits模块(如下所述)定义的构造函数,因此除非我们导入它,否则它们不在范围之内。
DataKinds扩展还支持数据类型提升。当我们定义数据类型时,我们创建自定义类型。启用DataKinds后,该类型将成为自定义类型,其数据构造函数也将成为类型,从而在值层次结构(>;类型->;种类)中得到“提升”。
回到我们喜爱的SpringFlingQueen示例,让我们考虑一下在REPL中创建简单类型构造函数时通常会发生什么:
λData SpringFlingQueen=Cady|Reginaλ:T CadyCady::SpringFlingQueenλ:K SpringFlingQueenSpringFlingQueen::*λ:K Cady<;Interactive>;:1:1:Error:不在作用域中:类型构造函数或类‘Cady’该名称的数据构造函数在作用域中;您是指DataKinds吗?
我们可以检查它的数据构造函数的类型,以及它的类型构造函数的类型。但是,当我们试图查询Cady的类型时,我们得到一个错误。但是,当我们打开XDataKinds时,我们会看到以下内容:
λ:Set-XDataKindsλ:t CadyCady::SpringFlingQueenλ:K Cady<;Interactive>;:1:1:Warning:[-Wunticked-Promoted-Constructors]未勾选的提升构造函数:‘Cady’。用‘';Cady’代替‘Cady’。Cady::SpringFlingQueen。
我们可以查询Cady的类型,因为它现在是类型级值,而SpringFlingQueen现在是种类级类型。
它会编译的!但是,如果您像我一样打开了-Wunticked-Promoted-Constructors警告,它就会被触发。虽然不需要刻度(代码编译时不需要刻度),但您通常需要在数据构造函数前面加上刻度,以消除它们与常规的、未升级的数据构造函数的歧义:
以下是你可以推广什么和不能推广什么的快速总结:
<;li&>;type同义词<;/li>;<;li>;type/data family<;/li>;<;li>;高类类型(如data Fix f=in(f(Fix F))<;/li>;<;li>;类型涉及提升类型的数据类型(如VEC::*->;NAT。种类多态、涉及约束、提及类型或数据族的数据构造函数<;/li>;
在这些世界之间划清界限可能会令人困惑。虽然每个类别处理不同的实体,但它们都使用相同的文字语法。回想一下,值只存在于运行时,并且由语法(1)中出现的术语表示。我制作了以下图表来演示几个简单值之间的关系。如果最左边的实体是值,则使用:t将其键入GHCi,如果是类型,则使用:k将最左边的实体键入GHCi,结果如下:
作为一种类型的东西和处于类型级别的东西之间存在着微妙但本质的区别。尽管数据类型提升允许我们表示以其他方式表示类型级别的值的构造函数(例如,True被提升为True),但这不会使它们成为类型。要成为一种类型,它必须具有被居住的能力,这意味着它的类型的值是存在的。例如,Bool是一个类型,因为它可以有居民(即True和False),而';True不是类型,因为它不能有居民,尽管它具有编译时意义。
函数对于值的作用就像类型族对于类型的作用一样。通过GHCi中的-XTypeFamilies标志或程序中的{-#language TypeFamilies#-}选项启用,它们提供了一种指定如何将一种类型映射到另一种类型的方法。例如,字符串连接通常通过++或<;>;函数完成,但这些运算符专门用于值。为了串联类型级别字符串,GHC.TypeLits定义了一个名为AppendSymbol的类型族:
类型系统无法运行常规Haskell函数。但是,它可以评估类型族。由于类型族将类型映射到其他类型,因此它们的行为与函数非常相似。例如,当类型family+(类型为NAT->;NAT->;NAT)应用于类型级自然数5和2时,其计算结果为NAT值7,就像(类型为num p=>;p->;p)的值级函数(+)在应用于Int值时的行为一样。
闭合类型族都是在一个位置定义的。它们在不能扩展的意义上是封闭的:您不能在其他地方向它们添加更多的案例。这些是使用WHERE子句及其用例列表(也称为“类型实例”)定义的:
开放式类型族是在没有WHERE子句后跟实例列表的情况下定义的,可以出现在顶级类型族中,也可以出现在类型族的正文中。与封闭式类型族不同,封闭式类型族是一个黑盒,开放式类型族可以延伸。
下面是在顶级声明的类型族的另一个示例,这一次使用实例:
类型系列UnsignedVersionOf ttype实例UnsignedVersionOf Int=WordType实例UnsignedVersionOf Int32=Word32type实例UnsignedVersionOf Int64=Word64。
请注意,类型族是参数化的,因为它们包含可以用不同参数实例化的多态类型变量t,从而允许专用表示。在上面的示例中,Int、Int32和Int64都会产生不同的赋值。如果您希望根据类型产生不同的结果,则此功能非常强大。
当它们出现在类型类的主体中时,它们也称为关联类型。例如:
在本例中,我们不必说类型族,只要说类型就行了,因为与类关联的任何类型都会形成一个开放的类型族。关联类型的另一个示例是Generic1中的Rep或Generic1中的Rep1。
请注意,这看起来像是填写函数的案例,这是因为它确实如此。我们声明UnsignedVersionOf类型族将类型映射到其他类型,并且此映射发生在编译时。这就是函数在类型级别上的工作方式。
类似地,Rep是泛型类的关联类型,它将类型映射到编译器可用的有关该类型的泛型信息。它由GHC.Generics类定义为:
当您在类型级别调用Rep Int时,GHC进行一些计算,并返回一些描述Int#的类型,这是一种未装箱的类型。此类型通常基于表示数据类型形状的泛型类型之一(例如,M1、K等)。
未装箱的类型具有种类#。通常,值包含在“框”中,因为它们由指向堆分配的对象的指针表示。这种对值进行装箱的方式启用了延迟计算,但可能会导致编译时间变慢。相反,未装箱的值直接获取原始值。
导入GHC.TypeLits会暴露种类NAT和Symbol的名称(我们之前在调查整数值和字符串值的种类时看到的名称)。Num类定义可在实例化它的类型(如Integer)上使用的操作(如+)。但是,像+这样的函数只能用于对值执行计算(如6+2,其结果为8)。如果我们想在类型级别执行这些计算,我们需要类型族。GHC.TypeLits提供了可以覆盖类型级别文字的类型族。例如,如果我们希望通过+进行二进制加法,我们有(类型family(m::
..