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

SemigroupK/MonoidK methods #4084

Merged
merged 33 commits into from
Mar 12, 2022
Merged

Conversation

TimWSpence
Copy link
Member

Ports missing methods from Semigroup/Monoid to SemigroupK/MonoidK

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.

Thanks for the PR! Just a quick question, do we need to add anything to laws to cover these new methods? E.g. to check the consistency of these implementations.

@TimWSpence
Copy link
Member Author

Thanks for the PR! Just a quick question, do we need to add anything to laws to cover these new methods? E.g. to check the consistency of these implementations.

Good question! I was waiting to see if the tests passed before commenting 😅 I had two observations to make:

  1. SemigroupK and MonoidK don't have XXXFunctions traits to add forwarders to the companion objects. Should I add those as well?
  2. I was going to comment that these functions for Monoid and Semigroup don't seem to have any tests but now I see that they do in fact have laws so yes, I'll add corresponding laws for these 👍

Copy link
Contributor

@johnynek johnynek left a comment

Choose a reason for hiding this comment

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

+1 to update the laws.

I think basically we should have laws that the implementations match the naive implementations.

BTW: I think you can override combineAllK for sequence types and get a significant win (list becomes O(N) vs O(N^2)) also Option can short circuit at the first non-empty item.

* }}}
*/
override def combineNK[A](a: F[A], n: Int): F[A] =
if (n < 0) throw new IllegalArgumentException("Repeated combining for monoids must have n >= 0")
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should say MonoidK not monoid here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry! Good spot!

Copy link
Contributor

Choose a reason for hiding this comment

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

This hasn't been fixed.

@johnynek
Copy link
Contributor

note: https://github.com/typelevel/cats/pull/4084/files#diff-d189c08189989813f9b402a6993d2c49a1b7302601bc5de3dfe287eca63be6beR69

you can override the algebra there to override combineAll and similarly for SemigroupK.

Another idea, with this in hand, I think we can add:

def fromIterableOnce[A](as: IterableOnce[A]): F[A] =
  combineAllK(as.map(pure(_)))

def fromFoldable[G[_]: Foldable, A](as: G[A]): F[A] =
  fromIterableOnce(Foldable[F].toIterable(as))

to Alternative.

@TimWSpence
Copy link
Member Author

@johnynek just a heads up that I might not get to this till early in the new year but that it's at the top of my Todo list. Just in case you thought I was ignoring your feedback 😅

@TimWSpence TimWSpence dismissed a stale review via c4a620e January 12, 2022 16:19
* }}}
*/
override def combineNK[A](a: F[A], n: Int): F[A] =
if (n < 0) throw new IllegalArgumentException("Repeated combining for monoids must have n >= 0")
Copy link
Contributor

Choose a reason for hiding this comment

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

This hasn't been fixed.

def combineAllK[A](as: IterableOnce[F[A]]): F[A] =
as.iterator.foldLeft(empty[A])(combineK[A])

override def combineAllOptionK[A](as: IterableOnce[F[A]]): Option[F[A]] =
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it is better to do:

def combineAllK(as: IterableOnce[F[A]]): F[A] =
  combineAllOptionK(as) match {
    case Some(fa) => fa
    case None => empty[A]
  }

and not override combineAllOptionK.

The reason is, you want to only have to override one thing when you implement MonoidK and not have to override both and since combineAllOptionK is on both MonoidK and SemigroupK I think it is the easier one to remember to override.

I have sadly seen many cases of people picking the wrong one to override and the code paths not hitting the optimized method.

Alternatively you could do it this way, but I make this override final (that's allowed right)? so no one accidentally makes the mistake.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks 👍

}

override def combineAllK[A](as: IterableOnce[List[A]]): List[A] =
as.iterator.flatMap(_.iterator).toList
Copy link
Contributor

Choose a reason for hiding this comment

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

this duplication is unfortunate IMO. I think in MonoidK we should be able to only override one.

