Haskell的types检查器允许非常错误的typesreplace,并且程序仍然编译

在我的程序中尝试debugging一个问题时(使用Gloss的两个相同半径的圆正被绘制成不同的大小),我偶然发现了一个奇怪的情况。 在处理对象的文件中,我对Player有如下的定义:

 type Coord = (Float,Float) data Obj = Player { oPos :: Coord, oDims :: Coord } 

并在我的主文件,其中导入Objects.hs,我有以下定义:

 startPlayer :: Obj startPlayer = Player (0,0) 10 

发生这种情况是因为我添加和更改了播放器的字段,忘记更新startPlayer之后(它的尺寸是由一个单一的数字来表示一个半径,但是我把它改成了一个代表宽度,高度的Coord ;如果我永远让玩家对象成为非圆形)。

令人惊奇的是,上面的代码编译并运行,尽pipe第二个字段是错误的types。

我首先想到,也许我打开了不同版本的文件,但任何文件的任何更改都反映在编译的程序中。

接下来我想可能是因为某些原因, startPlayer没有被使用。 尽pipe如此,注释掉startPlayer产生编译器错误,甚至更奇怪的是,在startPlayer更改10会导致相应的响应(更改Player的开始大小)。 再次,尽pipe它是错误的types。 为了确保正确读取数据定义,我在文件中插入了一个错字,它给了我一个错误; 所以我正在看正确的文件。

我尝试将上面的2个代码片段粘贴到自己的文件中,并且抛出了startPlayerPlayer的第二个字段不正确的错误。

什么可能允许这发生? 你会认为这是Haskell的types检查器应该防止的事情。

这可能编译的唯一方法是如果存在一个Num (Float,Float)实例。 这不是由标准库提供的,尽pipe可能出于某种疯狂原因使用了其中一个库。 尝试在ghci中加载你的项目,看看10 :: (Float,Float)工作,然后尝试:i Num要找出实例来自哪里,然后大叫谁定义它。

附录:无法closures实例。 甚至没有办法从模块中导出它们。 如果这是可能的话,会导致令人混淆的代码。 这里唯一真正的解决scheme是不定义这样的实例。

Haskell的types检查器是合理的。 问题是,你正在使用的图书馆的作者做了一些…不那么合理。

简短的回答是:如果有一个实例Num (Float, Float) ,则10 :: (Float, Float)是完全有效的。 从编译器或语言的angular度来看,没有什么“非常错误的”。 这与数字文字所做的直觉不符。 既然你已经习惯了types系统捕捉你犯的那种错误,你有理由感到惊讶和失望!

Num实例和fromInteger问题

你很惊讶,编译器接受10 :: Coord ,即10 :: (Float, Float) 。 假设像10这样的数字文字被推断为具有“数字”types是合理的。 开箱即可,数字文字可以被解释为IntIntegerFloatDouble 。 没有其他上下文的数字元组在数字方面看起来不像数字。 我们不是在谈论Complex

幸运的是,不幸的是,Haskell是一种非常灵活的语言。 该标准指定像10这样的整数文字将被解释为fromInteger 10 ,其types为Num a => a 。 所以10可以推断为任何types的Num实例为它写的。 我在另一个答案中更详细地解释了这一点。

所以当你发布你的问题时,一个经验丰富的Haskeller马上发现,要接受10 :: (Float, Float) ,必须有一个像Num a => Num (a, a)Num (Float, Float)的实例。 Prelude没有这样的实例,所以它一定是在其他地方定义的。 使用:i Num ,你很快发现它来自哪里: gloss包。

键入同义词和孤立实例

但是等一下。 在这个例子中你没有使用任何glosstypes; 为什么gloss实例会影响你? 答案分两步走。

首先, 使用关键字type引入的types同义词不会创build新的types 。 在你的模块中,编写Coord只是简单的(Float, Float) 。 同样在Graphics.Gloss.Data.PointPoint表示(Float, Float) 。 换句话说,你的Coordgloss Point是字面上相同的。

