Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace the entire type class hierarchy #93

Closed
jdegoes opened this issue Dec 5, 2017 · 11 comments
Closed

Replace the entire type class hierarchy #93

jdegoes opened this issue Dec 5, 2017 · 11 comments

Comments

@jdegoes
Copy link

jdegoes commented Dec 5, 2017

The existing Cats Effect type class hierarchy was a good proof-of-concept and demonstrated the existence of demand for library authors to abstract over effect monads.

Unfortunately, it is not possible to build resource safe, composable applications on the existing type class hierarchy. At the root of this problem is the lack of an abstraction for resource safety.

In the same way that all languages with exceptions have a try / catch / finally construct, all effect monads that support failure must have a similar analogue that can be used by higher-level libraries to ensure resource safety.

In other words, something like MonadBracket needs to be a super class of all effect monads.

The lack of such a construct has created libraries like FS2 which (a) depend on unlawful behavior, and (b) depend on unspecified behavior. This is false abstraction, not actual abstraction as per the original design goals of this project, and the very antithesis of principled functional programming.

I propose to replace the entire existing type class hierarchy by three type classes: MonadBracket, which extends MonadError; MonadFork, which extends MonadBracket; and MonadIO, which subsumes the best parts of Sync/Async and extends MonadBracket and MonadFork.

These are necessary and sufficient abstractions for resource-safe, concurrent, composable functional applications, and other functionality should be left to the underlying implementations.

@alexandru
Copy link
Member

Thanks John for the proposal. But you have a background that I seem to lack.

Can you please paste some Scala code of the type classes you're thinking of? Just to make it clear what you're thinking of.

@jdegoes
Copy link
Author

jdegoes commented Dec 5, 2017

The Scala versions are coming shortly in Scalaz 8, but in the meantime there are other versions available to review.

@alexandru
Copy link
Member

alexandru commented Dec 5, 2017

There are some merits to the current type class design and off the top of my head:

  • Sync is for monads with a memory safe flatMap implementation, maybe the only thing missing here is a version that doesn't inherit from a MonadError, however Daniel had some good points against that — anyway, there is one good example of a Sync data type that is NOT like async IO, which is Monix's Coeval — this data type is not Async and that's a feature, because you're guaranteed immediate evaluation with no threads or any other async capabilities involved
  • the separation between Async and Effect is very OK and clear in Monix's Task, because for Effect we have to provide a Scheduler on execution, in order to be able to get that result out of the Task context, whereas for getting an Async instance you don't need a Scheduler, since Async is pure

This is also clear in the implementation of Monix's streaming abstractions — Observable requires Effect in its integration with Task and IO, however Iterant only requires Sync and seldom Async or Parallel, this is because Iterant is deferring the evaluation to the underlying F[_], whereas Observable does not rely on some F[_] for evaluation and needs to trigger that evaluation by itself — I actually explained this in my presentation at Scala World.

So my point is that when you do higher-kinded polymorphism, like we are doing by using these type classes, the important bit is being able to only use the restrictions required for the operation in question and no more.

Therefore the separation between Sync, Async and Effect is in my opinion really good because I've already been successful in making use of it.

Of course, the question is — is this hierarchy not enough? In particular, maybe we need:

  1. MonadBracket and we have an issue open in Consider a Type class for resource safety #88 discussing it
  2. a lighter Sync that does not inherit from a MonadError — the use-cases for it don't have to do with dealing with side effects though, but with purely functional and lazy lists, so it might not be worth solving
  3. a replacement for MonadError[Throwable, A] that catches exceptions in map / flatMap by law?
  4. a type class for cancellable IO types, i.e. that MonadKill?

@mpilquist
Copy link
Member

Thanks for opening. For some context, see this twitter thread: https://twitter.com/jdegoes/status/938051548843618304

Note specifically the criticism of Stream.bracket, which claims to release resources. @jdegoes pointed out two cases in which this doesn't occur:

  1. When an exception is thrown from a "pure" function applied to an effect type that doesn't catch exceptions. E.g., Stream.eval(IO(..).map(_ => throw Err))
  2. When the resulting stream is interrupted using an effect type's native interruption support.

