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

Add replicateA_, parReplicateA_ #4208

Merged
merged 11 commits into from
Jun 9, 2022
Merged

Add replicateA_, parReplicateA_ #4208

merged 11 commits into from
Jun 9, 2022

Conversation

rabinarai1
Copy link
Contributor

This work was inspired by reading the cats-effect code. We saw that replicateA_ had a TODO message to PR the function to cats, so we decided to try and implement that. We found this difficult to implement in a way that didn't use lots of memory or stack. Then we saw that replicateA_ is missing from other strict functional programming languages like pureScript. So we decided to implement this in the Monad class using tailRecM.

Contributed on behalf of the @opencastsoftware open source team 👨‍💻

@armanbilge
Copy link
Member

Thanks for the PR! Haven't had a chance to look yet, but wanted to link to:

@johnynek
Copy link
Contributor

I think a stack safe and fast replicateA_ is just:

def replicateA_[A](n: Int, fa: F[A]): F[Unit] = {
  val fvoid = void(fa)
  def loop(n: Int): F[Unit] =
    if (n <= 0) unit
    else if (n == 1) fvoid
    else {
      val half = loop(n >> 1)
      val both = productR(half, half)
      if ((n & 1) == 1) productR(both, fvoid)
      else both
    }

  loop(n)
}

This isn't tail recursion, but it is constant depth recursion (it goes at most depth 31 since you are using an Int).

This produces a tree, so it should be shallow depth for the F[_] context as well.

I would prefer we add this to Applicative and not bring Monad into the picture.

@rabinarai1 rabinarai1 changed the title ReplicateM_ ReplicateA_ May 23, 2022
@rabinarai1
Copy link
Contributor Author

@johnynek Thank you for that, I have edited the code to reflect your suggestion! :)

Copy link
Member

@armanbilge armanbilge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! Can we add a law check replicateA_(n, fa) <-> replicateA(n, fa).void?

Btw @johnynek is there rationale to make fa: => F[A] lazy for when n == 0?

@djspiewak
Copy link
Member

This would also imply that parReplicateA is possible, which sounds cool.

