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

Cats Effect 3.0 Design Discussion #321

Open
jdegoes opened this issue Aug 27, 2018 · 60 comments

Comments

@jdegoes
Copy link

commented Aug 27, 2018

For Cats Effect 2.0, I'd propose the following:

  1. Refocus Cats Effect 2.0 on enabling interop via a common set of type classes, together with any essential, core building blocks that can be expressed in terms of these type classes (e.g. Ref, etc.).
  2. Move IO into cats-io, with a separate (but initially overlapping) set of maintainers, and its own independent release schedule. Those so interested can enhance it with other utilities like linebacker, scheduling, etc. Importantly, cats-io would depend on cats-effect, not the other way around.
  3. Redesign the hierarchy based on everything we've learned from 1.x. I've had discussions with many people on this and can summarize them if useful.
  4. Support bifunctor and monofunctor type classes (with an EitherT adapter?), in synchronous and asynchronous variations (@mpilquist has convincing use cases for synchronous variants), in a clean and minimalist hierarchy that works across Monix Task, Cats IO, and ZIO. The bifunctor type classes should be truly bifunctor (F[_, _]).

These steps would, in my opinion, take what's good about Cats Effects and make it better, while reducing maintenance burden, supporting the whole FP ecosystem, and solving well-known problems with the current design.

@djspiewak

This comment has been minimized.

Copy link
Collaborator

commented Aug 27, 2018

  1. Refocus Cats Effect 2.0 on enabling interop via a common set of type classes, together with any essential, core building blocks that can be expressed in terms of these type classes (e.g. Ref, etc.).

  2. Move IO into cats-io, with a separate (but initially overlapping) set of maintainers, and its own independent release schedule. Those so interested can enhance it with other utilities like linebacker, scheduling, etc. Importantly, cats-io would depend on cats-effect, not the other way around.

These are kind of the same point, so I'll address them together.

There were two original goals behind marrying the typeclass hierarchy and the IO implementation. The first was simply convenience, thus easing adoption and lowering as many barriers as possible that might be in the way of people who were then using scalaz.concurrent.Task. The second reasoning was a fast-and-loose parametricity "rooting" concept.

The idea was simple: _root_.cats.effect.IO is the one magic type which represents a container for side-effects, and all other types which purport to have independent mechanisms for managing side-effects express such by showing isomorphism with cats.effect.IO. This is extremely loose reasoning, but the thought was that it is better than nothing (which is to say, no nominative characterization of what it means to be a side-effect-y datatype).

To be clear, that reasoning made its appearance in the original form of Effect:

def liftIO[A](ioa: IO[A]): F[A]
def runAsync[A](fa: F[A])(cb: Either[Throwable, A] => IO[Unit]): IO[Unit]

So then in the system of axioms proposed by cats-effect, IO is the only datatype which must be assumed to be "magical". All other types must prove they are exactly as magical as IO, but do not themselves need any special assumptions. The delay and async constructors could in principle be implemented in terms of these two functions, though they were left abstract since such implementations are impractically inefficient.

That was the idea, anyway.

The problem with this, of course, is that it ties everyone to cats.effect.IO. You literally are not allowed to have a ZIO or a Monix Task or anything which doesn't, ultimately, provide a way to evaluate itself into cats.effect.IO. I'm not sure exactly how onerous this is in practice, but I can certainly see why it would be aesthetically annoying.

I'm also not sure how valuable the parametricity argument is. I've used it, but I don't think anyone else really has. I think it's very desirable to reduce the magic down to a single axiom, and it certainly makes the theorems easier to think about, but in practice I'm honestly not sure that anyone cares (other than me).

So unless I'm missing something, this is essentially the debate that we're weighing: does the annoyance of tying third-party effect types to cats.effect.IO outweigh the value of having a single parametric root?

(edit: oh, as a more practical aside: LiftIO becomes broadly-useless if we don't have a concrete IO type hanging around, so any split of the two projects would result in that typeclass being removed, which is a material loss for some abstractions)

  1. Redesign the hierarchy based on everything we've learned from 1.x. I've had discussions with many people on this and can summarize them if useful.

This is the most important point in the whole issue description. I definitely would appreciate much more specificity here.

  1. Support bifunctor and monofunctor type classes (with an EitherT adapter?), in synchronous and asynchronous variations (@mpilquist has convincing use cases for synchronous variants), in a clean and minimalist hierarchy that works across Monix Task, Cats IO, and ZIO. The bifunctor type classes should be truly bifunctor (F[_, _]).

While I remain unconvinced by bifunctor IO types, I think there's basically no question that the typeclasses should be bifunctor, at the very least in their primary form. It's just too useful not to do that. The strongest argument against bifunctor IO (the inability to handle uncaught exceptions in pure transformations without global magical escapes/thread death) melts away completely where the typeclasses are involved, since there is absolutely no defense for attempting to crystalize those semantics into the generic interfaces and laws.

The strongest argument against bifunctor-by-default typeclasses is simply the extra type parameter that everyone would thus need to deal with. We might be able to solve this with type aliases and some implicit enrichment machinery (e.g. type SyncT[F[_]] = Sync[λ[(_, ⍺) => F[⍺]]]; this still doesn't work but you get where I'm headed), but I'm dubious. There isn't a clear way to unify bifunctor and monofunctor interfaces without generating unnecessary overhead for one or the other (e.g. EitherT), so I'm not sure. It merits some deeper thought.

We would need to be careful to ensure that the bifunctor error type manipulation machinery doesn't do anything unsound on monofunctor types, among other concerns.

solving well-known problems

Point 3 again. This is exactly the place to be more specific about these problems, rather than referring to them obliquely. They clearly aren't as well-known as you imply since I am not aware of any of them. Not saying they don't exist, but please elaborate. This point, exactly here and almost nothing else, is what the original issue should be focused on.

@alexandru

This comment has been minimized.

Copy link
Member

commented Sep 7, 2018

Some thoughts:

  • separating IO into its own sub-module is something that I want us to do ... this can keep our type-class implementations honest and I can certainly see reasons for why a library like ZIO wouldn't want its users to have two IO / Task implementations on their classpath, as it leads to confusion
    • interoperability is something I cared about ever since the start for obvious reasons
  • the functionality in Effect and ConcurrentEffect should be redesigned to no longer depend on IO; this means exposing unsafe methods
    • using IO is cool for safety, but it doesn't buy us that much when it comes to using these, since in my experience most of the time we end up doing runAsync(...).unsafeRunSync and that defeats the purpose
    • in the early days we did not have Concurrent or asyncF so Effect was required in libraries like FS2 for describing operations like start; nowadays the need for Effect functionality is at the "edge" ... note that the "edge" can be non-obvious, these type classes being still very useful, we are not talking of their usefulness:
      • "edge" also means conversions between effect types, e.g. from Task to IO or vice-versa
      • and also features that we haven't thought about, e.g. if you don't have start, until a type class happens you implement it via Effect, ditto for race, although with asyncF we cover a lot of ground for those cases now
  • I'm open to us introducing type-class variants to accommodate bifunctor IO implementations
@djspiewak

This comment has been minimized.

Copy link
Collaborator

commented Sep 7, 2018

the functionality in Effect and ConcurrentEffect should be redesigned to no longer depend on IO; this means exposing unsafe methods

Technically, this isn't required to split the typeclasses from IO, but I'll loop back to that.

Exposing unsafe methods is very dangerous. I realize that we're not enabling anything that isn't already technically possible by running to IO and then running that IO, but the obfuscation of that path is very intentional. Typeclasses are tools for safe abstraction. You cannot write laws about abstracting over side-effects. And while, yes, we already have the two constructor functions which explicitly deal with side-effects, they are quite a bit less unsafe. It is one thing to say "give me a thing that I'll tuck away into my datatype which promises not to do anything with it", and it's very much another thing to say "call this function which could do anything and return nothing".

I don't consider myself to have any real sway here – you're doing the work, the decisions are yours – but I'm strongly opposed to exposing any unsafe functions on the typeclasses.

Fortunately, I don't think we technically need that to split IO. Simply adding a def to[G[_]: Async, A](fa: F[A]): G[A] function to Effect would resolve the issue (also note: this can be implemented in a fashion that avoids the stack unsafety in sync cases by using suspend composed with async, rather than just async). I steered clear of this approach originally because I felt that the enforced isomorphism represented by Effect was a little gauche, but now I'm not sure it matters. We still don't have any way of keeping LiftIO, which as I said, would be a very meaningful loss for a number of current use-cases, but in a sense we get something more general (albeit less efficient).

using IO is cool for safety, but it doesn't buy us that much when it comes to using these, since in my experience most of the time we end up doing runAsync(...).unsafeRunSync and that defeats the purpose

Well, not entirely. You still get JavaScript safety out of that. One of the nice things about Effect as it stands is it is not possible (without a broken runAsync implementation) to write code in terms of the typeclasses which does any thread blocking whatsoever, meaning that anything written in terms of the typeclasses is always guaranteed to be safe on both major platforms. This was a very conscious design decision.

the early days we did not have Concurrent or asyncF so Effect was required in libraries like FS2 for describing operations like start; nowadays the need for Effect functionality is at the "edge" ... note that the "edge" can be non-obvious, these type classes being still very useful, we are not talking of their usefulness

Agreed that start removes a lot of the early use-cases for Effect, where runAsync was often composed with liftIO simply to encode start.

I will say though that I don't consider converting to another effect type (e.g. the to function) to be an "edge". You still haven't run any effects. They're encoded in a different form, sure, but they still haven't run and they aren't running (e.g. you haven't gone to something like Future). As long as you're still within an Effect, you haven't yet hit the edge, just the edge of your concrete implementation.