We've discussed (1) quite a bit, most recently in #71. We haven't discussed (2) though. Ideally, cats-effect would provide type classes which let both (1) and (2) be handled correctly in fs2 for all effect types. As such, I'm pretty sympathetic to re-examining our hierarchy and adding things like MonadBracket, MonadKill/MonadCancel/MonadFork, and perhaps Catchable.

@jdegoes
Copy link
Author

jdegoes commented Dec 5, 2017

@alexandru

  1. Yes, MonadBracket is absolutely critical, and should be a superclass of every effect type. This functionality is required to actually abstract over failure in higher-level libraries like FS2 without resource leaks.
  2. No, as @djspiewak has already pointed out, a Sync without MonadError is ill-defined.
  3. No, as @djspiewak has already pointed out, if catch on map / flatMap is codified in laws, then these laws will contradict Applicative laws.
  4. Yes, MonadFork is needed. However, interruption only has well-founded semantics in the presence of forking (Monix Task interruption is impure, requiring the Task be run). See Parallel and Concurrent Programming in Haskell by Simon Marlow for more.

As for Coeval, I would argue its existence does not warrant the separation between Sync and Async (this is an artificial distinction, an implementation detail, that should not leak into type classes; any performant effect monad on the JVM must necessarily support sync and async).

That said, I've spoke my piece. I've suggested the best possible design that I know of that supports non-leaky, composable applications that abstract over effect systems. I'm happy to contribute the Scalaz 8 design but don't really have energy to debate it. Do with these suggestions as you will!

@djspiewak
Copy link
Member

@mpilquist @jdegoes The two cases you point out there both stem from users assuming behavior which doesn't hold in general: either catching exceptions in pure code, or that the specific interrupt mechanism in an underlying effect type can somehow magically cross into generic code. The first point is pretty directly addressed: we can't really codify exception-catching into the laws, because a) it conflicts with ApplicativeLaws, and b) it can't be done in general anyway. So case 1 is always going to be a problem in any effect type and with any abstraction.

Case 2 is more interesting, and it basically comes down to cats-effect's philosophy that the effect type is an atomic unit of work. fs2 inherits that philosophy (technically, it was a philosophy that Paul, Runar, and I came up with when sketching out fs2). Now, this does not mean that it is unlawful for an effect type to implement the cats-effect abstractions and provide a native interruption mechanism (see: Monix and Scalaz 8), but what it does mean is that any framework which solely works in terms of these abstractions will not understand the nature of those interrupts. If users take advantage of those interrupt mechanisms, the guarantees provided by higher level frameworks working through the abstractions may be corrupted.

This is by design.

Here's what it comes down to for me… I strongly believe that there are two semantic layers of abstraction in functional, composable effects: concurrency, resource safety, preemption, streaming, etc; and effect capture itself. While it is certainly possible for a framework to handle all of these in one lump (see: Scalaz 8), it requires extra complexity and imposes semantic costs on use-cases which don't care about concurrency or resource safety in the face of preemption. Thus, we have the split between cats-effect and fs2 (and other similar frameworks).

MonadBracket is only critical if effect types are providing native preemption. If effects cannot be preempted, then bracket is less meaningful since it can be trivially written in terms of MonadError with the same guarantees. We could certainly provide the typeclass with such a default implementation, but we lose all static guarantees, since then all effect types would implement MonadBracket, even those which don't provide preemption and therefore don't have a special bracket implementation. Perhaps this is still an improvement on the current situation though.

Yes, MonadFork is needed

Only if you want concurrency to be represented directly in the effect library. I desperately want to not do this, since supporting parallelism means stepping away from the "atomic effect" concept, due to the fact that parallelism can only sanely be supported by an effect type which also has first-class preemption and resource-safe primitives.

any performant effect monad on the JVM must necessarily support sync and async

