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.
Given a closed set (fixed number of elements) S
with elements {a..z}
and a binary operator *
:
There is a single identity element i
such that:
forall x in S: i * x = x = x * i
The operator is associative such that:
forall a, b, c in S: a * (b * c) = (a * b) * c
You have a monoid.
Now given any monoid you can define a binary function f
as:
f(i, x) = x
f(x, _) = x
What this means is that for the example of the Maybe
monoid (Nothing
is the identity element denoted above as i
):
f(Nothing, Just 5) = Just 5
f(Just 5, Nothing) = Just 5
f(Just 5, Just 10) = Just 5
f(Nothing, f(Nothing, Just 5)) = Just 5
f(Nothing, f(Just 5, Nothing)) = Just 5
Surprisingly, I can't find this precise function in the default libraries, which is likely due to my own inexperience. If anyone else can volunteer this, I would sincerely appreciate it.
Here's the implementation I deduced off hand from the above example:
(<||>) :: (Monoid a, Eq a) => a -> a -> a
x <||> y
| x == mempty = y
| True = x
Example:
λ> [] <||> [1,2] <||> [3,4]
[1,2]
λ> Just "foo" <||> Nothing <||> Just "bar"
Just "foo"
λ> Nothing <||> Just "foo" <||> Just "bar"
Just "foo"
λ>
Then if you want to use a list of functions as input...
tryFunctions x funcs = foldl1 (<||>) $ map ($ x) funcs
example:
instance Monoid Bool where
mempty = False
mconcat = or
mappend = (||)
λ> tryFunctions 8 [odd, even]
True
λ> tryFunctions 8 [odd, odd]
False
λ> tryFunctions 8 [odd, odd, even]
True
λ>
Best Answer
Alright, first rule of error handling in Haskell: Never use
error
.It's just terrible in every way. It exists purely as an act of history and the fact that Prelude uses it is terrible. Don't use it.
The only conceivable time you could use it is when something is so internally terrible that something must be wrong with the very fabric of reality, thus rendering the outcome of your program moot.
Now the question becomes
Maybe
vsEither
.Maybe
is nicely suited to something likehead
, which may or may not return a value but there is only one possible reason to fail.Nothing
is saying something like "it broke, and you already know why". Some would say it indicates a partial function.The most robust form of error handling is
Either
+ an error ADT.For example in one of my hobby compilers, I have something like
Now I define a bunch of error types, nesting them so that I have one glorious top level error. This can be an error from any stage of compilation or an
ImpossibleError
, which signifies a compiler bug.Each of these error types try to keep as much information for as long as possible for pretty printing or other analysis. More importantly, by not having a string I can test that running an illtyped program through the type checker actually generates a unification error! Once something is a
String
, it's gone forever and any information it contained is opaque to the compiler/tests, so justEither String
isn't great either.Finally I pack this type into
ExceptT
, a new monad transformer from MTL. This is essentiallyEitherT
and comes with a nice batch of functions for throwing and catching errors in a pure, pleasant way.Finally, it's worth mentioning that Haskell has the mechanisms to support handling exceptions like other languages do, except that catching an exception lives in
IO
. I know some people like to use these forIO
heavy applications where everything could potentially fail, but so infrequently that they don't like to think about it. Whether you use these impure exceptions or justExceptT Error IO
is really a matter of taste. Personally I opt forExceptT
because I like being reminded of the chance of failure.As a summary,
Maybe
- I can fail in one obvious wayEither CustomType
- I can fail, and I'll tell you what happenedIO
+ exceptions - I sometimes fail. Check my docs to see what I throw when I doerror
- I hate you too user