30

One thing that I've never quite come to terms with in Haskell is how you can have polymorphic constants and functions whose return type cannot be determined by their input type, like

class Foo a where
    foo::Int -> a

Some of the reasons that I do not like this:

Referential transparency:

"In Haskell, given the same input, a function will always return the same output", but is that really true? read "3" return 3 when used in an Int context, but throws an error when used in a, say, (Int,Int) context. Yes, you can argue that read is also taking a type parameter, but the implicitness of the type parameter makes it lose some of its beauty in my opinion.

Monomorphism restriction:

One of the most annoying things about Haskell. Correct me if I'm wrong, but the whole reason for the MR is that computation that looks shared might not be because the type parameter is implicit.

Type defaulting:

Again one of the most annoying things about Haskell. Happens e.g. if you pass the result of functions polymorphic in their output to functions polymorphic in their input. Again, correct me if I'm wrong, but this would not be necessary without functions whose return type cannot be determined by their input type (and polymorphic constants).

So my question is (running the risk of being stamped as a "discussion quesion"): Would it be possible to create a Haskell-like language where the type checker disallows these kinds of definitions? If so, what would be the benefits/disadvantages of that restriction?

I can see some immediate problems:

If, say, 2 only had the type Integer, 2/3 wouldn't type check anymore with the current definition of /. But in this case, I think type classes with functional dependencies could come to the rescue (yes, I know that this is an extension). Furthermore, I think it is a lot more intuitive to have functions that can take different input types, than to have functions that are restricted in their input types, but we just pass polymorphic values to them.

The typing of values like [] and Nothing seems to me like a tougher nut to crack. I haven't thought of a good way to handle them.

0

4 Answers 4

38

I actually think that return type polymorphism is one of the best features of type classes. After having used it for a while, it is sometimes hard for me to go back to OOP style modeling where I don't have it.

