Cats-effect: Sync instance for EitherT[Eval, Throwable, ?]

Created on 22 Mar 2018  Â·  11Comments  Â·  Source: typelevel/cats-effect

As was mentioned in #169, currently there is a Sync instance for EitherT[Eval, Throwable, ?]. However this seems to be inconsistent, since it uses Eval to capture side-effects. (This is the same problem like in #78. Actually it's worse, since here it actually does use Eval in this way, instead of only suggesting it.)

Most helpful comment

@durban nice catch! I had only considered Comonad, not Foldable. In this case it's the same as #169 , and it should definitely be removed.

All 11 comments

The problem with Eval is error handling and its Comonad implementation, setting the expectation that extract should be pure. EitherT[Eval, Throwable, ?] on the other hand can handle errors and is not a Comonad, by the magic of EitherT.

Speaking of #78, lets not forget that that particular decision was made based on the fact that a Sync[Eval] isn't possible. Feeding that decision as input for this one creates a circular argument.

If you don't want to allow Sync[EitherT[Eval, Throwable, ?]] then you need to come up with a law in SyncLaws that excludes it.

Note that if we can't allow instances like Sync[EitherT[Eval, Throwable, ?]] on ideological grounds, we are going to leave users in the dust, to implement it themselves when needed, like what happened with the Kleisli instances. I mean Frameless literally has a document instructing people to define an unlawful Sync instance. And when you end up with such situations, as a library author you have to accept that it's your users that are right and you're the one that's wrong.

The bigger problem is that a type class hierarchy that describes only IO is not that useful, we might as well do a MonadIO to take care of IO + monad transformers and be done with it.

In other words, either you disallow all alternative data types except for IO, or you need to allow Sync[EitherT[Eval, Throwable, ?]], because there's absolutely nothing about it that's wrong except for your dislike for Eval.

I strongly disagree that pure functional programming is an "ideology". Breaking referential transparency has very practical implications.

As a preface, I want to be clear that this isn't my decision to make. :-) I just want to put forth some opinions…

I strongly disagree that pure functional programming is an "ideology". Breaking referential transparency has very practical implications.

While I agree with this fully, we need to take a bit of a step back here. We can't really pretend that there is an objective mathematical reason to admit _.unsafeRunSync : IO[A] => A and not admit _.value.value : EitherT[Eval, Throwable, A] => Either[Throwable, A]. In fact, the latter is actually safer!

What we're talking about here are two things: the existence of Comonad[Eval] and the fact that Eval calls its function value rather than unsafeValue. The latter is most definitely a subjective point, not an objective one. The former is more concerning, IMO, but I think in this case probably fine.

The reason I think this is probably fine is the fact that Comonad[Eval] exists, but Comonad[EitherT[Eval, Throwable, ?]] does not. So users need to explicitly translate between two types in order to go from a Sync-enabled type to a Comonad-enabled type. There's nothing incoherent about that, and while you can make the argument that maybe EitherT's value function should be called unsafeValue in this context, that's still a subjective argument.

I don't really have a horse in this race. I'm fine with removing the instance, and I can certainly see the merits to that position. I'm also a bit unclear how useful the instance is in the first place (I had my doubts even when I added it). But on the flip side, it does feel somewhat pleasing to define explicitly and lawfully how a transformation on Eval may be viewed as an effect type. I just want to be clear that this is a broadly subjective design question. @alexandru is correct: if it were actually objectively harmful, we should be able to define either laws or types that forbid it, and we can't do that in this case (note: we can do it for Eval itself; SyncLaws will in fact fail on that type).

From practical side of things, it is possible to abstract things using F[_]: Sync. EitherT[Eval, Throwable, ?] can be used for unit-testing such things without giving in to unsafe IO methods, while in app itself this is likely to be embedded into F[_]: Async. There are also things like console and desktop apps where you can afford blocking, so having a Sync is enough.

