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
Add IO#to conversion method #50
Conversation
Codecov Report
@@ Coverage Diff @@
## master #50 +/- ##
=========================================
- Coverage 88.27% 87.9% -0.38%
=========================================
Files 17 17
Lines 273 281 +8
Branches 20 20
=========================================
+ Hits 241 247 +6
- Misses 32 34 +2 |
|
||
override def liftIO[A](ioa: IO[A]): F[A] = { | ||
// Implementation for `IO#to` depends on the `Async` type class, | ||
// and not on `Effect`, so this shouldn't create a cyclic dependency |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's being overridden anyway.
case Pure(a) => F.pure(a) | ||
case RaiseError(ex) => F.raiseError(ex) | ||
case async => | ||
F.async(async.unsafeRunAsync) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should keep threading suspends, rather than just immediately jumping to async. It should be possible to convert an entirely synchronous IO
in terms of entirely synchronous F
constructions, even if the IO
involves flatMap
and suspend
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what you mean. Doesn't unsafeStep
eliminate Suspend
and BindSuspend
?
From what I've seen that branch is hit only if we have an Async
or BindAsync
state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I just realized you're using unsafeStep
. That branch will only be hit at an async point.
I guess it's sort of an open question whether it's better to collapse bind chains or carry them forward into the structure of the target monad. I was originally thinking of the latter implementation, but the former is possible. Both should be lawful.
Removed the WIP status. Changes:
NOTE - I could not do the stack-safety test generically, because this conversion isn't stack safe unless the source is stack safe F.async(source.unsafeRunAsync) And we have a problem that the generator in def genAsync[A: Arbitrary]: Gen[IO[A]] =
arbitrary[(Either[Throwable, A] => Unit) => Unit].map(IO.async(_)) |
I have also updated the PR description. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't surprise me that you can't test the to
using the generic generator. The fact that it also generates stack-unsafe IO
values was intentional; I wanted to ensure that things were generally safe with IO.async
being generated at a moderate probability.
* Converts the source `IO` into any `F` type that implements | ||
* the [[cats.effect.Async Async]] type class. | ||
*/ | ||
final def to[F[_]](implicit F: cats.effect.Async[F]): F[A @uV] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor sidebar: I would rather just write out the whole @uncheckedVariance
here. I don't expect to use the annotation elsewhere, so the value of the alias is minimal and IMO not worth the obfuscation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, will fix.
final def to[F[_]](implicit F: cats.effect.Async[F]): F[A @uV] = | ||
this match { | ||
case Pure(a) => F.pure(a) | ||
case RaiseError(e) => F.raiseError(e) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 This is exactly what I had in mind.
case RaiseError(e) => F.raiseError(e) | ||
case _ => F.suspend { | ||
// Evaluating until finished or the first async boundary | ||
unsafeStep match { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh actually, randomly, I think we could achieve better stack-safety on the conversion (and push more control into the target Effect
) by not using unsafeStep
here. Rather, just pattern match the whole thing and pass along the Bind
cases to the target. That should in theory mean that the stack properties of the resultant should be identical to the input.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what you mean here.
The evaluation of the source IO
should still be handled by the IO
implementation, because we can't translate this into the target F[_]
otherwise:
case BindSuspend(thunk, f) => ???
I mean, besides evaluating our IO
with unsafeStep
, what else can we do with this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Something like this:
def to[F[_]: Effect]: F[A @uV] = this match {
case Pure(a) => F.pure(a)
case RaiseError(e) => F.raiseError(e)
case Suspend(thunk) => F.suspend(thunk.to[F])
case Async(k) => F.async(k)
case BindSuspend(thunk, f) =>
F.flatMap(F.suspend(thunk(()).to[F]))(e => f(e).to[F])
case BindAsync(k, f) =>
F.flatMap(F.async(k))(e => f(e).to[F])
}
The neat thing about this is that ioa.to[IO] === ioa
(as in, will have identical structure to). In so far as it is possible, it gives control over the bind chain to the target monad, and ensures stack safety of the output in all cases where the target monad would guarantee stack safety, regardless of IO
's own guarantees.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, so this is very elegant, however I think it ends up using more memory than the current version, plus I feel that it is making assumption about how F[_]
is implemented, assuming that there's a 1:1 correspondence in those internal states.
The concern here is that this isn't necessarily the case, F[_]
adding overhead of its own, while at the same time inheriting IO
's own overhead (i.e. you still have AndThen
in charge, you still have an AtomicBoolean
in that Async(k)
, etc). I don't have concrete examples right now, this isn't necessarily about Monix's implementation.
If we are converting from IO
to F[_]
I would rather leave IO
's implementation in charge of the actual evaluation.
So besides your implementation being cool 😄 are there any stack safety concerns about the current implementation? Stack safety makes my head hurt, but currently I'm not seeing problems.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My implementation definitely uses some more memory, because unsafeStep
is able to discard intermediate things that get captured in in closures by my implementation. One point at a time…
plus I feel that it is making assumption about how F[_] is implemented, assuming that there's a 1:1 correspondence in those internal states.
I'm not sure I see how? It's basically just assuming that F
is a lawful effect. In fact, it's actually just assuming that F
is a lawful Async
, so we could soften the constraints a little further. If F
can implement flatMap
properly, then there shouldn't be a problem from a semantic standpoint.
Efficiency is the more interesting question. I don't think there's any doubt that the unsafeStep
implementation is faster, because it leaves IO
maximally in control and essentially forces the target Effect
to treat converted actions as black boxes. That removes evaluation control from the target type but does reduce the indirection.
are there any stack safety concerns about the current implementation?
I was thinking there was, since it looked like it was suceptible to the same issue you pointed out on #42, but now I'm not so sure. The only difference between your implementation and mine is that mine preserves the binds into the target type, whereas yours just runs them in-place. That should be exactly the same stack-safety, I think.
The biggest win to my implementation, aside from the fact that it doesn't use any unsafe
functions, is the fact that it allows the target monad to redefine what flatMap
means between actions in the already-constructed IO
. It gives you the flexibility to implement cool things like thread relocation heuristics (e.g. for fairness) and similar, whereas just suspending unsafeStep
eliminates that possibility. Basically, my implementation treats IO
as a free monad that we're interpreting into some other monad, whereas yours treats it as an opaque box that we can evaluate and suspend, but nothing more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It gives you the flexibility to implement cool things like thread relocation heuristics (e.g. for fairness) and similar, whereas just suspending unsafeStep eliminates that possibility
You're right here and Monix's Task
does just that.
I guess we can go ahead with your version and if we bump into problems down the road, we can backtrack.
Question: do you really want F[_]
to have an Effect
constraint? Because it seems to me that this works with just an Async
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: do you really want F[_] to have an Effect constraint? Because it seems to me that this works with just an Async.
Thinking about that exact question right now. to
absolutely can be implemented with just Async
. Will add a second reply here within the hour.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@alexandru Ok, so I think we should just constrain to Async
. The interesting thing this also implies is that Async
should extend LiftIO
, rather than deferring it to Effect
. There's actually almost no difference between Async
and LiftIO
, but we'll keep them separate for the time being. There's no particular harm in the two separate representations.
This works out pretty well, because I think #51 shows that Async
is a generally very easy typeclass to lift, but Effect
is extremely hard. So the more stuff we can usefully constrain solely by Async
, the better.
// Override default generator to only generate | ||
// synchronous instances that are stack-safe | ||
implicit val arbIO: Arbitrary[IO[Int]] = | ||
Arbitrary(Gen.delay(genSyncIO[Int])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of curiosity, with the new implementation, does this work even with the async
-including generator?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it's the same thing.
Depending on F[_]
it might end up being stack safe, but only if F.async
would make it so, but we've established that F.async
shouldn't do that, which sort of makes sense (I was just complaining that we should have two versions of async
in there for the price of 1 :))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thanks.
@alexandru I'll move the |
I've already changed the implementation to yours, plus restricted Yes, |
Tests failed. I'll merge once the build is passing. |
Damn, will fix it in about 1 hour.
…--
Alexandru Nedelcu
alexn.org
|
OK, so I had a faulty generator that was still generating async stuff. I fixed it, works now. |
Feel free to merge if you want. |
This is a follow up on #47
Adding a conversion utility to
IO
that can convert from any type implementing theAsync
type-class:Notes:
F.async(io.unsafeRunAsync)
can be inefficient, besides being dangerous@uncheckedVariance
to avoid introducing aB >: A
type (due to covariance), which would wreck our signature; Scala's standard library also does it for the conversions in the standard collections