Consider the encoding of algebra. In Haskell we have a type class Monoid (ignoring mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

How could we encode this as an interface in an OO language? The short answer is we can't. That's because the type of mempty is (Monoid a) => a aka, return type polymorphism. Having the ability to model algebra is incredibly useful IMO.*

You start your post with the complaint about "Referential Transparency." This raises an important point: Haskell is a value oriented language. So expressions like read 3 don't have to be understood as things that compute values, they can also be understood as values. What this means is that the real issue is not return type polymorphism: it is values with polymorphic type ([] and Nothing). If the language should have these, then it really has to have polymorphic return types for consistency.

Should we be able to say [] is of type forall a. [a]? I think so. These features are very useful, and they make the language much simpler.

If Haskell had subtype polymorphism [] could be a subtype for all [a]. The problem is, that I don't know of a way of encoding that without having the type of the empty list be polymorphic. Consider how it would be done in Scala (it is shorter than doing it in the canonical statically typed OOP language, Java)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Even here, Nil() is an object of type Nil[A] **

Another advantage of return type polymorphism is that it makes the Curry-Howard embedding much simpler.

Consider the following logical theorems:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

We can trivially capture these as theorems in Haskell:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

To sum up: I like return type polymorphism, and only think it breaks referential transparency if you have a limited notion of values (although this is less compelling in the ad hoc type class case). On the other hand, I do find your points about MR and type defaulting compelling.


*. In the comments ysdx points out this isn't strictly true: we could re-implement type classes by modeling the algebra as another type. Like the java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

You then have to pass objects of this type around with you. Scala has a notion of implicit parameters which avoids some, but in my experience not all, of the overhead of explicitly managing these things. Putting your utility methods (factory methods, binary methods, etc) on a separate F-bounded type turns out to be an incredibly nice way of managing things in an OO language that has support for generics. That said, I'm not sure I would have groked this pattern if I didn't have experience modeling things with typeclasses, and I'm not sure other people will.

It also has limitations, out of the box there is no way to get an object that implements the typeclass for an arbitrary type. You have to either pass the values explicitly, use something like Scala's implicits, or use some form of dependency injection technology. Life gets ugly. On the other hand, it is nice that you can have multiple implementations for the same type. Something can be a Monoid in multiple ways. Also, carrying around these structures separately has IMO a more mathematically modern, constructive, feel to it. So, although I still generally prefer the Haskell way of doing this, I probably overstated my case.

Typeclasses with return type polymorphism makes this kind of thing easy to handle. That doesn't meen it is the best way to do it.

**. Jörg W Mittag points out this isn't really the canonical way of doing this in Scala. Instead, we would follow the standard library with something more like:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

This takes advantage of Scala's support for bottom types, as well as covariant type paramaters. So, Nil is of type Nil not Nil[A]. At this point we are pretty far from Haskell, but it is interesting to note how Haskell represents the bottom type

undefined :: forall a. a

That is, it isn't the subtype of all types, it is polymorphically(sp) a member of all types.
Yet more return type polymorphism.

6
  • 4
    "How could we encode this as an interface in an OO language?" You could use first-class algebra: interface Monoid<X> { X empty(); X append(X, X); } However you need to pass it explicitly (this is done behind the scene in Haskell/GHC).
    – ysdx
    Sep 3, 2011 at 23:23
  • @ysdx Thats true. And in languages that support implicit parameters you get something very close to haskell's type classes (like in Scala). I was aware of that. My point though was that this makes life pretty difficult: I find myself having to use containers that store the "typeclass" all over the place (yuck!). Still, I probably should have been less hyperbolic in my answer.
    – Philip JF
    Sep 4, 2011 at 2:28
  • +1, awesome answer. One irrelevant nitpick, though: Nil should probably be a case object, not a case class. Sep 4, 2011 at 4:14
  • @Jörg W Mittag It's not totally irrelevant. I've edited to adress your comment.
    – Philip JF
    Sep 4, 2011 at 5:31
  • 1
    Thank you for a very nice answer. It will probably take me a bit of time to digest/understand it.
    – dainichi
    Sep 4, 2011 at 15:54
13

Just to note a misconception:

"In Haskell, given the same input, a function will always return the same output", but is that really true? read "3" return 3 when used in an Int context, but throws an error when used in a, say, (Int,Int) context.

Just because my wife drives a Subaru and I drive a Subaru does not mean we drive the same car. Just because there are 2 functions named read does not mean it's the same function. Indeed read :: String -> Int is defined in the Read instance of Int, where read :: String (Int, Int) is defined in the Read instance of (Int, Int). Hence, they are completely different functions.

This phenomenon is common in programming languages and is usually called overloading.

10
  • 7
    I think you kind of missed the point of the question. In most languages that have ad-hoc polymorphism, aka overloading, the selection of which function to call depends only on the parameter types, not on the return type. This makes it easier to reason about the meaning of function calls: simply start at the leaves of the expression tree, and work your way "upwards". In Haskell (and the small number of other languages that support return-type overloading) you potentially have to consider the entire expression tree at once, even to figure out the meaning of a tiny subexpression. Jun 12, 2012 at 1:28
  • 1
    I think you hit the crux of the question perfectly. Even Shakespeare said, "A function by any other name would function just as well." +1 Oct 28, 2013 at 21:23
  • @Laurence Gonsalves - Type inference in Haskell isn't referentially transparent. The meaning of code can depend on the context where it's used due to type inference pulling information inward. That's not limited to return-type issues. Haskell effectively has Prolog built into its type system. This can make code less clear, but has big advantages too. Personally, I think the kind of referential transparency that matters most is what happens at run-time, because laziness would be impossible to deal with without it.
    – user8709
    Oct 29, 2013 at 0:55
  • @Steve314 I guess I have yet to see a situation where the lack of referentially transparent type inference doesn't make the code less clear. To reason about anything of complexity one needs to be able to mentally "chunk" things. If you tell me you have a cat, I don't think about a cloud of billions of atoms or individual cells. Likewise, when reading code I partition expressions into their sub-expressions. Haskell defeats this in two ways: "wrong-way" type inference, and overly complex operator overloading. The Haskell community also has an aversion to parens, making the latter even worse. Oct 29, 2013 at 19:11
  • 1
    @LaurenceGonsalves You are right that the infix feature can be abused. But this is a failure of the users, then. OTOH, restrictiveness, like in Java, is IMHO not the right way. To see this, look no further than some code that deals with BigIntegers.
    – Ingo
    Oct 29, 2013 at 21:22
8

I really wish the term "return type polymorphism" was never created. It encourages a misunderstanding of what is happening. Suffice it to say, while eliminating "return type polymorphism" would be an extremely ad-hoc and expressiveness killing change, it wouldn't even remotely resolve the issues brought up in the question.

The return type is in no way special. Consider:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

This code causes all the same issues as "return type polymorphism" and demonstrates the same kinds of differences from OOP as well. No one talks about "first argument Maybe type polymorphism" though.

The key idea is that the implementation is using types to resolve which instance to use. The (run-time) values of any sort have nothing to do with it. Indeed it would work even for types that have no values. In particular, Haskell programs have no meaning without their types. (Ironically, this makes Haskell a Church-style language as opposed to a Curry-style language. I have a blog article in the works elaborating on this.)

3
  • 1
    'This code causes all the same issues as "return type polymorphism"'. No it doesn't. I can look at "foo Nothing" and determine what its type is. It's a Bool. No looking at the context is needed.
    – dainichi
    Jan 18, 2016 at 2:39
  • 5
    Actually, the code doesn't type check because the compiler doesn't know what a is just like in the "return type" case. Again, there is nothing special about return types. We need to know the type of all subexpressions. Consider let x = Nothing in if foo x then fromJust x else error "No foo". Jan 18, 2016 at 3:29
  • 3
    Not to mention "second argument polymorphism"; a function like Int -> a -> Bool is, by currying, actually Int -> (a -> Bool) and there you go, polymorphism in the return value again. If you allow it anywhere it has to be everywhere.
    – Ryan Reich
    Apr 4, 2019 at 3:15
4

Concerning your question about referential transparency on polymorphic values, here's something that may help.

Consider the name 1. It often denotes different (but fixed) objects:

  • 1 as in an Integer
  • 1 as in a Real
  • 1 as in a square identity matrix
  • 1 as in an identity function

Here it is important to note that under each context, 1 is a fixed value. In other words, each name-context pair denotes a unique object. Without the context, we cannot know which object we are denoting. Thus the context has to be inferred (if possible) or explicitly provided. A nice benefit (aside from convenient notation) is the ability to express code in generic contexts.

But since this is just notation, we could have used the following notation instead:

  • integer1 as in an Integer
  • real1 as in a Real
  • matrixIdentity1 as in a square identity matrix
  • functionIdentity1 as in an identity function

Here, we gain names that are explicit. This allows us to derive the context just from the name. Thus only the name of the object is needed to fully denote an object.

Haskell type classes elected the former notation scheme. Now here's the light at the end of the tunnel:

read is a name, not a value (it has no context), but read :: String -> a is a value (it has both a name and a context). Thus the functions read :: String -> Int and read :: String -> (Int, Int) are literally different functions. Thus if they disagree on their inputs, referential transparency is not broken.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.