test("replicateA_ executes the Applicative action 'fa' 'n' times") {
val A = Applicative[Option]
val fa = A.pure(0)
assert(fa.replicateA_(5) === Option(unit))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a minor bit: can we exercise values other than 5?

Actually, can you make a loop and try all values from 0 up to 10 or something?

@johnynek
Copy link
Contributor

Looking good! Can we add a law check replicateA_(n, fa) <-> replicateA(n, fa).void?

+1

Btw @johnynek is there rationale to make fa: => F[A] lazy for when n == 0?

I wouldn't because it wouldn't match replicateA and I assume n == 0 is a very rare case.

@@ -59,6 +59,9 @@ trait ApplicativeLaws[F[_]] extends ApplyLaws[F] {
def applicativeUnit[A](a: A): IsEq[F[A]] =
F.unit.map(_ => a) <-> F.pure(a)

def replicateAVoidReplicateA_Consistent[A](fa: F[A]): IsEq[F[Unit]] =
F.replicateA_(2, fa) <-> F.replicateA(2, fa).void
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When increasing this number, it causes us to produce large structures in memory when running the tests which caused out of memory errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, even to 3? Even still, I think we should take n: Int as a parameter to the law, and let the tests decide what number to put there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we tried 5 and explosions resulted - if you think about this it makes sense as if the input data structure that is being replicated is already huge, replicateA(5).void will replicate it fivefold!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you want to plumb this number though the laws? Should these laws accept an explicit parameter now? I don't think that would be binary compatible either.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's binary-compatible to add n as a parameter here, after all we are defining a brand new method! :)

What I'm suggesting is we leave the laws general, and do the hardcoding in the tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh I see, I thought you were suggesting to add this parameter to ApplicativeTests.applicative

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rabinarai1 I think if we can make n a parameter of the law then this should be good to go.

I think for the tests it would be good to use a value bigger than n = 2. To keep the structures from getting too large, I wonder if we can use scalacheck tricks like withSize.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@armanbilge we used Gen.resize to resize the generator of F[A]. But even increasing the number to 5 causes out of memory issues.

@rabinarai1
Copy link
Contributor Author

I've amended the code to address all the comments left :) thank you

@rabinarai1 rabinarai1 requested a review from johnynek May 24, 2022 14:44
@@ -43,6 +43,7 @@ trait ApplicativeTests[F[_]] extends ApplyTests[F] {
EqFB: Eq[F[B]],
EqFC: Eq[F[C]],
EqFABC: Eq[F[(A, B, C)]],
EqFUnit: Eq[F[Unit]],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh no, I don't think this change is binary-compatible :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can fix this by deriving:

def EqFUnit(a: A)(implicit Eq[F[A]]): Eq[F[Unit]] = Eq.by(_.as(a))

And then get an A via Arbitrary[A], since we already have Eq[F[A]]

@@ -54,9 +57,15 @@ trait ApplicativeTests[F[_]] extends ApplyTests[F] {
"applicative map" -> forAll(laws.applicativeMap[A, B] _),
"applicative unit" -> forAll(laws.applicativeUnit[A] _),
"ap consistent with product + map" -> forAll(laws.apProductConsistent[A, B] _),
"replicateA_ consistent with replicateA.void" -> forAll { (a: A) =>
// Should be an implicit parameter but that is not a binary-compatible change
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some place that these bincompat hacks are being catalogued? Should we make this a // TODO: cats 3.x comment?

@@ -59,6 +59,9 @@ trait ApplicativeLaws[F[_]] extends ApplyLaws[F] {
def applicativeUnit[A](a: A): IsEq[F[A]] =
F.unit.map(_ => a) <-> F.pure(a)

def replicateAVoidReplicateA_Consistent[A](fa: F[A]): IsEq[F[Unit]] =
F.replicateA_(2, fa) <-> F.replicateA(2, fa).void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rabinarai1 I think if we can make n a parameter of the law then this should be good to go.

I think for the tests it would be good to use a value bigger than n = 2. To keep the structures from getting too large, I wonder if we can use scalacheck tricks like withSize.

@armanbilge
Copy link
Member

Needs a scalafmtAll :)

Comment on lines 50 to 53
assert(fa.replicateA_(num) === Option(unit))
assert(increment.replicateA_(num).runS(0).value === num)
assert(increment.replicateA_(num).run(0).value === ((num, ())))
assert(increment.replicateA_(num).run(0).value === increment.replicateA(num).void.run(0).value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert(fa.replicateA_(num) === Option(unit))
assert(increment.replicateA_(num).runS(0).value === num)
assert(increment.replicateA_(num).run(0).value === ((num, ())))
assert(increment.replicateA_(num).run(0).value === increment.replicateA(num).void.run(0).value)
assertEquals(fa.replicateA_(num), Option(unit))
assertEquals(increment.replicateA_(num).runS(0).value, num)
assertEquals(increment.replicateA_(num).run(0).value, ((num, ())))
assertEquals(increment.replicateA_(num).run(0).value, increment.replicateA(num).void.run(0).value)

@@ -54,9 +57,15 @@ trait ApplicativeTests[F[_]] extends ApplyTests[F] {
"applicative map" -> forAll(laws.applicativeMap[A, B] _),
"applicative unit" -> forAll(laws.applicativeUnit[A] _),
"ap consistent with product + map" -> forAll(laws.apProductConsistent[A, B] _),
"replicateA_ consistent with replicateA.void" -> forAll { (a: A) =>
// Should be an implicit parameter but that is not a binary-compatible change
implicit val eqFUnit = makeEqFUnit[A](a)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the Scala 3 compiler is tripping on this line.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, looks like we need

Suggested change
implicit val eqFUnit = makeEqFUnit[A](a)
implicit val eqFUnit: Eq[F[Unit]] = makeEqFUnit[A](a)

@armanbilge
Copy link
Member

Huh, this is 2.12 btw.

[error] /home/runner/work/cats/cats/tests/shared/src/test/scala/cats/tests/SyntaxSuite.scala:419:26: value replicateA_ is not a member of type parameter F[A]
[error]     val replicateA_ = fa.replicateA_(1)

@DavidGregory084
Copy link
Member

Huh, this is 2.12 btw.

[error] /home/runner/work/cats/cats/tests/shared/src/test/scala/cats/tests/SyntaxSuite.scala:419:26: value replicateA_ is not a member of type parameter F[A]
[error]     val replicateA_ = fa.replicateA_(1)

Hmm that is very strange indeed as the same code compiled at c4e960d

@DavidGregory084
Copy link
Member

Had a proper look at this and I don't get this compilation error locally - @rabinarai1 how about if we just close and reopen the PR to kick off CI again? If that doesn't work, perhaps it's a conflict that's only introduced on merging into main.

@armanbilge armanbilge closed this Jun 9, 2022
@armanbilge armanbilge reopened this Jun 9, 2022
@DavidGregory084
Copy link
Member

Aha, I was able to replicate it locally after merging main though 👍

@armanbilge
Copy link
Member

armanbilge commented Jun 9, 2022

@DavidGregory084 thanks for tracking that down! While you are here, feel free to add parReplicateA_ roundabouts here if you wish :)

def parReplicateA(n: Int)(implicit P: Parallel[M]): M[List[A]] =
Parallel.parReplicateA(n, ma)
}

@armanbilge
Copy link
Member

Fantastic, thanks!!! 🚀

@armanbilge armanbilge changed the title ReplicateA_ Add replicateA_, partReplicateA_ Jun 9, 2022
@armanbilge armanbilge changed the title Add replicateA_, partReplicateA_ Add replicateA_, parReplicateA_ Jun 9, 2022
@johnynek johnynek merged commit b351d67 into typelevel:main Jun 9, 2022
@DavidGregory084 DavidGregory084 deleted the ReplicateM_ branch June 9, 2022 17:18
@armanbilge armanbilge added this to the 2.8.0 milestone Jun 9, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants