-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Remove FlatMapRec make all FlatMap implement tailRecM #1280
Conversation
This is interesting. It would definitely be nice to not have to worry about separate Should/could we have a helper method to create an "unsafe" (meaning not stack-safe) version of |
I really like this! 👍 I think lack of stack safety is one of the biggest barriers to using FP techniques in the large. As @johnynek argued, it's not obvious to me that there are many monads we care about that can't be stack-safe (and if a monad can't be stack safe it's not clear to me how you use it correctly in generic code that needs arbitrary monadic recursion). @ceedubs I would be fine with allowing people to create their own "unsafe instance" as long as we aren't providing them implicitly. This constructor would be safe for types which are internally stack-safe, and unsafe for types which aren't. |
For context: I hit a situation recently where generic code was using The argument I'm making is that any time someone wants to express monadic recursion they should have a method to do so, rather than just using |
I have an issue with this purely on principle - the definition of EDIT I haven't thought it through completely/tried it, but I think if you wrote an algebra in finally tagless it would not be stack safe. If this PR went through, would something like: https://github.com/adelbertc/scala-sandbox/blob/master/tagless-stacksafety/src/main/scala/taglessStackSafety/TaglessSet.scala still be stack safe? Where the |
OK I did some experimenting, it looks like this unexpectedly blows stack, though it's also possible I'm doing it wrong: import cats.{Eval, Monad, MonadRec}
import cats.data.{State, Xor}
import cats.implicits._
trait SafeMonadSet[F[_]] extends MonadRec[F] {
def add(int: Int): F[Unit]
def exists(int: Int): F[Boolean]
}
object SafeMonadSet {
def apply[F[_]](implicit F: SafeMonadSet[F]): SafeMonadSet[F] = F
implicit val stateInterpreter: SafeMonadSet[State[Set[Int], ?]] = new SafeMonadSet[State[Set[Int], ?]] {
def add(int: Int): State[Set[Int], Unit] = State.modify(_ + int)
def exists(int: Int): State[Set[Int], Boolean] = State.inspect(_.contains(int))
def pure[A](x: A): State[Set[Int], A] = State.pure(x)
def flatMap[A, B](fa: State[Set[Int], A])(f: A => State[Set[Int], B]): State[Set[Int], B] = fa.flatMap(f)
def tailRecM[A, B](a: A)(f: A => State[Set[Int], Xor[A, B]]): State[Set[Int], B] =
MonadRec[State[Set[Int], ?]].tailRecM(a)(f)
}
}
trait SafeTaglessSet[A] {
def run[F[_]: SafeMonadSet]: F[A]
}
object SafeTaglessSet {
implicit val instance: MonadRec[SafeTaglessSet] = new MonadRec[SafeTaglessSet] {
def pure[A](x: A): SafeTaglessSet[A] = new SafeTaglessSet[A] {
def run[F[_]: SafeMonadSet]: F[A] = SafeMonadSet[F].pure(x)
}
def flatMap[A, B](fa: SafeTaglessSet[A])(f: A => SafeTaglessSet[B]): SafeTaglessSet[B] = new SafeTaglessSet[B] {
def run[F[_]: SafeMonadSet]: F[B] = SafeMonadSet[F].flatMap(fa.run[F])(a => f(a).run)
}
def tailRecM[A, B](a: A)(f: A => SafeTaglessSet[Xor[A, B]]): SafeTaglessSet[B] = new SafeTaglessSet[B] {
def run[F[_]: SafeMonadSet]: F[B] = SafeMonadSet[F].tailRecM(a)(a => f(a).run)
}
}
def add(int: Int): SafeTaglessSet[Unit] = new SafeTaglessSet[Unit] {
def run[F[_]: SafeMonadSet]: F[Unit] = SafeMonadSet[F].add(int)
}
def exists(int: Int): SafeTaglessSet[Boolean] = new SafeTaglessSet[Boolean] {
def run[F[_]: SafeMonadSet]: F[Boolean] = SafeMonadSet[F].exists(int)
}
} And the usage that blows stack: package taglessStackSafety
import cats.MonadRec
import cats.data.{Xor, State}
import cats.implicits._
object BenchmarkApp extends App {
val M = MonadRec[SafeTaglessSet]
def loop(i: Int): SafeTaglessSet[Xor[Int, Int]] =
if (i == 0) M.pure(Xor.right(i))
else M.pure(Xor.left(i - 1))
val x = M.tailRecM(5000)(loop).run[State[Set[Int], ?]].runA(Set.empty[Int]).value
println(x)
}
|
@adelbertc If you need a monad with broken monadic recursion I think @ceedubs' suggestion of providing an unsafe constructor works fine. I would be surprised if many/most monads can't implement stack-safe monadic recursion in one way or another though. I guess my point is that figuring out which things are (or aren't) stack-safe is subtle and if we can free our users from having to worry about this it can only help the cause of FP in scala. In the same way that @milessabin convinced me to build a trampoline into (Whether users will choose to do the same or not is an open question.) What do other folks think? |
There is at least one subtle issue, for any monad you can implement a (possibly stack unsafe) tailRecM, I can't see a way to do that for any We might more consider a conservative approach:
Since we have so few examples of |
looks like the docs failed. Will look at that. |
Continuing the discussion from here: https://gitter.im/typelevel/cats?at=57aba485ae838f6f569559dd since I'm not sure how often I'll be on Gitter tonight Finally tagless aside, on principle |
In my view, the current facts are playing into my thoughts:
To me the only approach that makes sense is one of the two:
I don't see how we can not do 2 yet argue that 1 is too risky. |
An alternate argument leading to the same place is: cats only supports stack-safe recursive monads. If your monad is cannot be made stack safe with tailRecM, you must trampoline it. So your above example is simple not a supported monad because it is not safe. You must trampoline it or find a trick to implement tailRecM. Given how important recursion is, I think this is a fine position to take. |
What are the pain points specifically? Just multiple constraints?
The example generalizes to any (perhaps naive) implementation of a finally tagless algebra, which I strongly believe is a perfectly valid way to encode EDSLs in Scala. The Cats encoding of type classes already penalizes finally tagless with the ambigous implicits when using type class syntax (as shown in #1210) - this patchset would penalize it even more. Between 1 and 2 I would vote for 2 which clearly delineates when stack safety is expected and when it is not. Having used tagless final EDSLs for the past year, I explicitly know that it's not stack safe but I'm OK with it both because I know the trees are very small and I don't want the overhead associated with At the bottom level my main concern is this delineation and my perhaps stubborn unwillingness to break laws. |
Neither solution seems to be particularly idealistic to me. Having stack-unsafe monads means I can create more lawful monads, or at least create them easily, but it does that by greatly restricting the types of programs I can write with them. Worse, this prohibition can't really be encoded into a law. From the Monad laws, I see no reason why I can't do a recursive flatMap (or have some way of doing it). Instead, this is just some runtime implementation detail I need to keep in my head, along with the existence of MonadRec. Given the number of recursive flatMaps there are, even in cats, it seems like this is not an easy thing to keep in mind. Having safe Monads means we can't (currently?) create some lawful, but stack unsafe monads. At least not without worrying about what code we're breaking because they assume stack-safe monads. However, I'm able to write much more powerful programs with these monads. I also don't need to worry nearly as much about the particular details of the runtime my program will run on. Anyways, we're making compromises in either case (MonadRec is very leaky, stack-safe tailRecM limits the monads we can implement) - we're just wondering which compromises are easier to live with. I believe having |
I think I'd prefer a world where there are clear conventions or whatever that allow me as a library author to say "stack safety is BYOB" for some context, and as a library user to know that I'll need to provide my own trampolining or take some other approach if I need to use a monad with un-stack-safe recursive I don't have any real objections to adding |
This is the same reason why we have
This is my worry. We could as Oscar suggested make the stack-safety law a separate check, but that law is the essence of As Oscar said, most/all data types supported by Cats have a valid |
I guess the question we need to ask is whether we think the collection of types which cannot participate in We could maintain the current monad/rec split, but if we rewrote all of Cats' recursive I'm somewhat biased, since I haven't really used finally tagless algebras (or similar types from the first collection) whereas I often find myself using types from the second collection (e.g.
This is definitely the crux of the matter. I'm not convinced that basing a type class on a clean-but-dangerous definition is the right way to go. For If most people prefer to keep the separation, I could imagine another design that trades trait Recurse[F[_]] {
def recurse[A, B](a: A, f: A => F[A Xor B])(implicit m: Monad[F]): F[B]
} The (small) advantages of this approach are that you don't have a type class dependency diamond, and that you can more easily specify this as a constraint: def applicationLogic[F[_]: MonadEmpty: Recurse](...) = ... |
What are the ugly points? If it's a similar case to #1210 (comment) I think we should aim to solve that more general problem instead of this specific one. We agreed to punt on that one , but it sounds like any nastiness with using, say, As for your Apologies if I'm making things difficult, I just want to make sure we don't muddle fundamental type classes without careful discussion, and want to make sure we're solving the right problem instead of a specific instance of a more general one. |
Yes, I think you're right that we should solve that issue in a general way. I don't think that ugliness is the crux of the argument above though. |
The way I see it, there are two different ways we can talk about possible stack-safety:
Cats has traditionally done (2). The problem here is that there's nothing in the type system that lets the caller know they need a stack-safe monad -- and even if documented, it's easy for upstream callers to fail to pass the documentation along. The proposal here (within Cats at least) is to move to (1), either by adding this requirement to all It seems like either of these options will inconvenience types that are currently sneaking through under (1) though. How do other people feel? Am I exaggerating the scope of the problem? |
So going back to @johnynek 's two proposals (as I understand them):
In the former case, users of Cats which have non stack-safe can still opt-in to In the latter case everything lives in EDIT Just saw your second comment:
I vote for this. Specifically I would like to keep the separation and move uses of monadic recursion in Cats itself to be done through |
@non Apologies for constantly getting in the way, just want to make sure we arrive at a decision having explored most possibilities. What is the meaning of |
@adelbertc The presence of trait Recurse[F[_]]
def mightFailOnRecursion[F[_]: Monad]: F[Unit] = ...
def wontFailOnRecursion[F[_]: Monad: Recurse]: F[Unit] = ... The law for val Limit: Int = ... // some large-ish positive value, possibly F-dependent
F.pure(Limit) <-> F.tailRecM(0, n => F.pure(if (n < Limit) Left(n + 1) else Right(n))) |
Does doing |
@adelbertc Yes, in that it makes it really clunky to use with To turn it around, is there a real concern that someone is going to have multiple |
Sorry, I'm not sure I follow where the clunkiness comes in with the self-type versus not having a self-type. Could you show an example with and without the self-type? |
So maybe I'm wrong, but consider any situation where we can derive a monad in some cases but not others. These are situations where different amounts of evidence allow us to derive different types and we have to use prioritization to avoid implicit ambiguities. In those situations deciding when you can (or can't) add Since If you wanted to only create one instance, you'd be free to mix |
Oh I see, essentially for any monad transformer that has the equivalent of like: implicit def monadOptionT[F[_]: Monad]: Monad[OptionT[F, ?]] = ... and with implicit def monadRecOptionT[F[_]: Monad: Recurse]: Monad[OptionT[F, ?]] with Recurse[OptionT[F, ?]] = ... Is that what you mean? I think I can be convinced of this. I would like to hear @tpolecat 's thoughts on this as well though. |
Right, that's it exactly. |
@adelbertc I can't really weigh in much because I haven't had time to mess with it. My instinct is to not complicate |
For the record the combinatorial explosion of instances for |
okay, I have updated the PR with what I think the suggestions are here. Please take a look. |
* Based on Phil Freeman's | ||
* [[http://functorial.com/stack-safety-for-free/index.pdf Stack Safety for Free]]. | ||
* | ||
* Implementations of this method must use constant stack space. |
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.
We should remove this comment -- implementations are no longer required to do this.
@@ -1,19 +1,52 @@ | |||
package cats | |||
package tests | |||
|
|||
import cats.data.{OptionT, StateT, Xor, XorT} | |||
import cats.data.{ |
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 this file be renamed?
Also can this be added as a law somewhere as opposed to a separate check?
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 agree, but would it be possible that we follow that up in a next PR since this PR and discussion is already getting pretty large? Refactoring the existing tests might be a good line to draw.
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.
Sure, I've created #1283 to track it
Current coverage is 90.88% (diff: 93.10%)@@ master #1280 diff @@
==========================================
Files 243 238 -5
Lines 3293 3369 +76
Methods 3235 3312 +77
Messages 0 0
Branches 56 54 -2
==========================================
+ Hits 2982 3062 +80
+ Misses 311 307 -4
Partials 0 0
|
👍 This looks great to me. Looking forward to following it up with improved laws. Thanks everyone! |
* it is constant stack space, an instance of `RecursiveTailRecM[F]` should | ||
* be made available. | ||
*/ | ||
def tailRecM[A, B](a: A)(f: A => F[A Xor B]): F[B] |
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.
Can we add a comment saying theres a defaultTailRecM
that could be used as a stub/template?
Modulo the 1 comment, LGTM 👍 |
@@ -136,6 +137,9 @@ implicit def kleisliFlatMap[F[_], Z](implicit F: FlatMap[F]): FlatMap[Kleisli[F, | |||
|
|||
def map[A, B](fa: Kleisli[F, Z, A])(f: A => B): Kleisli[F, Z, B] = | |||
Kleisli(z => fa.run(z).map(f)) | |||
|
|||
def tailRecM[A, B](a: A)(f: A => Kleisli[F, Z, A Xor B]) = | |||
Kleisli[F, Z, B]({ z => FlatMap[F].tailRecM(a) { f(_).run(z) } }) |
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.
We should probably write a note somewhere for these tuts why we also have tailRecM
on monad instances. I've added this ticket #1284 to track it
Merged with the two +1s. Thanks everyone for working together to compromise. I think this brings a simpler hierarchy and more opportunities in practice for avoiding stack overflows, which I'm really excited about. |
Looking for comments on this. This addresses #1278
I think all the non-test code compiles. The test code needs to be updated.
After doing this I am more convinced we should remove
FlatMapRec
. It seems we didn't implement it in many cases, and everywhere it seems it can be implemented safely in cats.Given that this gives us the chance to avoid blowing the stack, I think it will be a real advance to just require tailRecM and disallow recursive flatMap in the abstract in favor of calling tailRecM.
I will update the tests next.