@alexandru

This comment has been minimized.

Copy link
Member

commented Sep 7, 2018

@djspiewak I don’t have any strong opinions on this matter and I think we have plenty of time to think about it.

I don't consider myself to have any real sway here – you're doing the work, the decisions are yours – but I'm strongly opposed to exposing any unsafe functions on the typeclasses.

You started the project, you did a lot of the work, you have unmatched insight on many issues, the sway is yours if you want it 🙂

@djspiewak

This comment has been minimized.

Copy link
Collaborator

commented Sep 7, 2018

I think we have plenty of time to think about it.

Agreed. And I think we should definitely take that time. I don't think the existing hierarchy is perfect by any means and I'm strongly in favor of evolving it in response to lessons learned in this iteration, but that process should absolutely be very deliberate and considered from many angles. I think the discussion here has been excellent so far; I hope it continues and draws thoughts from other interested parties!

@jdegoes

This comment has been minimized.

Copy link
Author

commented Sep 24, 2018

So unless I'm missing something, this is essentially the debate that we're weighing: does the annoyance of tying third-party effect types to cats.effect.IO outweigh the value of having a single parametric root?

Yes, absolutely, and without question, for the following reasons:

  1. Loss of Abstraction. Any abstraction that features a concrete instance of that abstraction inside the abstraction (e.g. IO appears in the cats-effect hierarchy) is no longer an abstraction at all. To define the meaning of an effect in terms of a concrete data type is to abandon the conception of abstraction. There is no longer abstraction, just layers of ceremony around pretend abstraction.
  2. Massive Wasted Effort. Based on contributor commit logs, @alexandru is now the defacto maintainer of cats-effect IO, and regularly ports features back and forth between cats-effect IO and Monix Task. The tiny functional programming ecosystem is maintaining essentially two copies of the same data type, with the caveat that Monix Task can be regarded as a much better version of cats-effect IO, supporting everything that cats-effect IO does, in a more flexible way, and with more features.
  3. Harmful Biases. In the past, I had to argue vigorously for certain features (such as resource safety) that were not easy to incorporate into cats-effect IO, but which ZIO and Monix Task already had. Much of the pushback came from the fact that any significant design decision must be accompanied by significant re-engineering of the cats-effect IO data type, resulting in decisions that are biased toward the current design, even if it's suboptimal in the landscape of possible designs. In the best possible world, cats-effect provides a layer of abstraction which is influenced only by the libraries that consume and provide instances.
  4. Crippling Innovation. There is repeated significant demand to add more features to the cats-effect IO data type—features that are present in other libraries like ZIO and Monix Task. Yet adding an endless battery of never-ending features is not good for this library, because it increases maintenance burden and spirals the project away from its initial goals. If cats-effect IO were a separate project, then maintainers of this project could feel free to contribute improvements so long as they were committed to maintaining them; and likely they would diverge from Monix Task and evolve in their own ways.
  5. Zero Benefit. Having a data type in cats-effect has zero benefit and all cost. In a world in which cats-effect IO lives inside cats-io, and the user prefers cats-effect IO for some reason, this user can switch their SBT build file from cats-effect %% "1.0.0" to cats-io %% "1.0.0" and continue to use everything almost exactly as they do today. In fact, such a project can easily add extension methods so the user still has toIO and other features they might like to use.

In summary, because having a concrete data type in the hierarchy destroys abstraction, results in massive wasted effort, biases design discussions towards a (previously-called) "reference type", cripples innovation, and has zero benefit, splitting out IO into a separate project for 2.0 makes a ton of sense.

Then the original design goal of this project to provide a lightweight way to abstract over the different effect data types can be restored, in a healthier way that will lead to better abstractions and possibly even an improved cats-effect IO.

This is the most important point in the whole issue description. I definitely would appreciate much more specificity here.

It's actually not the most important point from my point of view. The most important point is to remove cats-effect IO from cats-effect (the project and the hierarchy), and make it a separate project cats-io, which depends on cats-effect, and is free to evolve independently, and not constrain the hierarchy or laws of cats-effect.

If we agree on this, and a willingness to accept a well-designed bifunctor hierarchy, then I will happily roll up my sleeves and suggest improvements based on extensive discussions with Scalaz folks, Michael Pilquist, Fabio Labella, etc. (such as, for example: pushing FFI methods to the leaves of the hierarchy; thoroughly supporting synchronous-only IO; never drawing distinctions between a parent/child when the parent is already powerful enough to implement the child; etc.). But if cats-effect 2.0 ends up with cats-effect IO and / or no willingness to accept a proper bifunctor hierarchy (which is desperately needed right now), then I would say my interest in further contributions to the project is gone.

There isn't a clear way to unify bifunctor and monofunctor interfaces without generating unnecessary overhead for one or the other (e.g. EitherT), so I'm not sure. It merits some deeper thought.

EitherT is a nice and sound unification and many will happily pay the cost; we can generate bifunctor instances "for free" for any monofunctor using EitherT, which means users can use bifunctor instances anywhere and everywhere with their data type of choice, no custom development needed. The slightly unprincipled but more performant way to unify is to submerge errors in a custom Throwable, and @LukaJCB has demonstrated this approach works without overhead. And anyway, there's already a Cats BIO in the wild from @LukaJCB that merely lacks bifunctor instances to implement.

@mpilquist

This comment has been minimized.

Copy link
Member

commented Sep 24, 2018

In general, I'm in support of moving cats.effect.IO to cats.io.IO and in to a separate module. I'm less enthusiastic about moving it to a separate repository. I'd nitpick some about original goals of the project -- interop was one of the goals but a solid IO type was another. I use cats.effect.IO in many projects, both closed and open source, and feel very strongly about it continuing to exist and continuing to evolve.

@djspiewak

This comment has been minimized.

Copy link
Collaborator

commented Sep 24, 2018

@jdegoes

Replying point for point...

Any abstraction that features a concrete instance of that abstraction inside the abstraction (e.g. IO appears in the cats-effect hierarchy) is no longer an abstraction at all.

That doesn't appear in any definition of "abstraction". The inclusion of a concrete type is appropriate and correct in the case that the calculus of the category is to be defined in terms of an axiomatic concrete instance. This is not conventional because it's generally preferable to have the smallest set of weak axioms as possible, but it was a conscious choice that I made in this case. To say that this is "no longer an abstraction" is simply wrong and not supported by any definition that I'm aware of (formal or otherwise), it's just not the abstraction that you want.

The tiny functional programming ecosystem is maintaining essentially two copies of the same data type, with the caveat that Monix Task can be regarded as a much better version of cats-effect IO, supporting everything that cats-effect IO does, in a more flexible way, and with more features.