In library code I think it is worth the verbosity for the possibility of slightly better performance:

val bldr = List.newBuilder[A]
val it = as.iterator
while(it.hasNext) {
  bldr ++= it.next()
}
bldr.result()

@@ -25,6 +26,12 @@ trait OptionInstances extends cats.kernel.instances.OptionInstances {

def combineK[A](x: Option[A], y: Option[A]): Option[A] = if (x.isDefined) x else y

override def combineAllOptionK[A](as: IterableOnce[Option[A]]): Option[Option[A]] =
as.iterator.find(_.isDefined)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should avoid a closure in library code if we can:

val it = as.iterator
if (it.hasNext) {
  while (true) {
    val o = it.next()
    if (o.isDefined) return Some(o)
    if (!it.hasNext) return Some(None)
  }
  sys.error("unreachable")
}
else {
  return None
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Just for my own edification, why do you prefer to avoid a closure? Just avoiding unnecessary allocations or something deeper?

@@ -17,6 +18,14 @@ trait QueueInstances extends cats.kernel.instances.QueueInstances {

def combineK[A](x: Queue[A], y: Queue[A]): Queue[A] = x ++ y

override def combineAllOptionK[A](as: IterableOnce[Queue[A]]): Option[Queue[A]] = {
val iter = as.iterator
if (iter.isEmpty) None else Some(iter.flatMap(_.iterator).to(Queue))
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could write a function to share like:

def appendAll[F <: Iterable[A], A](as: IterableOnce[F], bldr: Builder[F, A]): bldr.type = {
  val it = as.iterator
  while(it.hasNext) {
    bldr ++= it.next()
  }
  bldr
}

then we could use it for all the collection types with appendAll(as, Queue.newBuilder[A]).result()

}

override def combineAllK[A](as: IterableOnce[Seq[A]]): Seq[A] =
as.iterator.flatMap(_.iterator).toSeq
Copy link
Contributor

Choose a reason for hiding this comment

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

same comments as above.

}

override def combineAllK[A](as: IterableOnce[Vector[A]]): Vector[A] =
as.iterator.flatMap(_.iterator).toVector
Copy link
Contributor

Choose a reason for hiding this comment

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

comments above.

@TimWSpence
Copy link
Member Author

Sorry @johnynek I was going to check this over before tagging you but you were too quick for me 😂 (hence the error message not being fixed)

@TimWSpence
Copy link
Member Author

@johnynek I think I've addressed all your feedback (many thanks!) but do let me know if I've missed something. I also left a few questions for my own edification, hope that's ok!

johnynek
johnynek previously approved these changes Jan 19, 2022
Copy link
Contributor

@johnynek johnynek left a comment

Choose a reason for hiding this comment

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

This is great! Thanks

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.

Does Alternative also need a couple more laws to cover fromIterableOnce and fromFoldable? And also can't our instances offer a more optimized implementation of those? E.g. it should be possible to go from an Iterable to a collection without pure on every element.

@johnynek
Copy link
Contributor

Yeah. Good point. We could override using appendK/prependK and possibly skip the pure but it has to be per instance because it isn't obvious which is faster.

@TimWSpence
Copy link
Member Author

Does Alternative also need a couple more laws to cover fromIterableOnce and fromFoldable? And also can't our instances offer a more optimized implementation of those? E.g. it should be possible to go from an Iterable to a collection without pure on every element.

Thanks! Yeah I thought as I wrote those methods that they should have laws and then didn't get round to it 😅

@TimWSpence
Copy link
Member Author

@armanbilge I've added a law for fromIterableOnce that asserts that the implementation matches its default behaviour. I wasn't sure what to do about fromFoldable though - if we added a similar law than it would require a binary incompatible addition of another type parameter on AlternativeLaws for the applicative

@armanbilge
Copy link
Member

Oh, that's annoying. I wonder if we should make that method final then. Since we can't test other implementations' correctness, seems like we should declare our fromFoldable as the One True Implementation.

@TimWSpence
Copy link
Member Author

Oh, that's annoying. I wonder if we should make that method final then. Since we can't test other implementations' correctness, seems like we should declare our fromFoldable as the One True Implementation.

Yep, good call 👍 If an instance has override fromIterableOnce then it should probably be fine anyway

Binary compatibility prohibits us from laws testing it as we would have
to add another type parameter to `AlternativeLaws` so we mark it final
instead

override def fromIterableOnce[A](as: IterableOnce[A]): Queue[A] = {
val iter = as.iterator
val builder = Queue.newBuilder[A]
Copy link
Contributor

Choose a reason for hiding this comment

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

Won't builder ++= as work here?

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this is worth the micro optimization of if (as.isEmpty) Queue.empty else { ... } since in that case we don't allocate the builder?

@TimWSpence TimWSpence dismissed a stale review via 19b3338 February 24, 2022 11:46
johnynek
johnynek previously approved these changes Feb 24, 2022
Copy link
Contributor

@johnynek johnynek left a comment

Choose a reason for hiding this comment

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

added a few minor nits if you want to take them. If you don't want to take them we can possibly address them later.

def fromIterableOnce[A](as: IterableOnce[A]): F[A] =
combineAllK(as.iterator.map(pure(_)))

final def fromFoldable[G[_]: Foldable, A](as: G[A]): F[A] =
Copy link
Contributor

Choose a reason for hiding this comment

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

I may have suggested making this final, but on second thought I think it should not be.

It could be that F is very efficient to build from a foldRight or a foldLeft or a foldMap. Since we should assume that the foldable for G knows the most efficient way to do those, the F implementation here may want to call one of those rather than converting to an Iterable[A] and going from there.

This is probably minor, and maybe never a big deal, but the final will block this (I may have suggested or previously agreed with final, if so, apologies for the flip-flop).

Copy link
Member

Choose a reason for hiding this comment

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

Oh yeah, that's a good point.

FTR this was my suggestion in #4084 (comment).

The problem is we can't currently check this implementation in the laws; see #4084 (comment).

So there's a bit of performance vs yolo factor here 😉

This is probably minor, and maybe never a big deal, but the final will block this

We can always take away a final modifier, but we can't add it back. So I'd be fine to leave this until a practical need to change this comes up.

Copy link
Contributor

Choose a reason for hiding this comment

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

why can't we check the laws? why can't we check that fromFoldable(g) == fromIterable(g.toIterable) in the laws?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah AFAICT we would have to add extra type parameters to AlternativeTests#alternative to do this. Unless I've missed something?


override def fromIterableOnce[A](as: IterableOnce[A]): Queue[A] = {
val iter = as.iterator
val builder = Queue.newBuilder[A]
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this is worth the micro optimization of if (as.isEmpty) Queue.empty else { ... } since in that case we don't allocate the builder?

if (iter.isEmpty) None else Some(appendAll(iter, Seq.newBuilder[A]).result())
}

override def fromIterableOnce[A](as: IterableOnce[A]): Seq[A] = {
Copy link
Contributor

Choose a reason for hiding this comment

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

same pondering about as.isEmpty

Copy link
Member Author

Choose a reason for hiding this comment

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

AFAICT isEmpty is deprecated in favour of iterator.isEmpty

Copy link
Member Author

Choose a reason for hiding this comment

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

otherwise this would be a nice optimization

Copy link
Member Author

Choose a reason for hiding this comment

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

or is allocating an Iterator to maybe not have to allocate a builder preferable?


override def fromIterableOnce[A](as: IterableOnce[A]): Queue[A] = {
val builder = Queue.newBuilder[A]
builder ++= as.iterator
Copy link
Contributor

Choose a reason for hiding this comment

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

do we need .iterator? won't builder ++= as do?

Copy link
Member Author

@TimWSpence TimWSpence Mar 3, 2022

Choose a reason for hiding this comment

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

Thanks! Think I got lost in a sea of different versions with different APIs. I've fixed this

@@ -17,6 +20,17 @@ trait SeqInstances extends cats.kernel.instances.SeqInstances {

def combineK[A](x: Seq[A], y: Seq[A]): Seq[A] = x ++ y

override def combineAllOptionK[A](as: IterableOnce[Seq[A]]): Option[Seq[A]] = {
val iter = as.iterator
if (iter.isEmpty) None else Some(appendAll(iter, Seq.newBuilder[A]).result())
Copy link
Contributor

Choose a reason for hiding this comment

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

actually, in this pattern, won't if (as.isEmpty) None else Some(appendAll(as.iterator, ...) work also that way we don't need to convert to iterator in the empty case?

Copy link
Member

@armanbilge armanbilge Feb 24, 2022

Choose a reason for hiding this comment

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

Is it possible to check as.isEmpty on an IterableOnce without consuming it?

In fact, in 2.13 IterableOnce#isEmpty is deprecated:

(Since version 2.13.0) Use .iterator.isEmpty instead

I think what we could do here is use IterableOnce#knownSize.

The number of elements in this collection, if it can be cheaply computed, -1 otherwise. Cheaply usually means: Not requiring a collection traversal.

Copy link
Contributor

Choose a reason for hiding this comment

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

okay. Bummer we have to allocate in order to check emptiness.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah that was what I wanted to write originally but ran into the deprecation

@TimWSpence
Copy link
Member Author

added a few minor nits if you want to take them. If you don't want to take them we can possibly address them later.

Many thanks as always for the thorough review! I think I've addressed or replied to all your comments so let me know what you think or if I've missed anything!

johnynek
johnynek previously approved these changes Mar 11, 2022
Copy link
Contributor

@johnynek johnynek left a comment

Choose a reason for hiding this comment

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

noticed one minor issue you can take or not (I think it is worth it in theory, but in practice maybe the method never gets called on planet earth).

@@ -1029,6 +1046,7 @@ sealed abstract private[data] class ChainInstances extends ChainInstances1 {

def empty[A]: Chain[A] = Chain.nil
def combineK[A](c: Chain[A], c2: Chain[A]): Chain[A] = Chain.concat(c, c2)
override def fromIterableOnce[A](xs: IterableOnce[A]): Chain[A] = Chain.fromSeq(xs.iterator.toSeq)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit...

god... I will never stop finding micro issues:

override def fromIterableOnce[A](xs: IterableOnce[A]): Chain[A]  =
  xs match {
    case s: Seq[A @unchecked] =>
      // Seq is a subclass of IterableOnce, so the type has to be compatible
     Chain.fromSeq(s) // pay O(1) not O(N) cost
   case notSeq =>
     Chain.fromSeq(notSeq.iterator.toSeq)
  }

Copy link
Member

Choose a reason for hiding this comment

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

Could it make sense to add a Chain.fromIterableOnce that does this instead? I don't really know what to make of these IterableOnce possibly side-effecty things.

Copy link
Contributor

Choose a reason for hiding this comment

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

sure, we can put it in Chain.

I don't view this as a side effect so much as we are lacking linear or affine types. What we want is to say this method takes ownership of xs. If we had that, then iterating it would be safe (assuming it did no side effect, which we make that assumption all the time in every function and method call).

Copy link
Contributor

Choose a reason for hiding this comment

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

note: It would be really cool if scala added ownership types but I think it is a bit complex to get right and ergonomic.

@TimWSpence
Copy link
Member Author

noticed one minor issue you can take or not (I think it is worth it in theory, but in practice maybe the method never gets called on planet earth).

Thanks! Yeah definitely worth making it O(1) for the more common case of Seq 👍

@johnynek johnynek merged commit 65e7499 into typelevel:main Mar 12, 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.

3 participants