所以当gloss维护者select写instance Num Point where ... ,他们也使得你的Coordtypes成为Num一个实例。 这相当于instance Num (Float, Float) where ...instance Num Coord where ...

(默认情况下,Haskell不允许types同义词是类实例, gloss作者必须启用一对语言扩展, TypeSynonymInstancesFlexibleInstances来写实例。)

其次,这是令人惊讶的,因为它是一个孤儿实例 ,即一个实例声明instance CA ,其中CA都在其他模块中定义。 这里特别隐晦,因为涉及的每个部分,即Num(,)Float ,都来自Prelude并且很可能在任何地方。

你的期望是Num是在Prelude定义的,元组和Float是在Prelude中定义的,所以在Prelude中定义了这三件事情的一切。 为什么导入一个完全不同的模块会改变什么? 理想的情况是不会的,但孤儿实例打破了这种直觉。

(请注意,GHC会对孤立实例发出警告 – gloss的作者会明确否定该警告,这应该会引起红旗,并在文档中至less提示警告。)

类实例是全局的,不能被隐藏

此外,类实例是全局的 :在任何模块中定义的任何实例都可以从模块中传递导入,并且在进行实例parsing时可以在types检查器中使用。 这使得全局推理变得方便,因为我们可以(通常)假定像(+)这样的类函数对于给定的types总是相同的。 但是,这也意味着地方决策具有全球效应; 定义一个类实例将不可避免地改变下游代码的上下文,而无法将其隐藏或隐藏在模块边界之后。

您不能使用导入列表来避免导入实例 。 同样,你也不能避免从你定义的模块中导出实例。

这是一个Haskell语言devise中有问题和讨论的领域。 关于这个reddit线程中的相关问题有一个迷人的讨论。 例如,参见Edward Kmett关于允许对实例进行可视性控制的评论:“你基本上抛弃了我写的几乎所有代码的正确性。”

(顺便说一下,正如这个答案所表明的那样 ,通过使用孤立实例你可以在某些方面打破全局实例的假设!)

为图书馆实施者做些什么

在实施Num之前请三思。 你不能解决fromInteger问题 – 不,定义fromInteger = error "not implemented"不会使它更好。 你的用户会感到困惑或惊讶,或者更糟糕的是,从来没有注意到,如果他们的整数文字意外推断有你正在实例化的types? 是提供(*)(+)关键 – 特别是如果你必须破解它?

考虑使用像Conal Elliott的vector-space (typestypes* )或Edward Kmett的linear (typestypes* -> * )类库中定义的替代算术运算符。 这是我倾向于自己做的。

使用 – -Wall 。 不要实现孤立实例,也不要禁用孤立实例警告。

或者,遵循linear和许多其他良好行为的库,并在以.OrphanInstances.Instances结尾的单独模块中提供孤立实例。 不要从任何其他模块导入该模块 。 然后,用户可以根据需要明确导入孤儿。

如果你发现自己定义孤儿,考虑要求上游维护者实施它们,如果可能和适当的话。 我曾经经常写孤立实例Show a => Show (Identity a) ,直到他们把它添加到transformers 。 我甚至可能提出了一个关于它的错误报告。 我不记得了

为图书馆消费者做些什么

你没有太多的select。 向图书馆维护人员提出礼貌和build设性的要求! 指出他们这个问题。 他们可能有一些特殊的理由来写这个有问题的孤儿,或者他们可能没有意识到。

更广泛地说:请注意这种可能性。 这是Haskell真正的全球效应的less数几个领域之一; 您必须检查导入的每个模块以及这些模块导入的每个模块,都不会执行孤立实例。 types注释有时可能会提示您遇到问题,当然您可以使用:i在GHCi中进行检查。

如果足够重要,定义自己的新type而不是type同义词。 你可以肯定没有人会惹他们。

如果您经常遇到来自开源库的问题,那么您当然可以制作自己的库版本,但是维护很快就会变得令人头疼。