存在的Haskell

2020-11-26 22:06:43

大多数软件工程文献将面向对象的编程描绘为与功能编程不同,并且常常与功能编程不相容。当然,这是错误的二分法,因为函数式编程所鼓励的技术甚至适用于大多数面向对象的语言。然而,面向对象(可能是历史上最受欢迎的软件范例)已经普及了其宗旨,有时甚至可以看到它们出现在像Haskell这样的编程语言中,该语言与面向对象的哲学尽可能地对立。

在本篇文章中,我将描述一个使用Java等ALGOL风格语言隐藏信息的常见示例,然后以与Haskell兼容的术语来表达这一信息。然后,我们将使用此技术将响应程序链移植到Haskell,以说明Haskell如何在存在隐藏类型信息的情况下支持动态函数分派。我写这篇文章并不是因为我希望突破任何新领域-我在这里使用的所有技术在文献中都有很长的文献记载,并且Haskell退伍军人在这篇文章中可能会发现很少的新知识。跳到倒数第二部分,其中包含一些我还没有见过的有用的数据类型。 —但是由于现有资源分散,考虑到对大多数非Haskell编程语言的中央动态分配的影响,这也许很奇怪,并且由于探索设计中的极端情况说明了语言和库设计固有的折衷。

世界上大多数静态类型的编程语言都允许其用户编写类似于以下Java的代码:

从语法上讲,这段代码没有争议:它是一个返回值的函数。它唯一有趣的方面是函数签名-即使函数主体返回String类型的值,其返回类型也声明为Comparable,这不是具体的数据类型,而是Java接口。因此,我们不能将此函数调用的结果视为实际的String;我们只能通过Comparable接口上定义的方法与其进行交互。即使在像Haskell这样的强类型语言中,这种最小功耗规则的应用也是一种有用的方法:有时我们想隐藏函数返回类型的实现细节。

:3:27:错误:•无法将预期类型'a'与实际类型'[Char]'相匹配'a'是一个刚性类型变量,受以下类型签名的约束:someComparableValue :: forall a。 Ord a => Int-> a

Haskell的类型检查器查看此函数的主体并说:“嘿,伙计,您在这里返回一个具体的字符串值,而不是'任何可Ord的类型。'尽管这在Java中是一个有效的概念,但它不是有效的在Haskell。关于此的另一种观点是Java允许值具有多个类型:我们可以将Java字符串文字视为类型java.lang.String或类型的值,即使Comparable是接口,也不是具体类型Comparable ,或其超类java.lang.Object。但是,由于Haskell不支持继承,因此Haskell将其值视为只有一种类型。解决此问题需要明智地使用存在类型。

在Haskell中,存在性数据类型不是根据具体类型定义的,而是根据在数据声明的右侧引入的量化类型变量定义的。就像许多Haskell概念一样,这并不是抽象中特别有用的定义。显示起来比说起来容易,所以让我们看一个存在类型的规范示例之一:Showable类型,它包装了实现Show接口的任何类型。

关于此数据类型,有一些有趣的事情。首先,它使用forall关键字引入一个type变量:鉴于我们正在处理存在潜在类型,因此让我陷入一个循环,即没有存在关键字。 Scala为此保留了一个forSome关键字,就引入此类型变量的目的而言,我认为它读得更准确:使用短语“ for all”有点不合适,因为Showable构造函数应用于一个时间。考虑Showable的构造函数可能会更具有启发性:

我们可以将其理解为“ Showable是一个构造函数,它对于实现了Show的所有类型a都采用一个值,并返回Showable类型的值,一旦其内部的值便不再对世界可见。已被应用。”

其次,我们不能使用新类型来声明存在。尝试编写以下内容:

•newtype构造函数的类型Showable :: forall a不能具有上下文。显示=> a-> Showable•在数据构造函数“ Showable”的定义中在“ Showable”的新类型声明中