Actually there are material differences, particularly around cancellation, but I'm not going to get into a tit-for-tat argument. I will say though that I object to the use of subjective comparisons presented as objective fact.

IO and Task are specifically setting out to accomplish a lot of the same things. It's natural that their design would converge given similar philosophies even without shared maintainership. ZIO copied Monix Task's core run loop originally; you didn't hear anyone calling for you to delete ZIO and just contribute to Task, did you?

Much of the pushback came from the fact that any significant design decision must be accompanied by significant re-engineering of the cats-effect IO data type, resulting in decisions that are biased toward the current design, even if it's suboptimal in the landscape of possible designs.

Please keep in mind the difference between subjective and objective facts here. "Suboptimal in the landscape of possible designs" implies there is some objective and unquestioned lattice over effect datatypes and implementations. There is not and there will never be.

The effect type space is rich enough that there are several places where one decision or another could be perfectly valid (default cancelability for example). While there are certainly elements that I would consider to be objectively important (e.g. async), there are also aspects of the various effect types that can be safely considered instance of selecting one set of tradeoffs versus another. Those tradeoffs may seem overpoweringly significant and important to some people, and irrelevant or even perverse to others, and that's okay.

I don't see any scenario which would lead to complete parity convergence between the various effect types in the ecosystem. This is part of why the cats-effect hierarchy was designed to be so minimal in the first place. I wanted to make sure that it was possible to have implementations which provided concurrency and cancelability and all sorts of other things. Anyone who wished to use these features would need to use concrete types, which is not at all uncommon!

Most use-cases, in my experience, involve people selecting very concrete types and simply reaping the benefits of the typeclasses in the context of third-party frameworks, such as fs2 and others.

In the best possible world, cats-effect provides a layer of abstraction which is influenced only by the libraries that consume and provide instances.

With a strong emphasis on the consumption over the providing. Just because a concrete type provides some feature does not mean it needs to exist in the abstraction. The abstraction should provide the minimum of functionality that is deemed necessary by those who are consuming instances to build other useful things. Libraries which provide instances certainly should inform signatures and overall design as changes are needed but that doesn't mean that implementations drive de novo alterations.

There is repeated significant demand to add more features to the cats-effect IO data type—features that are present in other libraries like ZIO and Monix Task.

I think "repeated" and "significant" is a bit of an exaggeration. Also issues like this have existed since even before ZIO was a thing (notably regarding stdlib things). In fact, if you hunt back in the git history, you'll find an early version of cats-effect which did have some of these features. I removed them in the interest of a smaller API and avoiding constraining downstream libraries.

In many cases, requests such as this will come down to monorepo versus polyrepo philosophies. There have also been "repeated and significant" requests to squish cats-effect into cats itself, many of which coming from the same thought processes. I haven't been in favor of those suggestions, either.

My point being that these requests aren't really quashed by splitting things into cats-effect and cats-io, nor are they made any easier to incorporate. Many of them would still get rejected in favor of pushing functionality into separate libraries.

Having a data type in cats-effect has zero benefit and all cost.

I already listed the benefits. But just for your reference:

  • LiftIO is meaningless without IO, and is often-times a very useful typeclass
  • runAync provides an axiomatic root to parametricity arguments
    • Without liftIO and runAsync, all axioms basically boil down to "Function0 can do anything, and you can put Function0s inside of F[_] and get them back out again!". That's a considerably weaker argument and one which lacks a nominative foundation. In practice, most people who actually use these libraries probably don't care, but it's still a point to consider in principled design of abstractions.

I may have listed something else as well, I can't remember, but that's what sticks out in my mind.

biases design discussions towards a (previously-called) "reference type"

I mean, I think this is a valid point, and honestly I would rather it were not the case. As I said, I don't think that implementors of the typeclasses should have a strong initiating say over the ongoing evolution. I believe that evolution of the abstractions should be driven by consumers of the typeclasses, with the form those evolutions take being guided by the implementors. This is a critical distinction.

Then the original design goal of this project to provide a lightweight way to abstract over the different effect data types can be restored

Speaking as the person who typed git init on cats-effect, that was not the original goal. That was certainly a goal (and an important one), but not the goal. Let's not bring out originalist arguments here. There are certainly valid reasons to split the typeclasses away from the concrete IO but "rectifying scope creep"/"restoring the original vision" is not one of them.

EitherT is a nice and sound unification and many will happily pay the cost

And those who don't want to pay the cost would end up paying it anyway. I strongly oppose any approach to a bifunctor typeclass encoding which imposes overhead on one implementation or another, especially not the magnitude of overhead which would come from EitherT. The monofunctor typeclasses are overhead-free when implemented by bifunctor datatypes. They're less flexible than is desirable but they don't impose extra overhead. Flipping that situation (bifunctor typeclasses, monofunctor datatype) must not impose any more overhead than the current scenario (which is to say, none).

The slightly unprincipled but more performant way to unify is to submerge errors in a custom Throwable, and @LukaJCB has demonstrated this approach works without overhead.

This, I think, is probably a more viable design. It's kind of horrible for all the obvious reasons, but the lack of overhead is a strong selling point. And arguably EitherT is already embedded in IO, so submerging in a custom Throwable is really just leveraging that EitherT in an untyped way.

I think more iteration on these ideas (and others) is likely to yield fruit.

And anyway, there's already a Cats BIO in the wild from @LukaJCB that merely lacks bifunctor instances to implement.

This is true, though as I said, I remain unconvinced by bifunctor IO datatypes in general. This isn't the place for that argument, I'm just pointing out the fact that there are going to be bifunctor and monofunctor implementations in the wild, and the typeclasses need to cover them ideally without prejudice, performance or otherwise.

I would like the typeclasses to be bifunctorial, because that strikes me as a more general approach, but if that ends up forbidding penalty-free monofunctor datatypes then I will be strongly opposed.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Sep 24, 2018

In general, I'm in support of moving cats.effect.IO to cats.io.IO and in to a separate module. I'm less enthusiastic about moving it to a separate repository.