Removing such instance leaves Sync in a weird place w.r.t. what is it actually abstracting over. The only other type I know of now that is a Sync, but not Async is monix.eval.Coeval.

Scalaz 7.x IO is also a sync FWIW. Maybe instead of using Eval directly we could newtype it?

I'm tending towards the opinion that we shouldn't use Eval for effect capture and we should move the EitherT[Eval, Throwable, ?] instance into the test package.

We could imagine some non-canceable type that is simpler than IO, which maybe we could lift into IO, that might be newtype of opaque type IOSync[A] = Eval[Either[Throwable, A]], but not expose the Eval-ness of it.

Newtyping it in this case would be playing a game of pretend and newtyping in Scala is really awkward anyway, I like to avoid it if possible. Maybe opaque types will solve it, but I'm not holding my breath. But before that, we have to pinpoint the problem though and I'm not seeing any.

Haskell indeed has the philosophy that all side effects get described by IO and only IO, with everything else built on top. I don't think we can afford the same philosophy in Scala, or even the Cats ecosystem. Expand that to Java, to include things like Rx's Single and it's clear we've got quite the diversity. cats-effect was built with the explicit goal of leaving the door open to multiple IO-like data types.

My problem with #78 is that the existence of a type class implementation does not change the fundamental nature of a type, as it transpires from its data constructors. There's nothing impure about Eval.always(println("Hello!")). And the fact that newtyping Eval would make it possible to define a MonadError (see https://github.com/typelevel/cats/pull/1558) and thus a Sync implementation for it highlights the fact that Eval itself, the data structure, does suspend side effects.

Of course, I can agree that Comonad[Eval] or Group[Eval[A]] are incompatible with MonadError and thus Sync, see https://github.com/typelevel/cats/issues/1661 and notice that MonadError[Eval, Throwable] was removed due to a problem being pinpointed with the described laws. But EitherT[Eval, Throwable, ?] has no such problem.

In fact EitherT is a way of newtyping Eval via EitherT[Eval, Throwable, ?].

I agree that in this case the arguments against Eval are a lot less strong than in other cases, since here the proposed type EitherT[Eval, Throwable, ?] is perfectly lawful, and you can't break referential transparency in polymorphic code with it (unlike with naked Eval or () => Try[A]).

I think it's rather a matter of consistency: since in most other places Eval is not fit for effect capture, saying "don't use Eval for effect capture" is more consistent, and imho simpler.

What's the concrete advantage of having this instance?

In particular, I didn't understand this point by @oleg-py at all:

EitherT[Eval, Throwable, ?] can be used for unit-testing such things without giving in to unsafe IO methods, while in app itself this is likely to be embedded into F[_]: Async.

My problem with #78 is that the existence of a type class implementation does not change the fundamental nature of a type, as it transpires from its data constructors.

@alexandru This is a point of principle so perhaps it's not worth discussing, but I disagree

This type doesn't mean much:

data Free f a = Pure a | Join (f (Free f a))

unless we give it this instance

instance Functor f => Monad (Free f)

In general, in FP data types are dumb, and their meaning comes from the operations defined on them, it doesn't really matter if they come from a typeclass or data constructor imho

@djspiewak Yes, I agree that if value would be called unsafeValue or similar, this problem would not exist. But it isn't called that ... And sure, this is subjective; but I find it immensely helpful that in Cats/cats-effect (and also in FS2) I can be sure that anything which breaks RT is prefixed with unsafe.

@SystemFw

you can't break referential transparency in polymorphic code with it

I think you can:

def foo[F[_]](implicit F: Sync[F]): F[Int] = F.delay {
  println("foo")
  42
}
val f = foo[EitherT[Eval, Throwable, ?]]

def bar[F[_], A](fa: F[A])(implicit F: Foldable[F], A: Monoid[A]): A = F.fold(fa)

bar(f) // prints "foo"

@durban nice catch! I had only considered Comonad, not Foldable. In this case it's the same as #169 , and it should definitely be removed.

Was this page helpful?
0 / 5 - 0 ratings