当我们将类型类视为字典时,此限制更有意义:在GHC Core中,此Show约束将表示为假设的ShowDict数据类型,该数据类型包含show,showsPrec和showList函数的实现。因此,我们可以看到Showable接受两个参数,而不是一个:一个要包装的值,以及与该值的类型关联的ShowDict字典。存在新类型来包装单个值,并且这里我们同时包装了一个数据及其相关的Show字典:因此,即使相关的Showable构造函数仅接受一个值(在Haskell表面语法中),这里我们也需要一个数据声明。这是可以理解的限制,但是如果这种存在性值可以以新类型的方式加入派生机制中,那将很酷。

第三个有趣的事情是:我们无法编写解包此数据类型的函数。看起来像函数的直观类型被拒绝了:

-GHC将拒绝这一点。 unwrapShowable :: Showable->(forall a。Show a => a)unwrapShowable(Showable a)= a

如果我们使用记录选择器语法,我们可以更详细地了解这一点。

数据可显示=全部a。显示=>可显示{getShowable :: a}

尝试将getShowable用作提取某些任意Show-inhabiting类型的函数会产生一个很好的解释错误消息:

:1:1:错误:•由于转义了类型变量而无法将记录选择器“ getShowable”用作函数可能的解决方法:改用模式匹配语法•在表达式中:getShowable

我在这里使用的思维模型是,应用存在类型的构造函数充当类型信息的事件范围。在其他语言中,我们可以本机地组装异构列表。相比之下,在Haskell中,我们必须明确选择加入它:将Showable构造函数应用于值会吞噬其类型信息。我们无法编写一个函数,无论是手写的unwrapShowable还是从我们的getShowable记录选择器派生的函数,都可以从一个存在的类型中解包出任意类型。保留的全部能力是,给定适当的case语句,可以解开存在对象中的值,以显示其中包含的值:如上面的错误消息所述,它无法逃脱其范围,但是,我们可以使用getShowable记录选择器更新显示值中的包装值。 。

正如我上面提到的,我们可以使用case语句来跨越事件范围,将符合Show的内容绑定到变量名称:

在此case语句的右侧,我们在范围内有一个值x。快速查询类型孔可以发现我们期望的类型:

•相关绑定包括x :: a(绑定到:28:15)约束包括Show a(来自:28:11-15)

我们只知道这个值x就是我们可以调用Show。除了将其传递给基本的组合器(id和const)之外,我们只能使用此值。类型信息的任何一点都已丢失,而是通过类型类替换为功能。同样,当我们将类型类视为字典参数时,我们可以在核心演算级别上可视化它的工作方式:我们丢弃类型信息,仅包括forall上下文提供的相关字典。

关于此类型的第四个也是最后一个有趣的事情是,您可以使用GADTs GHC扩展来编写它,而无需使用显式的forall关键字:

这是因为GADT允许我们引入每个构造函数的类型变量和相关的约束,即使类型变量在外部不可见。需要注意的另一件事是,包含存在性值的数据声明不必限于单个值:它们可以包含具体值,也可以使用由更多引入的类型变量表示的值。

能够隐藏函数的返回类型的实现细节很好,但是许多用户将需要从存在类型转换回(或尝试转换)回具体类型。 Java通过instanceof运算符及其强制转换语法提供此功能:

可比的c = someFn();如果c instanceof String {系统。出来。 println(“得到一个字符串:” +(String)c); } else {系统。出来。 println(“在此处铸造为String会引发ClassCastException”); }

这是所有Java对象都源自java.lang.Object的结果,也是instanceof运算符在运行时查询对象类型的能力的结果。尽管这种编程风格在Haskell中并不是很流行,但是它并不是闻所未闻的,Haskell确实支持它:这是Typeable类型类的用处。在Control.Exception的基础上,它是最显着的基础。

class(可键入a,Show e)=>异常e data SomeException = forall e。异常e => SomeException e

