restrict traverse_ and friends to require Unit - #4352
Conversation
|
by the way -- the PR template asked me to run I also think @djspiewak had some insight about a potentially better way to accomplish this. |
|
So I have a more controversial idea: what if we just change |
|
I do think it would be better for these Also, it composes nicer: that said... it is also just foldMap with the Monoid coming from |
| /** | ||
| * Variant on travarse_ which enforces the "action or effect" expectation. | ||
| */ | ||
| def traverseEach[G[_], A](fa: F[A])(f: A => G[Unit])(implicit G: Applicative[G]): G[Unit] = |
There was a problem hiding this comment.
I personally don't love these aliases where two functions are actually always exactly the same.
There was a problem hiding this comment.
Hence why I think we should just replace traverse_ in place, since it's binary compatible.
If you happen to have the free |
917c1c1 to
128f801
Compare
When using `traverse_` or `sequence_` to evaluate some applicative effect `G[B]` within the context of a foldable structure, any `B` value is thrown away. Per the scaladoc for `traverse_`, these functions expect that the `G[B]` is primarily a side effect or action. The scala convention for expressing this expectation is to require a `Unit` parameter. Teach `traverse_`, `sequence_`, and their parallel and nonempty analogues to follow this convention by accepting only `G[Unit]` instead of simply any `G[B]`. Furthermore, update the implementations of `traverse_` and `nonEmptyTraverse` to use `foldMapA` and `reduceMapA` respectively. Since requiring Unit gives us a (trivial) `Monoid` for free, it turns out that in the presence of such a monoid, the `***MapA` functions are doing exactly the same thing as the existing implementations of `traverse_` and `nonEmptyTraverse_`.
128f801 to
a9a0879
Compare
|
I'm convinced and have updated the branch. It's a bigger change now of course but it did pass To the point about Because |
| } | ||
| }.value | ||
| def traverse_[G[_], A](fa: F[A])(f: A => G[Unit])(implicit G: Applicative[G]): G[Unit] = | ||
| foldMapA(fa)(f) |
There was a problem hiding this comment.
I'm afraid this change is not compatible. Although we can change the signature, we need to keep the current implementation. See the test I added in 41439fc.
sbt:root> binCompatTest/test
catsBC.MimaExceptionsTest:
==> X catsBC.MimaExceptionsTest.is binary compatible 0.566s java.lang.ClassCastException: class java.lang.String cannot be cast to class scala.runtime.BoxedUnit (java.lang.String is in module java.base of loader 'bootstrap'; scala.runtime.BoxedUnit is in unnamed module of loader sbt.internal.ScalaLibraryClassLoader @15aec8f1)
at cats.instances.EitherInstances$$anon$2.$anonfun$map2Eval$2(either.scala:95)
at scala.util.Either.map(Either.scala:382)
at cats.instances.EitherInstances$$anon$2.$anonfun$map2Eval$1(either.scala:95)
at cats.Eval.$anonfun$map$1(Eval.scala:78)
at cats.Eval$.loop$1(Eval.scala:379)
at cats.Eval$.cats$Eval$$evaluate(Eval.scala:384)
at cats.Eval$FlatMap.value(Eval.scala:305)
at cats.instances.ListInstances$$anon$1.traverse_(list.scala:163)
at cats.instances.ListInstances$$anon$1.traverse_(list.scala:37)
at cats.Foldable$Ops.traverse_(Foldable.scala:1050)
at cats.Foldable$Ops.traverse_$(Foldable.scala:1049)
at cats.Foldable$ToFoldableOps$$anon$6.traverse_(Foldable.scala:1075)
at catsBC.MimaExceptions$.isBinaryCompatible(MimaExceptions.scala:58)
at catsBC.MimaExceptionsTest.$anonfun$new$1(MimaExceptionsTest.scala:28)
There was a problem hiding this comment.
Or possibly, this implementation could work:
foldMapA(fa)(a => G.void(f(a)))There was a problem hiding this comment.
yikes -- neither G.void nor even the original implementation seems to make this go away, at least on my local machine. Does that mean the change can't be made with binary compatibility at all?
There was a problem hiding this comment.
Damn, really? I thought it should be possible, if done carefully. But maybe not.
Also, maybe it's not worth the risk since clearly this is not easy to reason about.
There was a problem hiding this comment.
pushed my branch with (credited) breaking test, and attempt to fix it by restoring original implementations. It didn't work. Wondering if:
a) there is some path to binary compatibility that I don't understand;
b) is it better to add new functions (traverseEach, sequenceEach, maybe traverseEachM for monads) and possibly deprecate the unrestricted variants (traverse_ and sequence_)?
happy to proceed however y'all think best. many thanks @armanbilge for spotting this issue
There was a problem hiding this comment.
btw: what is the motivation for making this change?
foldMapA is relatively rarely used, I think. While we have worked out many stack overflow issues around traverse and sequence. Touching these implementations has an excellent chance of introducing stack overflow errors for users I think.
There was a problem hiding this comment.
the motivation was that the implementation is identical so why not reduce repetition; but since it broke binary compatibility (see Arman's added MIMA test, now added to the PR) I reverted it.
| ArbFM: Arbitrary[F[M]], | ||
| ArbXM: Arbitrary[X[M]], | ||
| ArbYM: Arbitrary[Y[M]], | ||
| ArbFGA: Arbitrary[F[G[A]]], | ||
| ArbGU: Arbitrary[G[Unit]], | ||
| ArbFGU: Arbitrary[F[G[Unit]]], | ||
| ArbFXM: Arbitrary[F[X[M]]], | ||
| ArbGB: Arbitrary[G[B]], | ||
| ArbGM: Arbitrary[G[M]], |
There was a problem hiding this comment.
These changes will also cause compatibility problems.
There was a problem hiding this comment.
I assumed the correct answer here would be to leave the prior implicit params alone and just add mine at the end of arglist. the mima check is still complaining in 2.12 and I'm not sure how to resolve it
There was a problem hiding this comment.
Unfortunately we cannot change these signatures at all: no changes, no additions, no removals. It's not specific to Scala 2.12, that one probably just happened to fail first.
See #4324 (comment) for a possible strategy.
| ArbFGA: Arbitrary[F[G[A]]], | ||
| ArbGB: Arbitrary[G[B]], | ||
| ArbFGU: Arbitrary[F[G[Unit]]], | ||
| ArbGU: Arbitrary[G[Unit]], |
Co-authored-by: Arman Bilge <armanbilge@gmail.com>
| def sequence_(implicit F: Foldable[F], G: Applicative[G], ev: A <:< Unit): G[Unit] = | ||
| F.sequence_(fga.asInstanceOf[F[G[Unit]]]) |
There was a problem hiding this comment.
this one is also causing binary compatibility issues, and in this case I don't see how this function can still exist in its current form (i.e. without said evidence) -- I guess it would have to move or something and that would for sure break binary compatibility? not sure if there is a fix here.
There was a problem hiding this comment.
Right, we cannot change the signature of this method. What we can do is add a new ops class:
final class NestedUnitFoldableOps[F[_], G[_]](private val fgu: F[G[Unit]])There was a problem hiding this comment.
so this one has to change then to F.traverse_(fga)(G.void(_))?
There was a problem hiding this comment.
That still allows people to call .sequence on non-unit structures -- I would suggest deprecating it
There was a problem hiding this comment.
though even that seems so knotty I'm not sure it's worthwhile.
honestly, this is my feeling about this PR as a whole. It's obvious at the moment we don't even have existing test coverage to safely make this change.
There was a problem hiding this comment.
if we can't change signatures, what about adding new methods with an appropriate signature and deprecating the old ones?
There was a problem hiding this comment.
I recall I did something similar – i.e. was tuning a method with a very subtle change in its signature while preserving its name: #3997. Not exactly the same though, but there are some similarities apparent.
if we can't change signatures, what about adding new methods with an appropriate signature and deprecating the old ones?
Personally, I don't think it is a good idea, especially because the old methods do not have such bugs that would hinder their usage.
There was a problem hiding this comment.
It's obvious at the moment we don't even have existing test coverage to safely make this change.
Do you mean, there's no tests for traverse_ nor sequence_ or do you mean some specific test scenarios?
| val gb = f(a) | ||
| G.void(gb) | ||
| val fb = f(a) | ||
| G.void(fb) |
There was a problem hiding this comment.
we don't need this now. fb: G[Unit] so the void is a no-op.
There was a problem hiding this comment.
We absolutely do, or the change is not compatible. See #4352 (comment).
There was a problem hiding this comment.
I don't follow you. My claim is that f(a): G[Unit] already. While G.void(f(a)) is still G[Unit], it isn't needed and is likely to be wasteful.
In fact, unconditionally calling void internal to a method is a code smell that should have alerted us the type was poor to begin with.
That said, there could be ergonomic reasons to not have the users required to call this void.
There was a problem hiding this comment.
I don't follow you. My claim is that
f(a): G[Unit]already
Except, it's not really :) sure, that's what the current signature claims, and a signature of G[Unit] is binary-compatible with a signature of G[A] for an unbounded type parameter A (due to type erasure).
But Cats still has to retain compatibility with calling code that was passing e.g. a G[String] there. Otherwise we will get class cast exceptions, exactly as demonstrated in #4352 (comment).
There was a problem hiding this comment.
@johnynek just to spell it out: once the MIMA test was added, returning f(a) without void caused it to break.
There was a problem hiding this comment.
Sorry to be pedantic :) actually, that's not a MiMa test. MiMa compares the binary-compatibility of signatures. MiMa can only detect linking errors but not other errors such as class casting exceptions. In this case there is no linking error, because due to type erasure the method signature is the same.
What I added was a runtime-compatibility test. Cats has an internal test project, that is compiled against Cats 2.0.0 but run against the latest development version of Cats. It shows that code compiled with an old version of Cats, that is not passing G[Unit] to these methods will encounter a class cast exception if we rewrite the code here to assume we are indeed getting a G[Unit].
There was a problem hiding this comment.
thank you for the explanation!
I'd love to understand the team's thoughts and @armanbilge 's in particular on:
a) what's your evaluation of the problem? to be precise: is there value, if we can do it in a binary-compatible way, in alerting users to the code-smell of calling traverse_ and friends with non-Unit values?
b) what do you think of the approach to that problem of adding aliases with the correct signature and deprecating the original methods?
There was a problem hiding this comment.
I think there is value. See this issue I opened proposing a lint for flatTap, which currently suffers from a similar problem.
The linting route offers a safe way to opt-in to this change.
However, there are different perspectives. Quoting @SystemFw from the Discord discussion linked in that issue.
this discussion has happened a few times
it boils down to safety vs boilerplate, because it applies also to>><*, etc
I wouldn't say thatflatTaphavingUnitmakes it more safe in practice, because the purpose offlatTapis to throw theBaway
There was a problem hiding this comment.
In fact, I can make the same exact point here. The whole point of traverse_ is the avoid the boilerplate of calling void, so if I have to do list.traverse_(a => f(a).void), why don't I just do list.traverse(f).void? (And if the answer is, to avoid allocating the list, if you really care about that there is still foldMapA)
That being said, I don't feel strongly about this change :)
| val gb = f(a) | ||
| G.void(gb) | ||
| val fb = f(a) | ||
| G.void(fb) |
There was a problem hiding this comment.
same comment, this void is not needed.
When using
traverse_orsequence_to evaluate some applicative effectG[B]within the context of a foldable structure, anyBvalue is thrown away. Per the scaladoc fortraverse_, these functions expect that theG[B]is primarily a side effect or action. The scala convention for expressing this expectation is to require aUnitparameter.Teach
traverse_,sequence_, and their parallel and nonempty analogues to follow this convention by accepting onlyG[Unit]instead of simply anyG[B].Furthermore, update the implementations of
traverse_andnonEmptyTraverseto usefoldMapAandreduceMapArespectively. Since requiring Unit gives us a (trivial)Monoidfor free, it turns out that in the presence of such a monoid, the***MapAfunctions are doing exactly the same thing as the existing implementations oftraverse_andnonEmptyTraverse_.