Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

The type checker is allowing a very wrong type replacement, and the program still compiles

Tags:

types

haskell

While trying to debug an issue in my program (2 circles with an equal radius are being drawn to different sizes using Gloss*), I stumbled across a strange situation. In my file that handles objects, I have the following definition for a Player:

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

and in my main file, which imports Objects.hs, I have the following definition:

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

This happened due to me adding and changing fields for player, and forgetting to update startPlayer after (its dimensions were determined by a single number to represent a radius, but I changed it to a Coord to represent (width,height); in case I ever make the player object a non-circle).

The amazing thing is, the above code compiles, and runs, despite the second field being of the wrong type.

I first thought that maybe I had different versions of the files open, but any changes to any files were reflected in the compiled program.

Next I thought that maybe startPlayer wasn't being used for some reason. Commenting out startPlayer yields a compiler error though, and even stranger, changing the 10 in startPlayer causes an appropriate response (changes the starting size of the Player); again, despite it being of the wrong type. To make sure that it's reading the data definition correctly, I inserted a typo into the file, and it gave me an error; so I am looking at the correct file.

I tried pasting the 2 snippets above into their own file, and it spat out the expected error that the second field of Player in startPlayer is incorrect.

What could possibly allow this to happen? You'd think that this is the very thing that Haskell's type checker should prevent.


* The answer to my original problem, two circles of supposedly equal radius being drawn to different sizes, was that one of the radii was actually negative.

like image 811
Carcigenicate Avatar asked Nov 06 '14 01:11

Carcigenicate


2 Answers

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.

like image 90
Cubic Avatar answered Nov 18 '22 07:11

Cubic


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 Graphics.Gloss.Data.Point, 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.

like image 29
Christian Conkle Avatar answered Nov 18 '22 09:11

Christian Conkle