此代码以新的类型类Exception的声明开头,该类继承自Typeable和Show。 Exception类型类从Typeable继承而来的事实意味着,我们可以使用基本的Typeable基本类型cast来对具体值进行安全的转换,从而考虑了失败的可能性。

让我们举个例子,在IO monad的最低级(或也许强大,取决于您的外观)上,使用Haskell的动态类型化异常层次结构:

cautiouslyPrint ::显示a => IO a-> IO()cautiouslyPrint go = Control.Exception.catch(go >> = print)处理程序,其中处理程序:: SomeException-> IO()处理程序(SomeException e)= Just DivideByZero-> putStrLn“被零除”无-> putStrLn(“其他异常:” <> show e)

在这里,我们使用catch函数来评估提供的go参数,并在抛出运行时异常时调用处理程序。我们仅处理一种可能的错误类型:DivideByZero,ArithException的构造函数之一。但是,我们是通过检查是否经过强制转换而实现的,这取决于转换函数,因为我们无法直接识别ArithException值:处理程序将在任何异常上调用,因为SomeException要捕获,意味着“此catch语句应处理任何且它的身体抛出的所有异常。”看看演员的类型可以说明:

当类型a和b对齐时,强制类型转换(可能并不奇怪)被定义为仅返回一个值。这要归功于Typeable类,它是在运行时动态完成的,它确实是一个特殊的类型类:它是GHC明确禁止任何用户指定实例的仅有的两个类型类之一。尝试一下;您会被打耳光:

:4:10:错误:•类'Typeable'不支持用户指定的实例•在'Typeable Foo'的实例声明中

GHC禁止这样做是正确的:因为Typeable与内存中Haskell类型的内部表示有关,因此GHC有责任为您实现它。确实如此:所有类型都是免费实现Typeable的。请注意,强制转换要考虑所有类型信息,而不仅仅是结构:实际上,这意味着您不能将Maybe Int类型的Nothing值转换为Maybe Char类型的Nothing值,即使独立的Nothing标识符可以隐式地强制转换为Maybe Char,Maybe Int或String的值。

handler :: SomeException-> IO()handler(SomeException e)= Just DivideByZero的大小写转换e-> putStrLn“被零除”无-> putStrLn(“其他异常:” <> show e)

如前所述,我们仅处理一种可能的错误情况:尽管将为所有异常类型调用处理程序,但我们的转换操作仅处理DivideByZero异常(ArithException类型)。我们可以毫不费力地添加新的ArithException案例:

Just DivideByZero-> putStrLn“被零除” Just Underflow-> putStrLn“浮点数”没什么-> putStrLn(“其他异常:“ <> show e)

但是,当我们要处理不相交的符合异常的类型时,问题将变得更加棘手。问题的简单编码将不起作用,如下面的注释所示,该Just(e :: ArithException)语法(其中我们注释了具有指定类型且没有模式匹配的值)要求启用ScopedTypeVariables扩展。 ScopedTypeVariables应该始终启用:它做对了并且很明显。 ,我们尝试处理ArithExceptions和ArrayExceptions:

Just(arith :: ArithException)-> putStrLn(“ arithmetic:” <> show arith)Just(array :: ArrayException)-> putStrLn(“ array:” <> show array)

这将导致编译器错误,因为case语句分支的左侧的所有值都必须具有相同的类型!更正的版本可能显示为:

handler(SomeException e)= Just(arith :: ArithException)-> putStrLn(“ arith:” <> show arith)的大小写转换e-Just(array :: ArrayException)-> putStrLn(“ :“ <>显示数组)无-> putStrLn(”其他一些例外:“ <>显示e)

要解决第一个强制类型转换表达式将其结果类型限制为ArithException类型的值这一事实,我们必须再次调用强制类型转换:这一次,Typeable值固定到ArrayException,这使我们可以在Just子句中处理成功的强制类型转换和失败在Nothing子句中。

