Ed Kmett’s reddit comment full of biting commentary on the troubles
of attempting functional programming in
Scala
remains the most concise listing of such problems, and remains mostly
up-to-date over four years after it was written. It covers problems
from the now famous to the less well known. It’s still a useful guide
to what needs to change for Scala to be a great functional programming
language, or conversely, why a functional programmer might want to
avoid Scala.
But not everything is the same as when it was written. Some things
have gotten better. Some can even be eliminated from the list
safely. Some haven’t changed at all.
I’d like to go through each of Kmett’s bullet points, one by one, and
elaborate on what has happened in the ensuing four years since he
posted this comment.
[1:] If you take any two of the random extensions that have been
thrown into scala and try to use them together, they typically don't
play nice. e.g. Implicits and subtyping don't play nice together.
This hasn’t really changed. Paul Phillips’s age-old
“contrarivariance”
thread
about the specific example Kmett uses here might as well have been
written yesterday.
On a positive note, what is good hasn’t really changed, either. The
type soundness of new features still cannot be justified merely
because you can’t think of any ways programs would go wrong were your
idea implemented; you still need positive evidence that your idea
preserves soundness. This is more than can be said for, say,
TypeScript.
On the other hand, we’ve seen a lot of attempts to “solve” these kinds
of feature-compositionality problems by claims like “we don’t want you
to write that kind of code in Scala”. New features like AnyVal
subclasses are still made with the concerns of ill-typed, imperative
programming placed above the concerns of well-typed, functional
programming. Proposals like ADT syntax are likely to support only
those GADT features deemed
interesting
for implementing the standard library, rather than what application
programs might find useful.
[2:] Type inference works right up until you write anything that
needs it. If you go to write any sort of tricky recursive function,
you know, where inference would be useful, then it stops working.
Still 100% true.
[3:] Due to type erasure, its easy to refine a type in a case
expression / pattern match to get something that is a lie.
I’m not sure why Ed wrote “due to type erasure” here, but the
underlying problems are there. This comment came after the
introduction of “virtpatmat”, which improved things in a lot of ways,
not least with the improved support for GADTs. I’ve noticed some
things get better for GADTs in 2.12, too.
But there are numerous unsound things you can do with pattern
matching, some accompanied by compiler warnings, some not. Most of
these are due to its reliance on Object#equals
. Paul Phillips wrote
several bug reports a long time ago about these, and one of the major
ones is fixed: the type consequences of pattern matching used to
think that Object#equals
returning true
implied that the two values were perfect substitutes
for each other. For example, you could use an empty Buffer[A]
and an
empty Buffer[B]
to derive A = B
, even when they’re completely
incompatible types.
This has been fixed, but the very similar problem with matching
constants has not. I
suspect that it will never be fixed unless pattern matching’s use of
equals
is removed entirely.
[4:] Free theorems aren't.
In the base Scala language, nothing has changed here. But we’ve tried
to account for this shortcoming with practice. I wrote an article
elaborating on the free theorems problem in
Scala;
surprise surprise, Object#equals
makes another villainous
appearance. Tony Morris popularized the “Scalazzi safe Scala
subset” through his “Parametricity: Types
are Documentation” talk, and since then
“Scalazzi” has become the shorthand for this style of Scala
programming. (If you’ve heard “Scalazzi” before, this is what it’s
about: free theorems.) Tools like
Wartremover have arisen to mechanically
enforce parts of the Scalazzi rules (among other rules), and they’re
well worth using.
So the situation in the Scala language hasn’t changed at all. The
situation in Scala practice has gotten better, as long as you’re
aware of it and compensating in your projects with tools like
Wartremover.
Collections and covariant things
[5:] Since you can pass any dictionary anywhere to any implicit you
can't rely on the canonicity of anything. If you make a Map or Set
using an ordering, you can't be sure you'll get the same ordering
back when you come to do a lookup later. This means you can't safely
do hedge unions/merges in their containers. It also means that much
of scalaz is lying to itself and hoping you'll pass back the same
dictionary every time.
I don’t want to cover this in detail, because Ed’s already gone into
it in his talk “Typeclasses vs the
world”. I’ve also
written
about Scalaz’s “lying to itself” approach (a fair characterization),
and why we think it’s the best possible choice for Scalaz users in
Scala as it’s defined today.
You can think of this as the “coherence vs local instances” argument,
too, and Ed is describing here how Scala fails as a substrate for the
coherence approach. But he’s not saying that, as a result, coherence
is the wrong choice. Since we think that, despite the potential for
error, coherence is still the best choice for a Scala library, that
should tell you what we think about the alternative: that with local
instances, the potential for error is still greater.
So for us, the important question is, what has changed in Scala?
There’s been a “coherence”
proposal, but its
purpose is not to force you to define only coherent instances, nor
even to detect when you have not; instead, it’s to let you assert to
the compiler that you’ve preserved coherence, whether you have or not;
if you’re wrong, scalac simply makes wrong decisions, silently.
This would be very useful for performance, and I will embrace it for
all typeclasses if implemented. It will make many implicit priority hacks unnecessary.
But it wouldn’t address Ed’s concern at all.
[6:] The container types they do have have weird ad hoc
overloadings. e.g. Map is treated as an iterable container of pairs,
but this means you can't write code that is parametric in the
Traversable container type that can do anything sensible. It is one
of those solutions that seems like it might be a nice idea unless
you've had experience programming with more principled classes like
Foldable
/Traversable
.
The design of the current collections library is the one Kmett was
talking about, so nothing has changed in released code. As for the
future collections library, known as “collections-strawman”? The
situation is the same.
[7:] You wind up with code that looks like myMap.map(...).toMap all
over the place due to CanBuildFrom
inference woes.
I’m not sure what Kmett is referring to here, because I’ve been
relying on the correct behavior for a long time, that is, without the
trailing .toMap
. The only thing I can think of would be the
function being passed to map
returning something implicitly
convertible to two-tuple instead of a proper two-tuple, which would
require an extra step to force that conversion to be applied.
Monads and higher kinds
[8:] Monads have to pay for an extra map at the end of any
comprehension, because of the way the for { }
sugar works.
This hasn’t changed at all, but is worth some elaboration. This
behavior makes it so you can’t write “tail-recursive” monadic
functions in the obvious way. As Dan Doel
demonstrated,
this can turn a purely right-associated bind chain, i.e. one that can
be interpreted tail-recursively, into a repeatedly broken chain with
arbitrary left-binds injected into it, thus either crashing the stack
or requiring useless extra frames to be repeatedly shoved onto the
heap.
This is kind of silly, and could be ameliorated if for
wasn’t trying
to be non-monadic. But that’s not going to change.
[9:] You have type lambdas. Yay, right? But now you can't just talk
about Functor (StateT s IO)
. Its Functor[({type F[X] =
StateT[S,IO,X]})#F]
, and you have to hand plumb it to something
like return
, because it basically can't infer any of that, once
you start dealing with transformers ever. The instance isn't
directly in scope. 12.pure[({type F[X] = StateT[S,IO,X]})#F]
isn't
terribly concise. It can't figure out it should use the inference
rule to define the implicit for StateT[S,M,_]
from the one for
M[_]
because of the increased flexibility that nobody uses.
This is probably the best story of the bunch, and possibly the most
well-known of the whole series. This is good for Scala marketing, but
probably not best for the future of Scala FP…
We first got the
kind-projector to
help us write these type lambdas more succinctly. So Kmett’s first
example above can now be written Functor[StateT[S, IO, ?]]
. Not as
nice as the curried Haskell form, but much better.
Eventually, though, Miles Sabin
implemented the
“higher-order unification” feature, often called the “SI-2712 fix”
after the infamous bug.
This feature performs the inference Kmett describes above, and gets
away with it precisely because it ignores “increased flexibility that
nobody uses”.
The situation is not perfect—you have to flip this nonstandard switch,
the resulting language isn’t source-compatible with standard Scala,
and warts like bug 5075
(despite first appearances, this is quite distinct from 2712)
remain—but Scala is in great shape with respect to this problem
compared to where we were at the time of Kmett’s original writing.
[10:] In this mindset and in the same vein as the CanBuildFrom
issue, things like Either
don't have the biased flatMap
you'd
expect, somehow encouraging you to use other tools, just in case you
wanted to bind on the Left
. So you don't write generic monadic
code over the Either
monad, but rather are constantly chaining
foo.right.flatMap(... .right.flatMap(....))
ensuring you can't use
the sugar without turning to something like scalaz
to fill it
in. Basically almost the entire original motivation for all the type
lambda craziness came down to being able to write classes like
Functor have have several instances for different arguments, but
because they are so hard to use nobody does it, making the feature
hardly pay its way, as it makes things like unification, and path
dependent type checking harder and sometimes impossible, but the
language specification requires them to do it!
I’m not sure the situation was ever as severe as Kmett states, but
that might be down to my personal experience in Scala, with Scalaz as
my permanent companion.
The interspersed .right
s never prevented you from using the for
syntax, though they did make it significantly more
obscure. Supposing foo
and bar
are Either
s:
for {
x <- foo.right
y <- bar.right
...
That trailing .right
looks like it’s missing a dance partner, but
it’s in just the right place for that biased flatMap
or map
method
to kick in.
But in Scalaz, we never had to worry about it. Because we only
supplied the right-biased Monad
for Either
. When you also bring in
Scalaz’s Monad
syntax, suddenly Either
acquires the standard
right-biased map
and flatMap
.
import scalaz.syntax.bind._, scalaz.std.either._
for {
x <- foo
y <- bar
...
No more lonely dancers.
But now right-biasing has returned to the standard
library, so even these
extra imports are no longer necessary.
Kmett pairs this point with a tangentially related point about
functors over other type parameters. But I think higher-order
unification is going to solve this problem, albeit in a very ad hoc
way, in the long run. Programmers who want to use higher-kinded types
will increasingly want to turn on the feature, or even be forced to by
library designs that depend on it. Types that conform to
right-bias—placing the functor parameter last, not first—will find
happy users with nice inference.
class FA[F[_], A]
def fa[F[_], A](fa: F[A]): FA[F, A] =
new FA
scala> fa(Left(33): Either[Int, String])
res0: FA[[+B]Either[Int,B],String] = FA@542c2bc8
This works even in more elaborate situations, such as with monad
transformers:
trait EitherT[E, M[_], A]
trait ReaderT[R, F[_], A]
trait IO[A]
class Discovery[T1[_[_], _], T2[_[_], _], M[_], A]
def discover[T1[_[_], _], T2[_[_], _], M[_], A](a: Option[T1[T2[M, ?], A]])
: Discovery[T1, T2, M, A] = new Discovery
scala> discover(None: Option[EitherT[String, ReaderT[Int, IO, ?], ClassLoader]])
res0: Discovery[[M[_], A]EitherT[String,M,A],
[F[_], A]ReaderT[Int,F,A],
IO,
ClassLoader] = Discovery@4f20ea29
Contrarian types that don’t conform will find themselves rejected for
constantly introducing mysterious type mismatches that must be
corrected with more explicit type lambdas. So the libraries should
develop.
[11:] You don't have any notion of a kind system and can only talk
about fully saturated types, monad transformers are hell to
write. It is easier for me to use the fact that every Comonad
gives rise to a monad transformer to intuitively describe how to
manually plumb a semimonoidal Comonad
through my parser to carry
extra state than to work with a monad transformer!
This isn’t so much about inference of higher-kinded type parameters,
which I’ve dealt with above, but how convenient it is to write them
down.
As mentioned above, the kind-projector compiler plugin has made
writing these types significantly easier. Yet it remains ugly
compared to the curried version, for sure.
[12:] I've been able to get the compiler to build classes that it
thinks are fully instantiated, but which still have abstract methods
in them.
I haven’t seen this kind of thing in quite a while, but it wouldn’t
surprise me if a few such bugs were still outstanding. Let’s give the
compiler the benefit of the doubt and suppose that things have gotten
significantly better in this area.
[13:] Tail-call optimization is only performed for self-tail calls,
where you do not do polymorphic recursion.
There are two issues packed here. The first still holds: only
self-tail calls are supported. Plenty of ink has been expended
elsewhere; I point to Dan Doel
again for some of
that.
The second issue has a fix in Scala
2.12.4!
@annotation.tailrec def lp[A](n: Int): Int =
if (n <= 0) n else lp[Option[A]](n - 1)
// [in 2.12.3] error:⇑ could not optimize @tailrec annotated method lp:
// it is called recursively with different type arguments
scala> lp[Unit](1000000)
res0: Int = 0
To pour a little oil on, this isn’t a 50% fix; this is a nice
improvement, dealing with a particular annoyance in interpreting GADT
action graphs, but the much larger issue is the still-missing general
TCO.
[14:] Monads are toys due to the aforementioned restriction. (>>=)
is called flatMap
. Any chain of monadic binds is going to be a
series of non-self tailcalls. A function calls flatMap which calls a
function, which calls flatMap... This means that non-trivial
operations in even the identity monad, like using a Haskell style
traverse
for a monad over an arbitrary container blows the stack
after a few thousand entries.
And this is the same, for the same reason. Kmett goes on to discuss
the “solutions” to this.
[15:] We can fix this, and have in scalaz
by adapting apfelmus
'
operational monad to get a trampoline that moves us off the stack to
the heap, hiding the problem, but at a 50x slowdown, as the JIT no
longer knows how to help.
Nothing has changed here. We’ve tweaked the trampoline representation
repeatedly to get better
averages, but the costs still hold.
[16:] We can also fix it by passing imperative state around, and
maybe getting scala to pass the state for me using implicits and
hoping I don't accidentally use a lazy val
. Guess which one is the
only viable solution I know at scale? The code winds up less than
1/2 the size and 3x faster than the identity monad version. If scala
was the only language I had to think in, I'd think functional
programming was a bad idea that didn't scale, too.
This is still something you have to do sometimes. Just as above,
nothing has really changed here. You just have to hope you don’t run
into it too often.
Random restrictions
[17:] for
yield
sugar is a very simple expansion, but that means
it has all sorts of rules about what you can't define locally inside
of it, e.g. you can't stop and def
a function, lazy val
,
etc. without nesting another for
yield
block.
One thing has changed in this area! You no longer have to use the
val
keyword when defining a val locally in the for
block.
Otherwise, situation constant.
[18:] You wind up with issues like
SI-3295 where out of a
desire to not "confuse the computation model", it was decided that
it was better to you know, just crash when someone folded a
reasonably large list than fix the issue.. until it finally affected
scalac
itself. I've been told this has been relatively recently
fixed.
As Kmett mentions, this was fixed. It remains fixed.
[19:] No first-class universal quantification means that quantifier
tricks like ST s
, or automatic differentiation without
infinitesimal confusion are basically impossible.
def test = diff(new FF[Id,Id,Double] {
def apply[S[_]](x: AD[S, Double])(implicit mode: Mode[S, Double]): AD[S, Double]
= cos(x)
})
is a poor substitute for
test = diff cos
kind-projector has
provided
less well-known support for some varieties of polymorphic lambdas,
such as FF
in this example, for a while. The implicit
constraint
and fact that we’re trying to be polymorphic over a higher-kinded type
might make things tricky, but let’s see if we can get it working.
Lambda[FF[Id, Id, Double]](x => cos(x))
Lambda[FF[Id, Id, Double]](x => implicit mode => cos(x))
// both forms fail with the uninteresting error:
// not found: value Lambda
Scalaz 8 contains a very clever unboxed encoding of universal
quantification based on
the observation that if side effects and singleton type patterns are
forbidden, as they are under Scalazzi rules, multiple type
applications in Scala are indistinguishable at runtime. (To see why
this is, consider the difference between List.empty[A]
and
mutable.Buffer.empty[A]
.) The one that comes with Scalaz 8 only
quantifies over a *
-kinded type parameter, but we should be able to
use the same technique to quantify over S: * -> *
.
trait ForallK1Module {
type ForallK1[F[_[_]]]
type ∀[F[_[_]]] = ForallK1[F]
def specialize[F[_[_]], A[_]](f: ∀[F]): F[A]
def of[F[_[_]]]: MkForallK1[F]
sealed trait MkForallK1[F[_[_]]] extends Any {
type T[_]
def apply(ft: F[T]): ∀[F]
}
}
object ForallK1Module {
val ForallK1: ForallK1Module = new ForallK1Module {
type ForallK1[F[_[_]]] = F[λ[α => Any]]
def specialize[F[_[_]], A[_]](f: ∀[F]): F[A] = f.asInstanceOf[F[A]]
def of[F[_[_]]]: MkForallK1[F] = new MkForallK1[F] {
type T[_] = Any
def apply(ft: F[T]): ∀[F] = ft
}
}
}
// we're using an unboxed representation
type FF[F[_], G[_], T, S[_]] = AD[S, T] => Mode[S, T] => AD[S, T]
scala> ForallK1.of[Lambda[S[_] => FF[Id, Id, Double, S]]](
x => implicit m => cos(x))
res3: ForallK1Module.ForallK1.ForallK1[
[S[_$1]]AD[S,Double] => (Mode[S,Double] => AD[S,Double])
] = $$Lambda$2018/266706504@91f8cde
Upshot? Nothing has changed in core Scala. People in the Scala
community have discovered some clever tricks, which work even better
than on the slightly complicated test case Kmett supplied when tried
with more traditional *
-kinded rank-2 idioms like ST
.
scala> Lambda[List ~> Option](_.headOption)
res2: List ~> Option = $anon$1@73c4d4b5
trait ST[S, A] {
def flatMap[B](f: A => ST[S, B]): ST[S, B]
}
trait STVar[S, A] {
def read: ST[S, A]
}
def newVar[S, A](a: A): ST[S, STVar[S, A]] = ???
def mkAndRead[S]: ST[S, Int] = newVar[S, Int](33) flatMap (_.read)
def runST[A](st: Forall[ST[?, A]]): A = ???
scala> :t Forall.of[ST[?, Int]](mkAndRead)
scalaz.data.Forall.Forall[[α$0$]ST[α$0$,Int]]
scala> :t Forall.of[Lambda[s => ST[s, STVar[s, Int]]]](newVar(33))
scalaz.data.Forall.Forall[[s]ST[s,STVar[s,Int]]]
scala> :t runST(Forall.of[ST[?, Int]](mkAndRead))
Int
scala> :t runST(Forall.of[Lambda[s => ST[s, STVar[s, Int]]]](newVar(33)))
<console>:19: error: type mismatch;
found : Forall[[s(in type Λ$)]
ST[s(in type Λ$),
STVar[s(in type Λ$),Int]]]
required: Forall[[α$0$(in type Λ$)]
ST[α$0$(in type Λ$),
STVar[_ >: (some other)s(in type Λ$) with (some other)α$0$(in type Λ$), Int]]]
Knowledgable use of these tricks will give you much better code than
we could produce when Kmett wrote this, but it’s still nowhere near as
elegant or easy-to-use as rank-2 in Haskell.
... but it runs on the JVM.
Indeed, Scala still runs on the JVM.
So, a few things have gotten better, and a few things have gotten a
lot better. That bodes well, anyway.
Functional programming practice in Scala will continue to encounter
these issues for the foreseeable future. If you are writing Scala, you
should be practicing functional programming; the reliability
benefits are worth the price of entry. While you’re doing so, however,
it’s no thoughtcrime to occasionally feel like it’s a bad idea that
doesn’t scale.
This article was tested with Scala 2.12.4 -Ypartial-unification
,
Scalaz 8 3011709ba, and kind-projector 0.9.4.
Portions Copyright © 2013 Edward Kmett, used with permission.
Copyright © 2017, 2018 Stephen Compall. This work is licensed under a
Creative Commons Attribution 4.0 International
License.