EitherT[Eval, Throwable, ?] is a valid Sync, not a valid Async, and is quite a useful and relatively performant effect monad on the JVM. The only reason I say "relatively" performant is because EitherT has some overhead that could be avoided in theory by an ErrorableEval which folds the Throwable case into its algebra. The distinction is quite useful.


Anyway, in summary, I'm not opposed to revising the hierarchy. MonadBracket seems reasonably compelling, even though a lot of effect types will simply use the default implementation based on MonadError (and critically, the interaction with monad transformers is probably not going to be what you think it is). I really really want to keep away from MonadFork if at all possible.

And finally, to be clear, I'm not closed to the possibility that the division of abstractions (between atomic effects and more semantically rich parallel structures) is simply the wrong place to chop things up. (which is to say, I'm open to the possibility that Scalaz 8's philosophy of IO is practically preferable to the cats-effect philosophy) But thus far I haven't seen strong enough evidence to convince me that this is the case.

@jdegoes
Copy link
Author

jdegoes commented Dec 6, 2017

@djspiewak

👍

I don't need to argue for the Scalaz 8 / PureScript / Haskell model, since I think this debate will be settled by the community based on merit (one way or the other).

However, I would point out that it's not just MonadBracket that has a trivial implementation for cats-effect: it's also MonadFork. In MonadFork, forking and joining are one-liners for cats-effect, and "naive" interruption is nothing more than a join that ignores the exception and waits until the forked IO is completed. Indeed, forking and joining already exist in FS2 and could be moved over and defined solely in terms of shift and async, and then interruption could be defined in terms of join.

One has to loosen some of the laws for MonadFork, but that seems a small price to pay. In the end, libraries like FS2 would be able to gain correctness without depending on either (1) unlawful behavior, or (2) unspecified behavior, and gain compatibility with Scalaz 8 IO and Monix Task (when cancellation is used) and other effect monad types that have yet to be invented.

MonadBracket is only critical if effect types are providing native preemption.

This is not strictly true. If you decided to change cats-effect to not catch errors in map/flatMap, but instead, to report them by some other error reporting mechanism, then it would be critical to provide a MonadBracket, even though cats-effect would still not provide interruption.

MonadBracket is a way of codifying in laws the concept of a finalizer. The guarantees that libraries are assuming from MonadError are not actually codified in any laws. We need some way to precisely specify them so libraries can obtain the guarantees they need in a lawful fashion.

(One could also add these guarantees to MonadError, but IMO that's a bad idea because it removes the possibility of instances for things like the above cats-effect modification or Scalaz 8 IO.)

@djspiewak
Copy link
Member

without depending on either (1) unlawful behavior, or (2) unspecified behavior

Can you be more specific about this? Aside from the cases @mpilquist raised (which are both user expectation issues, not fs2 issues), what unlawful or unspecified behavior is fs2 relying on?

MonadBracket is a way of codifying in laws the concept of a finalizer

This is true, and it is compelling, though to be clear that concept of a finalizer has no real distinction from MonadError except if the underlying type has preemption.

@jdegoes
Copy link
Author

jdegoes commented Dec 6, 2017

Can you be more specific about this? Aside from the cases @mpilquist raised (which are both user expectation issues, not fs2 issues)

As recently as yesterday, the documentation for FS2 made guarantees that could not be satisfied by lawful instances for the Cats-effect type class hierarchy (you cannot make stronger guarantees than your base monad!).

For the documentation to be correct, catching during map/flatMap and atomicity are both required.

One can argue the documentation is broken, but I’d rather argue the capabilities required to build libraries like FS2 are simply missing from the hierarchy and can be added at no cost to users who don’t care about them.

@alexandru
Copy link
Member

We now have a type class hierarchy that addresses many of the concerns raised.
Well, the work is still in progress, but we are getting somewhere.

Would like to do some cleanup in the issue tracker, plus if there's new discussion to be had, I'd rather have us restart from scratch, participants being able to link to previous comments, otherwise engaging in old conversations is too noisy imo.

Thanks @jdegoes and all for your suggestions, it helped.

@jdegoes
Copy link
Author

jdegoes commented Mar 9, 2018

Happy to help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants