-
-
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
SemigroupK/MonoidK methods #4084
Conversation
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.
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:
|
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.
+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") |
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 think we should say MonoidK
not monoid
here.
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.
Sorry! Good spot!
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 hasn't been fixed.
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. |
@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 😅 |
* }}} | ||
*/ | ||
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") |
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 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]] = |
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 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.
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.
Thanks 👍
} | ||
|
||
override def combineAllK[A](as: IterableOnce[List[A]]): List[A] = | ||
as.iterator.flatMap(_.iterator).toList |
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 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) |
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 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
}
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.
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)) |
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 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 |
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.
same comments as above.
} | ||
|
||
override def combineAllK[A](as: IterableOnce[Vector[A]]): Vector[A] = | ||
as.iterator.flatMap(_.iterator).toVector |
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.
comments above.
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) |
@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! |
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 great! Thanks
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.
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.
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. |
Thanks! Yeah I thought as I wrote those methods that they should have laws and then didn't get round to it 😅 |
@armanbilge I've added a law for |
Oh, that's annoying. I wonder if we should make that method |
Yep, good call 👍 If an instance has override |
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] |
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.
Won't builder ++= as
work here?
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 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?
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.
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] = |
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 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).
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 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.
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.
why can't we check the laws? why can't we check that fromFoldable(g) == fromIterable(g.toIterable)
in the laws?
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 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] |
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 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] = { |
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.
same pondering about as.isEmpty
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.
AFAICT isEmpty
is deprecated in favour of iterator.isEmpty
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.
otherwise this would be a nice optimization
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.
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 |
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.
do we need .iterator
? won't builder ++= as
do?
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.
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()) |
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.
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?
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.
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.
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.
okay. Bummer we have to allocate in order to check emptiness.
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 that was what I wanted to write originally but ran into the deprecation
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! |
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.
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) |
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.
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)
}
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.
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.
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, 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).
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.
note: It would be really cool if scala added ownership types but I think it is a bit complex to get right and ergonomic.
Thanks! Yeah definitely worth making it |
Ports missing methods from
Semigroup
/Monoid
toSemigroupK
/MonoidK