类型检查器允许非常错误的类型替换,而程序仍在编译

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

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

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

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

这是因为我添加和更改了播放器的字段,忘记了更新 startPlayer(它的尺寸由一个数字来表示半径,但是我把它改为 Coord来表示(宽度,高度) ; 以防我把播放器对象变成非圆形)。

令人惊奇的是,上面的代码编译并运行,尽管第二个字段的类型不对。

我最初以为可能打开了不同版本的文件,但是对任何文件的任何更改都反映在编译后的程序中。

接下来我想也许 startPlayer因为某种原因没有被使用。注释掉 startPlayer会导致编译器错误,更奇怪的是,在 startPlayer中更改 10会导致适当的响应(更改 Player的起始大小) ; 同样,尽管它的类型不对。为了确保它正确地读取数据定义,我在文件中插入了一个输入错误,它给了我一个错误; 因此我正在查看正确的文件。

我尝试将上面的2个代码片段粘贴到它们自己的文件中,结果出现了预期的错误,即 startPlayerPlayer的第二个字段是不正确的。

怎么可能会发生这种事?你可能会认为这正是 Haskell 的类型检查器应该防止的事情。


我最初的问题的答案是,两个半径相等的圆被画到不同的大小,其中一个半径实际上是负的。

3877 次浏览

The only way this could possibly compile is if there exists a Num (Float,Float) instance. This isn't provided by the standard library, although it is possible that one of the libraries you're using added it for some insane reason. Try loading up your project in ghci and see if 10 :: (Float,Float) works, then try :i Num to find out where the instance is coming from, and then yell at whoever defined it.

Addendum: There is no way to turn off instances. There isn't even a way to not export them from a module. If this were possible, it would lead to even more confusing code. The only real solution here is to not define instances like that.

Haskell's type checker is being reasonable. The problem is that the authors of a library you're using have done something... less reasonable.

The brief answer is: Yes, 10 :: (Float, Float) is perfectly valid if there's an instance Num (Float, Float). There's nothing "very wrong" about it from the compiler's or the language's perspective. It just doesn't square with our intuition about what numeric literals do. Since you're used to the type system catching the sort of error you made, you're justifiably surprised and disappointed!

Num instances and the fromInteger problem

You're surprised that the compiler accepts 10 :: Coord, i.e. 10 :: (Float, Float). It's reasonable to assume that numeric literals like 10 will be inferred to have "numeric" types. Out of the box, numeric literals can be interpreted as Int, Integer, Float, or Double. A tuple of numbers, with no other context, doesn't seem like a number in the way those four types are numbers. We're not talking about Complex.

Fortunately or unfortunately, however, Haskell is a very flexible language. The standard specifies that an integer literal like 10 will be interpreted as fromInteger 10, which has type Num a => a. So 10 could be inferred as any type that's had a Num instance written for it. I explain this in a bit more detail in another answer.

So when you posted your question, an experienced Haskeller immediately spotted that for 10 :: (Float, Float) to be accepted, there must be an instance like Num a => Num (a, a) or Num (Float, Float). There's no such instance in the Prelude, so it must have been defined somewhere else. Using :i Num, you quickly spotted where it came from: the gloss package.

Type synonyms and orphan instances

But hold on a minute. You're not using any gloss types in this example; why did the instance in gloss affect you? The answer comes in two steps.

First, a type synonym introduced with the keyword type does not create a new type. In your module, writing Coord is simply shorthand for (Float, Float). Likewise in Coord0, Point means (Float, Float). In other words, your Coord and gloss's Point are literally equivalent.

So when the gloss maintainers chose to write instance Num Point where ..., they also made your Coord type an instance of Num. That's equivalent to instance Num (Float, Float) where ... or instance Num Coord where ....

(By default, Haskell doesn't allow type synonyms to be class instances. The gloss authors had to enable a pair of language extensions, TypeSynonymInstances and FlexibleInstances, to write the instance.)

Second, this is surprising because it's an orphan instance, i.e. an instance declaration instance C A where both C and A are defined in other modules. Here it's particularly insidious because each part involved, i.e. Num, (,), and Float, comes from the Prelude and is likely to be in scope everywhere.

Your expectation is that Num is defined in Prelude, and tuples and Float are defined in Prelude, so everything about how those three things work is defined in Prelude. Why would importing a completely different module change anything? Ideally it wouldn't, but orphan instances break that intuition.

(Note that GHC warns about orphan instances—the authors of gloss specifically overrode that warning. That should have raised a red flag and prompted at least a warning in the documentation.)

Class instances are global and cannot be hidden

Furthermore, class instances are global: any instance defined in any module that is transitively imported from your module will be in context and available to the typechecker when doing instance resolution. This makes global reasoning convenient, because we can (usually) assume that a class function like (+) will always be the same for a given type. However, it also means that local decisions have global effects; defining a class instance irrevocably changes the context of downstream code, with no way to mask or conceal it behind module boundaries.

You cannot use import lists to avoid importing instances. Similarly, you cannot avoid exporting instances from modules you define.

This is a problematic and much-discussed area of the Haskell language design. There's a fascinating discussion of related issues in this reddit thread. See, for instance, Edward Kmett's comment on allowing visibility control for instances: "You basically throw out the correctness of almost all of the code I have written."

(By the way, as this answer demonstrated, you can break the global-instance assumption in some regards by using orphan instances!)

What to do—for library implementers

Think twice before implementing Num. You cannot work around the fromInteger problem—no, defining fromInteger = error "not implemented" does not make it better. Will your users be confused or surprised—or worse, never notice—if their integer literals are accidentally inferred to have the type you're instantiating? Is providing (*) and (+) that critical—particularly if you have to hack it?

Consider using alternative arithmetical operators defined in a library like Conal Elliott's vector-space (for types of kind *) or Edward Kmett's linear (for types of kind * -> *). This is what I tend to do myself.

Use -Wall. Do not implement orphan instances, and do not disable the orphan instance warning.

Alternately, follow the lead of linear and many other well-behaved libraries, and provide orphan instances in a separate module ending in .OrphanInstances or .Instances. And do not import that module from any other module. Then users can import the orphans explicitly if they would like.

If you find yourself defining orphans, consider asking upstream maintainers to implement them instead, if possible and appropriate. I used to frequently write the orphan instance Show a => Show (Identity a), until they added it to transformers. I may even have raised a bug report about it; I don't remember.

What to do—for library consumers

You don't have many options. Reach out—politely and constructively!—to the library maintainers. Point them to this question. They may have had some special reason to write the problematic orphan, or they may just not realize.

More broadly: Be aware of this possibility. This is one of the few areas of Haskell where there are true global effects; you'd have to check that every module you import, and every module those modules import, doesn't implement orphan instances. Type annotations may sometimes alert you to problems, and of course you can use :i in GHCi to check.

Define your own newtypes instead of type synonyms if it's important enough. You can be pretty sure nobody will mess with them.

If you're having frequent problems deriving from an open-source library, you can of course make your own version of the library, but maintenance can quickly become a headache.