That's because it means we get less Maintenance for Free™, which is always painful but I'd argue generally a good thing. I feel if Cats IO is in the same repository, there will be pressure to (a) not make changes to the hierarchy that require re-engineering effort for cats-io, (b) port over changes to the hierarchy into cats-io concurrently, possibly even as a mandated part of the same pull request; (c) not introduce useful features that are lacking in Cats IO, to avoid growing the project; (d) privilege cats-io in a way that is not based on its own merit as an effect data type, but due solely to its bundling with the type classes (it should live or die based on its own merit); (e) conflate the set of contributors / maintainers for cats-effect with the set of contributors / maintainers for cats-io (I feel I'm a contributor to cats-effect but not cats-io, for example); and finally, (f) synchronize release schedules, which will slow down development of both libraries and limit potential for innovation.

That doesn't appear in any definition of "abstraction". The inclusion of a concrete type is appropriate and correct in the case that the calculus of the category is to be defined in terms of an axiomatic concrete instance.

No. In my opinion, it's a complete failure of abstraction to define a polymorphic effect as one that can be converted to a concrete effect data type.

If the best you could do for Foldable is write toList, then you would have failed utterly to abstract over the concept of a fold (arguably, Foldable is pretty terrible already, but it'd be worse if the only method were toList).

In any case, this is not a contentious claim, as far as I'm aware: nearly everyone is on board with ripping out the concrete data types from the interfaces. Most I've talked to see it in a similar way to me: it's a failure of abstraction, with more problems besides (creation of a privileged canonical effect type that is less powerful than alternatives, for example).

I think "repeated" and "significant" is a bit of an exaggeration.

I can quote lots of other closed issues. People consistently want to add retrying, scheduling, and other features into Cats IO. If you hang out in cats-effect on Gitter or look at the closed issues, you can see this.

These features, by the way, are already in Monix Task, and some are in ZIO. They're not contentious. You need them to do useful work. One can question where they should live, but the point is that Cats IO could and probably would evolve differently if not tied to the hierarchy. You might not like the way it would evolve but it's up for a project's maintainers to decide that. It's a major conflict of interest for the data typed to be tied so closely to the hierarchy.�

LiftIO is meaningless without IO, and is often-times a very useful typeclass

It doesn't belong in cats-effect. It's only useful for people who want to use Cats IO.

Every effect data type should define its own type class:

trait Monix[F[_]] {
  def liftTask[A](task: Task[A]): F[A]
}
trait ZIO[F[_, _]] {
  def liftZIO[E, A](io: IO[E, A]): F[E, A]
}
trait Cats[F[_]] {
  def liftCIO[A](io: IO[A]): F[A]
}

These should live in the data type libraries, not inside cats-effect. Then any library author can require compatibility with any other effect monad by using the appropriate type class. So if someone wants to write a library that uses cats-effect IO internally, but is compatible with Monix Task or ZIO, then they depend on Cats[F] in their code, which lets them use IO-specific features while still providing polymorphic compatibility with other libraries.

runAync provides an axiomatic root to parametricity arguments

The already contentious runAsync does not aid reasoning, nor is its existence required by any use case I know of. And if we really want to do it, we should do it polymorphically:

def runSync[G[_]: SyncOnly, A](fa: F[A])(f: Either[Throwable, A] => G[Unit]): G[Unit]

where G is a synchronous-only effect data type. In this case, the type signature provides the guarantee that the effect will be "run" right away and the callback (potentially) invoked at some later point.

I mean, I think this is a valid point, and honestly I would rather it were not the case. As I said, I don't think that implementors of the typeclasses should have a strong initiating say over the ongoing evolution.

Or rather, the way I'd say it is that users of the type classes should drive all feature requests, and all implementors from all effect data type libraries are free to discuss how to satisfy such requests in the context of the type class hierarchy. In any case, either change is preferable to the situation we have today.

The monofunctor typeclasses are overhead-free when implemented by bifunctor datatypes. They're less flexible than is desirable but they don't impose extra overhead. Flipping that situation (bifunctor typeclasses, monofunctor datatype) must not impose any more overhead than the current scenario (which is to say, none).

I've already explained that it's possible to do it without overhead. But further, I think the monofunctor hierarchy should continue to exist and be supported; I'm just arguing that if there is a bifunctor hierarchy (and there should be one), it makes sense to allow users to easily layer that hierarchy onto any data type (such as @LukaJCB's UIO) with EitherT.

Of course, they're free to use other instances; in particular, for Throwable effect types, they could submerge errors into a custom Throwable; and it makes sense to provide such instances for Monix Task and Cats IO, for performance reasons.

But it's not a given that all Cats-Effect 2.0 data types will support Throwable. UIO is a very nice data type and should not be required to catch and throw errors. There should exist some set of type classes that UIO-like data types can implement and still be maximally useful to consumers of these interfaces.

This isn't the place for that argument, I'm just pointing out the fact that there are going to be bifunctor and monofunctor implementations in the wild, and the typeclasses need to cover them ideally without prejudice, performance or otherwise.

Agreed, but keep in mind the implementations of these type classes will be in the respective data type libraries. Hopefully @alexandru would provide a zero-cost implementation of the bifunctor type classes. But that would be up to him. There should still exist in cats-effect proper automatic instances of the type classes for EitherT on any monofunctor effect type, even if it isn't the recommended way to use them in data types like Task that have their own error channel (they would still be essential for UIO, which has no error channel, and for some monad transformer stacks).

@mpilquist

This comment has been minimized.

Copy link
Member

commented Sep 24, 2018

That's because it means we get less Maintenance for Free™, which is always painful but I'd argue generally a good thing. I feel if Cats IO is in the same repository, there will be pressure to (a) not make changes to the hierarchy that require re-engineering effort for cats-io, (b) port over changes to the hierarchy into cats-io concurrently, possibly even as a mandated part of the same pull request; (c) not introduce useful features that are lacking in Cats IO, to avoid growing the project; (d) privilege cats-io in a way that is not based on its own merit as an effect data type, but due solely to its bundling with the type classes (it should live or die based on its own merit); (e) conflate the set of contributors / maintainers for cats-effect with the set of contributors / maintainers for cats-io (I feel I'm a contributor to cats-effect but not cats-io, for example); and finally, (f) synchronize release schedules, which will slow down development of both libraries and limit potential for innovation.

(a) This pressure will exist anyway, at least before releasing major versions. We've tried very hard to ensure compatibility with IO, ZIO, and Monix on each major version.
(b) Agreed, though I consider that a feature -- see free maintenance note. :)
(c) Disagree here -- e.g., IO won't get unexceptional capability and I'm fully behind having a UIO type and support via typeclasses.
(d) Somewhat but I think this overstates the case a bit. I understand this concern when it comes to IO showing up in the type classes but much less so when we're discussing releasing from the same repository.
(e) Agreed
(f) We'd synchronize them anyway, at least with respect to binary compatibility -- we've generally tried to synchronize with Monix and ZIO as well. E.g., the delay in 1.0 was initially driven by discovering the bracket laws prohibited ZIO instances.

If nothing existed, I'd be more supportive of the notion of keeping cats-effect and cats-io in separate repositories. That's not where we are though, and I'm not at all convinced that separating them will benefit us in the end.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Sep 24, 2018

(b) Agreed, though I consider that a feature -- see free maintenance note. :)

Please explain to me why, as a contributor to cats-effect, if I propose a change to a cats-effect type class, and submit a pull request with that change, including documentation and laws, I should be required to also maintain the Cats IO data type?

I mean, I understand the obvious reason: it makes it easier for the people who use Cats IO (e.g. yourself). But that's not a very good reason in my opinion. I maintain ZIO already, why should I have to also maintain Cats IO?

I want to contribute to cats-effect without contributing to cats-io. A separate repository makes that easier, and lessens the implied burden for the cats-effect maintainers.

@mpilquist

This comment has been minimized.

Copy link
Member

commented Sep 24, 2018

IMO, separating them increases the maintenance burden in a few ways:

  1. Amortizes maintenance work related to documentation, build health, release notes, etc.
  2. Introduces another axis in the binary compatibility graph that needs to be managed by users.
  3. Provides an existence proof that a new idea is implementable.
  4. Provides an effect type to use in tests.

Of these, I worry about 1 the most and then 4. 2 is a concern but given that folks already need to manage N dependencies for a large N, N+1 isn't going to be a deal breaker.

3 is interesting as it has disadvantages as well (your bias point from earlier) -- the existence proof can convince us something's a good idea and then months later we find that it wasn't as it doesn't work for ZIO or Task. I can also see 3 becoming less important as the type class hierarchy grows -- IO won't implement every type class anymore and I don't think we necessarily want to continue to add new variants to this repository.

Regardless, there's the practical issue that testing against 1 type is better than against 0 types. Maybe there's a better way to approach this in general?

@jdegoes

This comment has been minimized.

Copy link
Author

commented Sep 25, 2018

Regardless, there's the practical issue that testing against 1 type is better than against 0 types. Maybe there's a better way to approach this in general?

I think a reasonable policy would be that if you want to propose new functionality and it's contentious in any way, it needs to be implemented as a proof-of-concept in some effect type. Indeed, most of the proposed features for cats-effect were already implemented elsewhere. This way it de-risks the feature while not mandating that the proof-of-concept be Cats IO. This lets contributors to many effect libraries (Monix, ZIO, Trane) work on the hierarchy without having to donate free resources to maintaining Cats IO. I would also hope that before changing the hierarchy in any significant, we hear buy-in or at least get a debate from several effect library authors.

The 2.0 hierarchy will hopefully imply the following variations:

  • Exceptional <-> Unexceptional
  • Typed Errors <-> Untyped Errors
  • Synchronous <-> Asynchronous

The existing Cats IO will be occupying but a single point in this landscape: exceptional, untyped errors, asynchronous (well, and maybe synchronous too, depending on what happens with SyncIO). Other libraries (or a far larger, more comprehensive, and standalone Cats IO library) will have to fill out the other points in the space. Cats-effect proper should not bias toward any point in the landscape but ensure good interoperability among implementations across the landscape.

@alexandru

This comment has been minimized.

Copy link
Member

commented Sep 25, 2018

I like this conversation, a lot of good points here.

The maintenance burden is a good point. I don't mind maintaining both, but I can definitely see how a ZIO contributor might not be happy with having to maintain another IO implementation and I'm a strong believer in allowing multiple implementations to coexist, which is why I joined the project after Daniel introduced it.

But testing with one implementation, instead of zero (even if it biased our laws) is another good point.

Just one thought in support of what @jdegoes says ... what I worry about is that the type classes describe cats.effect.IO and at this point we have no capability described by the type classes that is not already supported by cats.effect.IO.

This isn't necessarily a good thing. If we want type class alternatives for bifunctor IO, I still want a cats.effect.IO that doesn't do typed errors, that's not a bifunctor. I like my errors to be "dynamic".
But then that's going to be a point of no return since cats.effect.IO will not be able to support the bifunctor type classes.

Another example I'm thinking of is a proposal for a Continual type class.

I think that the Continual model makes certain encodings possible that aren't otherwise if you restrict resource usage to bracket and I'm finding more and more samples everyday where not having auto-cancelable flatMap loops makes things more reasonable. For example we recently discovered that the Bracket[WriterT] instance is broken and the tests aren't catching it because it cannot be reproduced with cats.effect.IO, because this IO does not auto-fork and so the problem is very non-deterministic (can hardly be reproduced with Monix's Task and only in certain conditions)... #374 (comment)

I don't want to discuss the Continual model right now, auto-cancelable flatMap loops have won by default. But I would find value in this as an "optional" type class, such a feature being opt-in. And it would be an example of a feature that we cannot support for all implementations of Async. ZIO certainly doesn't want it by all indications, given my dialogs with John.

We can certainly use our imagination to think of features that we may not be able to support in certain implementations. So the value of having one type to test the implementation of the type classes against will certainly go down over time.


I haven't given much thought to this, just commenting on these 2 arguments here. Maybe I'll have a stronger opinion post Monix 3.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Nov 28, 2018

I am at the point where I want to open a pull request to cats-effect that begins work on 2.0.

Is there any consensus about whether we can pull cats-io out into a separate cats-io repository under typelevel?

It further occurs to me that there are actually 3 levels of functionality provided by cats-effect:

  1. Type classes and laws
  2. Data structures derived from type classes
  3. The Cats IO data type

All of these evolve naturally at different rates. One can imagine (2) growing considerably, acquiring FS2 Queue or other structures currently in Monix catnap.

I'd argue this naturally leads to 3 different repositories:

  • cats-effect — Type classes and laws
  • cats-concurrent — Generic concurrent structures derived from type classes (honestly catnap is a good name 😆)
  • cats-io — A concrete implementation of some type classes from cats-effect

However, my ask is only for pulling out IO into cats-io for cats-effect 2.0 (although I see advantages in pulling out cats-concurrent, too). As recent events indicate—random order of finalizers, duplicate running of finalizers, memory leaks with applicative operators, deadlocks, etc.—Cats IO has become complex and non-trivial, far from a simple reference implementation, and placing the burden of maintaining this (opinionated and complex) data type on all contributors of cats-effect will discourage contribution.

@mpilquist

This comment has been minimized.

Copy link
Member

commented Nov 28, 2018

I'd like to start with 2 (or 3) modules in the same project to keep development easier during 2.0 lifecycle, at least to start. That doesn't mean we release 2.0 that way -- I'm open to separate repos eventually. How does that sound?

@jdegoes

This comment has been minimized.

Copy link
Author

commented Nov 29, 2018

@mpilquist

I'm on board with that, so long as there is no requirement for contributions to cats-effect 2.0 to be accompanied by corresponding changes to Cats IO (although I do think a reasonable requirement for any new functionality in any type class is that it be already implemented and tested in Monix, ZIO, Trane, or Cats IO, as proof of viability).

@alexandru

This comment has been minimized.

Copy link
Member

commented Nov 29, 2018

👍

Just enumerating the current, urgent issues that need to be addressed by breaking binary compatibility:

  1. we discovered (the hard way) that any data type that registers a callback internally, to call later, needs to do a logical fork upon calling that callback; we are talking of MVar, Deferred, Semaphore; the breaking change is that they require ContextShift
  2. we probably need shift in Concurrent and maybe we can express it as F.start(F.unit).flatMap(_.join) and this implies that we need to make Fiber.join to guarantee that shift will happen (n.b. if the thread executes fast enough, then on a fiber.join.flatMap(f) that bind continuation can be executed on the current thread ... but expressing this as a law that can serve as a useful unit test is next to impossible
  3. expressing MVar, Semaphore and Deferred as abstract class is nice, as I could implement them in Monix, however we've made the mistake of not adding the F[_]: Concurrent restriction to those interfaces, which means that we cannot add new, derived operations without breaking binary compatibility, so the abstract class trick is next to useless

Depending on the timeline of Cats 2.0.0, which I heard was scheduled for Q1 2019, we might want to do these fixes earlier ... certainly we need to synchronize, @LukaJCB can keep us in loop 🙂

On the project split, we've got these issues:

  1. we would need to redesign Effect and ConcurrentEffect, because they depend on IO and SyncIO
  2. we've had problems in the past with overreaching laws, testing the laws will be challenging
@jdegoes

This comment has been minimized.

Copy link
Author

commented Nov 29, 2018

^^^

Note all of the above is yet another reason why cats-effect should really be:

  • cats-effect
  • cats-concurrency
  • cats-io

In this case, most likely cats-effect would not have to be upgraded to 2.0 just to fix problems in the concurrent structures. As it is, if a Cats 2.0 is necessary for urgent fixes (and indeed, it is necessary to fork all callbacks 100% of the time), then from a user point of view, it will be a mostly "useless" change, as the API will be identical or nearly identical, only containing some fixes to concurrent structures that the user may or may not use.

The original philosophy behind Cats was "modularize" to lessen these types of problems.

@alexandru

This comment has been minimized.

Copy link
Member

commented Nov 30, 2018

Btw on the issue of Deferred, MVar and Semaphore, I think I arrived at a reasonable solution based on just start: #424

Having a ContextShift would be better for performance, because start is expensive, but on the other hand frozen callbacks are not the happy path.

So I think we can live with this until a future version that adds a ContextShift as a requirement.

@SystemFw

This comment has been minimized.

Copy link
Collaborator

commented Dec 4, 2018

Through our usual commendable discipline, a very in depth discussion about this very issue is happening...on another issue ( 😬 ) . Therefore, linking #430 for posterity.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Dec 20, 2018

Attached is a first stab at a proposed design for Cats Effect 2.0.

Landscape

  • IO : Implements up to AsyncEffect for E =:= Throwable
  • SyncIO : Implements up to SyncEffect for E =:= Throwable
  • UIO : Implements up to AsyncEffect for E =:= Nothing
  • BIO : Implements up to AsyncEffect2
Error Type Guaranteed Errorful Temporal Concurrent Sync Async RunSync RunAsync
IO Throwable
SyncIO Throwable 𐄂 𐄂
UIO Nothing
Error Type Guaranteed2 Errorful2 Temporal2 Concurrent2 Sync2 Async2 RunSync2 RunAsync2
BIO Polymorphic

Notes

  1. Async effects provide no guarantees as to where they execute, unless evalOn is used, in which case they always execute on the specified context (in a compositional fashion), and any use of an async operation will merely hand off execution to the specified execution context, rather than execute in the async frame.
  2. ConcurrentData introduced because many default implementations can be provided with Ref and Deferred.
  3. Cats IO does not appear anywhere in the hierarchy nor does any way to run an IO (beyond kicking off an async task). This functionality can be described by a "runtime system"-style interface, which is not a type class. Design TBD.
  4. Rather than fixing the error type to Throwable, it's kept polymorphic everywhere, which lets UIO share the same hierarchy as IO. Syntax can be defined for any case where E =:= Nothing to simplify some operations.
  5. Bracket can be implemented purely in terms of guarantee, uninterruptible, and ConcurrentData, so guarantee is taken as the fundamental primitive, because it's more compatible with UIO. Of course an implementation can override the default if the runtime directly supports bracket.
  6. Effectful type classes occur at the leaves of the hierarchy to permit maximal parametric reasoning.
  7. shift deleted due to poorly-defined semantics in combination with async. Instead use evalOn.
  8. Adapters can be provided so that all mono effects have default instances for the bio type classes; and all bio effects have default instances for the mono type classes. Thus any effect, whether mono or bio, will share in the whole type class hierarchy, allowing users to choose the form that suits them.
  9. In general the type classes will have more methods than shown here but they will all have defaults.

package cats.effect.mono

    trait Guaranteed[F[+_]] extends Applicative[F] {
      def guarantee[A](fa: F[A], f: F[Unit]): F[A]
    }

    trait Errorful[E, F[+_]] extends Monad[F] with Guaranteed[F] {
      def raiseError(e: E): F[Nothing]

      def redeem[A, B](fa: F[A])(err: E => F[B], succ: A => F[B]): F[B]
    }

    trait Temporal[E, F[+_]] extends Errorful[E, F] {
      def sleep(duration: Duration): F[Unit]

      def now: F[Instant]
    }

    trait Concurrent[E, F[+_]] extends Temporal[E, F] {
      def start[A](fa: F[A]): F[Fiber[F, E, A]]

      def uninterruptible[A](fa: F[A]): F[A]

      def yieldTo[A](fa: F[A]): F[A]

      def evalOn[A](fa: F[A], ec: ExecutionContext): F[A]

      // All methods that can be implemented in terms of the above (race, par, etc.)
    }

    trait Sync[E, F[+_]] extends Errorful[E, F] {
      def delay[A](a: => A): F[A]
    }

    trait RunSync[E, F[+_]] extends Sync[E, F] {
      def runSync[G[+_], A](fa: F[A])(implicit G: Sync[E, G]): G[A]
    }

    trait Async[E, F[+_]] extends Sync[E, F] {
      def async[A](k: (F[A] => Unit) => Unit): F[A]

      def asyncF[A](k: (F[A] => Unit) => F[Unit]): F[A]
    }

    trait RunAsync[E, F[+_]] extends Async[E, F] {
      def runAsync[G[+_], A](fa: F[A], k: Either[E, A] => G[Unit])(implicit G: Sync[E, G]): G[Unit]
    }

    trait Fiber[F[+_], E, A] {
      def cancel: F[Option[Either[E, A]]]

      def await: F[Option[Either[E, A]]]

      def join: F[A]
    }

    trait ConcurrentData[F[+_]] {
      def ref[A]: F[Ref[F, A]]
      def deferred[A]: F[Deferred[F, A]]
    }
  }