上面的模式存在一个严重的问题:只有两种情况,它就像笨拙的地狱一样,随着添加更多可能的类型而变得笨拙。一种更现代的方法是使用GHC的MultiwayIf,这对于新来者来说可能是令人惊讶的。 if语句通常与布尔值有关,但不会这样:相反,我们将使用保护语法区分大小写来调用强制类型转换。通过保护(使用|)从强制转换返回的Just值,我们可以得到类似于多型case语句的内容:

如果| Just(arith :: ArithException) putStrLn(“ arith:” <> show arith)|只是(array :: ArrayException) putStrLn(“ array:” <>显示数组)|否则-> putStrLn(“其他:” <> show e)

可以说,这是MultiWayIf精神的混蛋,表面上是关于简化布尔方程组的大型系统。此处,否则涉及的唯一布尔值(由前奏定义为True)是其他情况。因为True永远都是True,所以它作为最后一个分支的位置将意味着它总是匹配的,除非与先前的情况(即成功的Just值)匹配。但是上下文中的其他内容是可读的,代码的意图很明确,并且它的一个缺陷(与多次调用关联的重复工作)可以通过简单的let绑定来解决:

如果| Just(arith :: ArithException) putStrLn(“ arith:” <> show arith)|只是(array :: ArrayException) putStrLn(“ array:” <>显示数组)|否则-> putStrLn(“其他:” <> show e)

尽管这种运行时多态在Haskell中并不是很普遍-我们通常在编译时解决多态-这并不是闻所未闻的,并且如上所述,它是GHC异常层次结构的Control.Exception接口的一部分。这种Haskell设计模式(一种从Typeable继承的现有数据类型)与Haskell获得的动态调度一样接近。尽管不常见,但也不是无效的:有时需要的是事件范围,它隐藏了基准的具体表示,但通过多态性提供了使用Typeable将其自身重新构造为具体类型的机会。

对于大多数GUI编程而言,至关重要的是macOS和iOS称为响应者链的概念。响应者链负责通过用户界面的层次结构传递事件,例如按键,鼠标单击,设备运动。例如,如果用户选择了文本字段,则在iOS中摇晃设备会产生撤消事件。响应者链负责将摇动事件向下传递到窗口层次结构中,最终在文本字段上建立。如果未选择,则UI的其余部分将有机会拦截和解释此事件。

从世界的面向对象的角度来看,实现响应者链非常简单:所有用户界面元素都可以扩展一些超类,并且该接口为动态分配事件提供了通用语言。至少从表面上看,它在没有分型的强类型世界中变得更加复杂。实际上,这是Objective-C社区对Swift的出现表达的忧虑之一。尽管Swift完全有能力表达流畅,惯用的响应者链,但该课程的适用范围更广。实际上,我们可以设想一个在Haskell中实现此行为的UI框架:

数据响应a where接受:: a->响应完成Finish :: a-> Response()Defer ::响应一个类(可键入a,显示a)=>响应a where响应::事件->响应数据SomeResponder =永远一个。响应者a => SomeResponder一个新类型Chain = Chain [SomeResponder]-用ST monad实现简单的污垢命令式实现。 -带有折叠的实现可以完全做到这一点-但是累加器稍微有点传播::响应者a =>(a-> a)->链->链传播fn(链c)= runST做- -我们需要一个信号变量,以防万一链中有东西-要中止遍历。中止 do-尝试在给定= fmap fn(cast item)的每个项目上应用该功能-但首先检查是否在先前的迭代中中止完成纯项目-写入信号变量时返回新值。 |只需(完成a)一个纯a-没有匹配项?继续前进| _-> pure item pure(连锁结果)

与Exception类类似,我们定义一个Responder类型类,该类实现了所有可以响应某种假设事件类型的UI元素所共有的接口。为了继承c,它同时继承自Show和Typeable。

......