package cats.effect.bio

    trait Guaranteed2[F[+_, +_]] extends Bifunctor[F] {
      def applicative[E]: Applicative[F[E, ?]]

      def guarantee[E, A](fa: F[E, A], f: F[Nothing, Unit]): F[E, A]
    }

    trait Errorful2[F[+_, +_]] extends Guaranteed2[F] {
      def monad[E]: Monad[F[E, ?]]

      def raiseError[E](e: E): F[E, Nothing]

      def redeem[E1, E2, A, B](fa: F[E1, A])(err: E1 => F[E2, B], succ: A => F[E2, B]): F[E2, B]
    }

    trait Temporal2[F[+_, +_]] extends Errorful2[F] {
      def sleep(duration: Duration): F[Nothing, Unit]

      def now: F[Nothing, Instant]
    }

    trait Concurrent2[F[+_, +_]] extends Temporal2[F] {
      def start[E, A](fa: F[E, A]): F[Nothing, Fiber2[F, E, A]]

      def uninterruptible[E, A](fa: F[E, A]): F[E, A]

      def yieldTo[E, A](fa: F[E, A]): F[E, A]

      def evalOn[E, A](fa: F[E, A], ec: ExecutionContext): F[E, A]

      // All methods that can be implemented in terms of the above (race, par, etc.)
    }

    trait Sync2[F[+_, +_]] extends Errorful2[F] {
      def delay[A](a: => A): F[Nothing, A]
    }

    trait Async2[F[+_, +_]] extends Sync2[F] {
      def async[E, A](k: (F[E, A] => Unit) => Unit): F[E, A]

      def asyncF[E, A](k: (F[E, A] => Unit) => F[Nothing, Unit]): F[E, A]
    }

    trait RunSync2[F[+_, +_]] extends Sync2[F] {
      def runSync[G[+_, +_], E, A](fa: F[E, A])(implicit G: Sync2[G]): G[E, A]
    }

    trait RunAsync2[F[+_, +_]] extends Async2[F] {
      def runAsync[G[+_, +_], E, A](fa: F[E, A], k: Either[E, A] => G[Nothing, Unit])(implicit G: Sync2[G]): G[Nothing, Unit]
    }

    trait Fiber2[F[+_, +_], E, A] {
      def cancel: F[Nothing, Option[Either[E, A]]]

      def await: F[Nothing, Option[Either[E, A]]]

      def join: F[E, A]
    }

    trait ConcurrentData2[F[+_, +_]] {
      def ref[A]: F[Nothing, Ref2[F, A]]
      def deferred[E, A]: F[Nothing, Deferred2[F, E, A]]
    }
@LukaJCB

This comment has been minimized.

Copy link
Member

commented Dec 20, 2018

Thanks for coming up with that @jdegoes, looks good so far, a couple of questions:

  • Why the variance annotations in the type classes? I think there were some mean pitfalls that came with that IIRC
  • Why Guaranteed instead of Bracket? It seems simpler, but what are the trade-offs?
  • Do we need Errorful if we already have MonadError? Seems like a duplicate.
  • Should SyncEffect really extend from Concurrent, or is that just an oversight?
@SystemFw

This comment has been minimized.

Copy link
Collaborator

commented Dec 20, 2018

Why Guaranteed instead of Bracket? It seems simpler, but what are the trade-offs?

One initial tradeoff I can see is that things that now need Bracket will potentially need Guaranteed: Concurrent. I haven't thoughts this through but it seems problematic to me, at first glance.

EDIT: you need Concurrent to access uninterruptible, without it Guaranteed is not equivalent to Bracket I don't think.

@LukaJCB

This comment has been minimized.

Copy link
Member

commented Dec 20, 2018

One initial tradeoff I can see is that things that now need Bracket will potentially need Guaranteed: Concurrent.

How so? Concurrent extends Guaranteed, no?

@SystemFw

This comment has been minimized.

Copy link
Collaborator

commented Dec 20, 2018

Ok, then let me rephrase: things that now need Bracket will need Concurrent :P

EDIT: I'm actually not sure whether this is a problem or not, but it's worth noting.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Dec 20, 2018

Why the variance annotations in the type classes? I think there were some mean pitfalls that came with that IIRC

I've made peace with variance annotations, because they lead to substantially improved type inference. Some bugs have been fixed in 2.x, there is willingness to fix more, and Scala 3 has consistently fewer variance bugs. IMO cats-effect 2.0 should be forward-looking.

Why Guaranteed instead of Bracket? It seems simpler, but what are the trade-offs?

In order to precisely state requirements on bracket, it is necessary to discuss interruption (neither acquire nor release may be interrupted), which doesn't appear nor make sense until Concurrent. Some types that don't permit interruption could nevertheless permit guarantee (e.g. Either). Further, guarantee is more primitive, being the pure version of finally, and using it here helps ensure all the effect types have the same implementation of bracket.

Do we need Errorful if we already have MonadError? Seems like a duplicate.

I'm OCD and wanted symmetry between mono and bio. 😆

Should SyncEffect really extend from Concurrent, or is that just an oversight?

Oops. Will fix.

EDIT: Actually this is ok. I know the terminology is different than 1.0 but the idea is that Sync* type classes are not useful parametrically speaking because they can import arbitrary effects; so you have the minimally powerful version in Sync, and the maximally powerful version in SyncEffect; similarly for Async and AsyncEffect. Maybe there's better terminology.

@SystemFw

This comment has been minimized.

Copy link
Collaborator

commented Dec 20, 2018

@jdegoes Where's asyncF? Can it be implemented given Concurrent?

@jdegoes

This comment has been minimized.

Copy link
Author

commented Dec 20, 2018

@SystemFw

Where's asyncF? Can it be implemented given Concurrent?

Unfortunately not (although it's not primitive, it needs a more powerful sync that is not in this hierarchy).

I'll add it to Async.

@mpilquist

This comment has been minimized.

Copy link
Member

commented Dec 21, 2018

FWIW, FS2 still uses covariant type params -- Pure and INothing are just almost-bottom-types to work around Scala 2 inference issues. In the topic/tagless2 branch, I pushed covariance all the way in to the core interpreter (in series/1.0 branch, we use covariant params on Stream and Pull but keep the internal FreeC + Algebra invariant.

Not sure what you mean by polyvariance if there are no variance annotations on type constructors?

@djspiewak

This comment has been minimized.

Copy link
Collaborator

commented Dec 21, 2018

Not sure what you mean by polyvariance if there are no variance annotations on type constructors?

class Cov[+A]
class Inv[A]
class Cont[-A]

def foo[F[_]] = ???

foo[Cov]
foo[Inv]
foo[Cont]

def bar[F[+_]] = ???

bar[Cov]
bar[Inv]     // does not compile
bar[Cont]    // ...neither does this

Basically, the absence of variance annotation on a concrete definition means invariance. In a polymorphic definition, the absence of a variance annotation means any variance. In both cases, the presence of an annotation means precisely and exactly only what is stated by the annotation. I'm not aware of a way to define an invariant polymorphic type constructor (though it may be possible by doing something funky with subtyping both co- and contravariant versions).

@jdegoes

This comment has been minimized.

Copy link
Author

commented Dec 21, 2018

Errorful is literally MonadError with the addition of the Guaranteed implication. Just as with the abomination of Catchable back in the day, I think a very strong justification needs to be given for introducing such a redundant typeclass, particularly as this one is actually parametric

I think it makes sense for symmetry and for the finalization guarantee (which is not provided by MonadError, but is essential to writing correct code in the presence of modern interruption).

Though honestly I don't care. It'll have to exist for the bifunctor version.

Adding E to every mono typeclass in this fashion doubles the type parameters in every generic signature which uses cats-effect typeclasses.

Prematurely specializing the error type to Throwable is both unnecessary (eliminating possible implementations of these type classes that use different error types) and actively detrimental to UIO, supporting which is a design goal for Cats Effect 2.0.

I would recommend un-making your piece with variance annotations on polymorphic variables as used in this fashion, unless there's a more concrete example (preferably many of them) of how they help.

Scala will not consider F[A] to be a subtype of F[B] when A <: B unless F[+_]. e.g.:

  object busted {
    trait Animal
    trait Dog extends Animal

    trait Test[F[_]] {
      def dog: F[Dog]

      // WILL NOT COMPILE
      // final def animal: F[Animal] = dog
    }
  }
  object fixed {
    trait Animal
    trait Dog extends Animal

    trait Test[F[+_]] {
      def dog: F[Dog]

      final def animal: F[Animal] = dog
    }
  }

which means that without such annotations, one will have to forego numerous default methods in the type classes.

Using variance annotations also constrains concrete types to use appropriate variance, which is a feature IMO.

I agree in theory that Nothing should be the answer here (since it's kind of Scala's only first-class higher-rank skolem) but it interacts so poorly with the rest of the type system that it practically doesn't seem to be worth it.

That's not true when you are using lower-rank Nothing, which is all the above scheme proposes. Having used it quite extensively, I can testify it works wonderfully.

It's not really stipulated by the hierarchy, but I will reiterate that I consider it a fundamentally essential feature that implementors are free to not thread-shift on async.

This is only allowed if a user has not called evalOn. Otherwise, they are required to thread shift to the specified execution context on async resumption. Without this guarantee, evalOn has poorly-defined semantics that lead to numerous implementation difficulties (as many here can testify).

Is there a particular reason aside from Scalaz 8 that the bi-classes are using a different typeclass implication encoding than the mono classes?

Because Monad2 etc. don't exist, so you have to universally quantify on the extra type parameter; i.e. for all E, F[E, ?] must form a Monad.

@djspiewak

This comment has been minimized.

Copy link
Collaborator

commented Dec 21, 2018

Scala will not consider F[A] to be a subtype of F[B] when A <: B unless F[+_]

Obviously I'm aware of what variance is. What I'm asking for is a justification for putting it onto the typeclasses. The syntax function inference point is a good example of what I'm asking for.

Having used it quite extensively, I can testify it works wonderfully.

I mean, we're basically dueling anecdotes here, since I haven't seen such wonderful results in many cases. To be clear, I'm generally very pro-variance on concrete types due to Scala's ADT encoding, but I've been bitten by it frequently and hard in polymorphic contexts.

Because Monad2 etc. don't exist, so you have to universally quantify on the extra type parameter; i.e. for all E, F[E, ?] must form a Monad.

trait Errorful2[F[+_, +_]] extends Monad[F[Nothing, ?]] with Guaranteed2[F]

Edit: Okay I see the problem nvm. Leaving my response above up as a decent starting point for thinking through why it's necessary.

The asymmetry of encoding is kind of annoying, but I don't have a better solution at present.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Dec 21, 2018

What I'm asking for is a justification for putting it onto the typeclasses.

  1. For the bifunctor hierarchy, without variance annotations, you have to leftMap / rightMap over the error / value type to go from a subtype (Nothing or something else) to a supertype, which happens all over, especially in many of the derived operations (with default implementations) that will be defined in these type classes. This is painful and imposes unnecessary performance overhead.
  2. Variance annotations in the type class ensure the data types use appropriate variance annotations. IMO it should be an error to attempt to define an instance for a data type that is invariant.

You'll see some of the effects of (1) even in the mono hierarchy, e.g. never, raiseError, etc., should return F[Nothing] (which precisely describes the fact that they do not return values), but if you don't have a variance annotation (F[+_]), then any derived operation defined in the type class that uses these operations will have to map over Nothing explicitly. It's just much more pronounced in the bifunctor hierarchy because of the additional polymorphism.

I mean, we're basically dueling anecdotes here, since I haven't seen such wonderful results in many cases.

Concrete examples would be useful. F[Nothing] for F[+_] has zero inference issues as far as I'm aware (and I've used this and the polymorphic bifunctor version, as have other ZIO users).

@kaishh

This comment has been minimized.

Copy link

commented Dec 22, 2018

If cats were to define Monad2 and other bifunctor classes and low priority Monad2[F] -> Monad[F[E, ?]] instances inside Monad companion object, then bifunctor and mono hierarchies could be compatible without the import tax on the users

@jdegoes

This comment has been minimized.

Copy link
Author

commented Dec 22, 2018

@kaishh At the very least we can define:

implicit def Errorful2ImpliesMonad[F[_, _]: Errorful2, E]: Monad[F[E, ?]] = ...

inside of cats.effect.bio package object.

@LukaJCB

This comment has been minimized.

Copy link
Member

commented Dec 22, 2018

@kaishh At the very least we can define:
inside of cats.effect.bio package object.

I think it should be this instead:

implicit def Errorful2ImpliesMonad[F[_, _]: Errorful2, E]: Monad[F[E, ?]] = ...
@jdegoes

This comment has been minimized.

Copy link
Author

commented Jan 2, 2019

This might be a nice addition after Concurrent:

    trait Cont[E, F[_+]] extends Concurrent[E, F] {
      def cont[A](r: (F[A] => F[Unit]) => F[Unit]): F[A]
    }

There are some problems that can use this pure version that do not require the (parametric-reasoning-destroying) power of Async. Although it's no more powerful than Deferred which you could get separately with ConcurrentData, so... 🤷‍♂️

@gvolpe

This comment has been minimized.

Copy link
Contributor

commented Jan 12, 2019

+1 on your proposal @jdegoes !

I know that naming is hard but here's my 2 cents:

Temporal -> I honestly like Timer much more.
Errorful -> MonadError + Guaranteed for what I can see? As others mentioned.

And a few suggestions for the most difficult one ConcurrentData:

  • Atomic or AtomicState
  • ConcurrentState (though just Ref might be represented by this name)
  • RefPromise or RefDeferred

To be fair if we can't find a proper name I think the last option will do just fine.

@johnynek

This comment has been minimized.

Copy link
Contributor

commented Feb 1, 2019

As a note, I hope we will try to minimize the breakage when we go to 2.0. Each break is a cost and barrier to adoption. For large industrial users, this can mean a very painful process which doesn’t get done for months or in some cases years.

Changing cats.effect.IO to cats.io.IO to me is an example of a gratuitous change.

I hope we can have as much source compatibility as possible when we go to 2.0 and not see it as a chance to revisit any wart.

@johnynek

This comment has been minimized.

Copy link
Contributor

commented Feb 1, 2019

As a second note, even cats 2.0 will be binary compatible with cats 1.0 for scala 2.12. Part of the success of cats has been this long commitment to binary compatibility which minimizes pains wit it being at the bottom of many dependency graphs.

@dwijnand

This comment has been minimized.

Copy link
Contributor

commented Feb 2, 2019

If one can maintain binary and source compatibility I think that's great.

But I hope that with the achievements of Scalafix rewrites we can deal with package name changes.

Also if/when one breaks source or binary compatibility I would suggest changing the package name entirely, either to a new one (eg cats.effect -> cats.io) or a modification on the origin (eg cats -> cats3, if v3 changes source and/or binary compatibility).

@jdegoes

This comment has been minimized.

Copy link
Author

commented Feb 2, 2019

The whole point of Cats Effect 2.0 is to learn from the history of the "first ever" abstraction over effects in Scala, and to improve the design. Breaking backward compatibility is a feature not a bug because it lets us make a useful thing better.

The 1.x line could be maintained for some time if people so desire, but the major version lines are specifically intended for breaking changes and will definitely require investment from larger users. Play requires significant changes on x.X.x version changes, I don't think it's unreasonable to think Cats Effect might require significant changes on X.x.x version changes.

Also big 👍 on package name changes being desirable in the presence of breaking changes, as per @dwijnand. It makes it easier to do changes incrementally in big projects or even have conversion layers, with both old and new libraries living side by side for a while (the importance of this cannot be overstated). It's a huge pain to do major upgrades when the package / module names don't change because it means you need a "big bang" style refactoring (project jigsaw notwithstanding).

Lastly remember if we don't innovate here, then people will innovate elsewhere, which will just kill this project. That's how most projects die. They're too afraid to innovate because of the work required to upgrade, which means they become slaves to old designs that no longer optimally meet the needs of users, and they end up being replaced by newer designs that do.

@dwijnand

This comment has been minimized.

Copy link
Contributor

commented Feb 2, 2019

It makes it easier to do changes incrementally in big projects or even have conversion layers, with both old and new libraries living side by side for a while (the importance of this cannot be overstated).

Seeing as you mentioned it, with the given "state of the art" of artifact resolution you need the two libraries to have distinct artifactIds (or groupId).

@johnynek

This comment has been minimized.

Copy link
Contributor

commented Feb 2, 2019

I think the needs for stability are different based on how low in the dependency graph you go. For instance, a CLI parsing library is generally used at the "top layer" of an app, and will rarely show up in the transitive dependencies.

By contrast, we have positioned cats-effect as a core library that projects like fs2, doobie, finch, monix, etc... For users that have large projects that may use all of these, a breaking change means not only changing their own code, but waiting for all their dependencies to update.

I think it is a major failing of OSS software that we can't be quantitative about this since the library author does not see the transitive pain. The community build helps scalac see this with library/compiler changes, maybe we could find a way to measure the impact of a change using the community build.

I find it glib to simply argue for breakage as though these costs are zero. We want a cost/benefit analysis ideally: we want the benefits of change, of course. How can we minimize the cost?

I am arguing that each binary and source change is an additional (huge) cost downstream. I'm advocating for minimizing those since I hope we want to minimize the pain we put on our users (and diamond dependency situations we complicate).

To address two issues:

  1. scalafix: many people run builds that cannot use scalafix. I don't think we really have a track record of this being smooth for users.
  2. I'd love to see us commit to never breaking binary compatibility for a given namespace/artifact. As discussed here, we could use cats.effect2. as the package and similarly with artifact id. This minimizes the chance for a pain due diamond dependency where one transitive dependency wants cats 1.0 but another wants cats 2.0. We could even make Effect/ConcurrentEffect instances in 1.0 for 2.0 and vice-versa, so someone could use both in the same application if needed due to transitive dependency issues.
@LukaJCB

This comment has been minimized.

Copy link
Member

commented Feb 2, 2019

Let's have an honest discussion over what is actually going to break. At the moment I see the following breakages.

  • When we separate the FFI (Sync, Async) from Concurrent.
  • Effect and ConcurrentEffect will need to be reworked if IO is moved to another package.
  • (?) Will we be able to keep Ref, Deferred etc. source compatible?
@alexandru

This comment has been minimized.

Copy link
Member

commented Feb 5, 2019

I wholeheartedly agree that breakage shouldn't be taken lightly.

And we won't break anything without thorough justification that outweighs the breakage.

As a general rule (that should be agreed upon of course) is that all changes need to be piecemeal such that they can be discussed individually and not in bulk. We are not going to rewrite Cats-Effect from scratch.

I'd love to see us commit to never breaking binary compatibility for a given namespace/artifact

Would like to see this happen too, however it's a cultural issue and I'd like to see examples of ecosystems that don't break compatibility, such that we can copy them.

I know that Clojure is close, however they don't have our binary compatibility problems, plus being a dynamic language, they also don't have the type "correctness" problem that we do, or in other words the potential for breakage in Scala or any statically typed language is much, much higher.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Feb 7, 2019

As a general rule (that should be agreed upon of course) is that all changes need to be piecemeal such that they can be discussed individually and not in bulk. We are not going to rewrite Cats-Effect from scratch.

Or maybe we are. It depends on whoever submits pull requests. 😄

@johnynek

This comment has been minimized.

Copy link
Contributor

commented Feb 7, 2019

@jdegoes if you want to rewrite from scratch, why not start a new project? This kind of plan injects a lot of risk into adopting cats-effect. I hope that's not your intent.

@jdegoes

This comment has been minimized.

Copy link
Author

commented Feb 7, 2019

@johnynek

I love the idea of separate artifact ids and package names and possibly a backward compatibility layer for 1.0 users (e.g. emulate the semantics of 1.0 on top the 2.0 API). But I very much intend my own contributions to 2.0 (including the API I've sketched out in #321) to be semantically backward incompatible with 1.0, because there are things we've learned about 1.0 and we can use this information to make the semantics of 2.0 noticeably better.

It will also be good to divvy up the work, I think, so those who have a vested interest in the 1.0 interfaces / API surviving can help contribute to the emulation layer. I'm sure this doesn't apply to you, but too often giant companies are willing to endlessly complain about the pain of migration, stifling innovation in the broader ecosystem, without being willing to contribute and give back to open source.

Focusing on an emulation layer allows big companies to contribute to what matters to them, while not negatively impacting other contributors—and avoids the unfortunate situation wherein a company benefits their own productivity by demanding free labor from unpaid volunteers.

Obviously this is just my perspective, but as with any open source project, work gets done by the people willing to do it. Cats Effect 2.0 needs major changes, I have a very clear idea of what I think those changes should be (which has broad consensus), and I'm willing to help make that happen in a way that works for all of us, even the big companies—but it may require big companies pitch in.

@dwijnand

This comment has been minimized.

Copy link
Contributor

commented Feb 7, 2019

the potential for breakage in Scala or any statically typed language is much, much higher

I can believe that's true, but I hope it's not taken as an excuse for breaking binary APIs.

@ajaychandran

This comment has been minimized.

Copy link
Contributor

commented Mar 1, 2019

Suggestions for names.

Errorful -> Fallible
ConcurrentData -> Shared (or Atomic as proposed by @gvolpe)

+1 on all other names proposed by @jdegoes

@jdegoes

This comment has been minimized.

Copy link
Author

commented Mar 23, 2019

I found a class of effects that is less powerful than Sync in a useful way: namely, the class of allocation of (mutable) memory.

The interesting thing about this class is that such effects can be safely retried without influencing the external world (since any unreferenced memory will simply be garbage collected).

I think it's worth considering adding an intermediate with a def alloc[A](a: => A): F[Nothing, A] capability.

@djspiewak djspiewak changed the title Cats Effect 2.0 Design Discussion Cats Effect 3.0 Design Discussion May 12, 2019

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.