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 missing methods to NE collections and syntax #3998

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
12 changes: 12 additions & 0 deletions core/src/main/scala/cats/data/NonEmptyList.scala
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,21 @@ final case class NonEmptyList[+A](head: A, tail: List[A]) extends NonEmptyCollec
def ::[AA >: A](a: AA): NonEmptyList[AA] =
prepend(a)

/**
* Prepend an item to this, producing a new `NonEmptyList`.
*/
def prepend[AA >: A](a: AA): NonEmptyList[AA] =
NonEmptyList(a, head :: tail)

/**
* Prepend a `List` to this, producing a new `NonEmptyList`.
*/
def prependList[AA >: A](list: List[AA]): NonEmptyList[AA] =
list match {
case Nil => this
case lh :: lt => NonEmptyList(lh, lt ::: toList)
}

/**
* Alias for append
*
Expand Down
14 changes: 14 additions & 0 deletions core/src/main/scala/cats/syntax/list.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ final class ListOps[A](private val la: List[A]) extends AnyVal {
*/
def toNel: Option[NonEmptyList[A]] = NonEmptyList.fromList(la)

/**
* Concatenates this `List` with a `NonEmptyList` producing a new `NonEmptyList`.
*
* Example:
* {{{
* scala> import cats.data.NonEmptyList
* scala> import cats.implicits._
*
* scala> List(1, 2, 3).concatNel(NonEmptyList.of(4, 5, 6))
* res0: NonEmptyList[Int] = NonEmptyList(1, 2, 3, 4, 5, 6)
* }}}
*/
def concatNel[AA >: A](nel: NonEmptyList[AA]): NonEmptyList[AA] = nel.prependList(la)

/**
* Groups elements inside this `List` according to the `Order` of the keys
* produced by the given mapping function.
Expand Down
141 changes: 141 additions & 0 deletions core/src/main/scala/cats/syntax/seq.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,153 @@
package cats.syntax

import cats.Applicative
import cats.Functor
import cats.Order
import cats.Traverse
import cats.data.NonEmptySeq

import scala.collection.immutable.Seq
import scala.collection.immutable.SortedMap

trait SeqSyntax {
implicit final def catsSyntaxSeqs[A](va: Seq[A]): SeqOps[A] = new SeqOps(va)
}

final class SeqOps[A](private val va: Seq[A]) extends AnyVal {

/**
* Returns an `Option` of `NonEmptySeq` from a `Seq`
*
* Example:
* {{{
* scala> import cats.data.NonEmptySeq
* scala> import cats.syntax.all._
* scala> import scala.collection.immutable.Seq
*
* scala> val seq1 = Seq(1, 2)
* scala> seq1.toNeSeq
* res1: Option[NonEmptySeq[Int]] = Some(NonEmptySeq(1, 2))
*
* scala> val seq2 = Seq.empty[Int]
* scala> seq2.toNeSeq
* res2: Option[NonEmptySeq[Int]] = None
* }}}
*/
def toNeSeq: Option[NonEmptySeq[A]] = NonEmptySeq.fromSeq(va)

/**
* Concatenates this `Seq` with a `NonEmptySeq` producing a new `NonEmptySeq`.
*
* Example:
* {{{
* scala> import cats.data.NonEmptySeq
* scala> import cats.syntax.all._
* scala> import scala.collection.immutable.Seq
*
* scala> Seq(1, 2, 3).concatNeSeq(NonEmptySeq.of(4, 5, 6))
* res0: NonEmptySeq[Int] = NonEmptySeq(1, 2, 3, 4, 5, 6)
* }}}
*/
def concatNeSeq[AA >: A](neseq: NonEmptySeq[AA]): NonEmptySeq[AA] = neseq.prependSeq(va)

/**
* Groups elements inside this `Seq` according to the `Order` of the keys
* produced by the given mapping function.
*
* {{{
* scala> import cats.data.NonEmptySeq
* scala> import cats.syntax.all._
* scala> import scala.collection.immutable.Seq
* scala> import scala.collection.immutable.SortedMap
*
* scala> val seq = Seq(12, -2, 3, -5)
* scala> val res = SortedMap(false -> NonEmptySeq.of(-2, -5), true -> NonEmptySeq.of(12, 3))
* scala> seq.groupByNeSeq(_ >= 0) === res
* res1: Boolean = true
* }}}
*/
def groupByNeSeq[B](f: A => B)(implicit B: Order[B]): SortedMap[B, NonEmptySeq[A]] = {
implicit val ordering: Ordering[B] = B.toOrdering
toNeSeq.fold(SortedMap.empty[B, NonEmptySeq[A]])(_.groupBy(f))
}

/**
* Groups elements inside this `Seq` according to the `Order` of the keys
* produced by the given mapping monadic function.
*
* {{{
* scala> import cats.data.NonEmptySeq
* scala> import cats.syntax.all._
* scala> import scala.collection.immutable.Seq
* scala> import scala.collection.immutable.SortedMap
*
* scala> def f(n: Int) = n match { case 0 => None; case n => Some(n > 0) }
*
* scala> val seq = Seq(12, -2, 3, -5)
* scala> val res = Some(SortedMap(false -> NonEmptySeq.of(-2, -5), true -> NonEmptySeq.of(12, 3)))
* scala> seq.groupByNeSeqA(f) === res
* res1: Boolean = true
*
* scala> // `f(0)` returns `None`
* scala> (seq :+ 0).groupByNeSeqA(f) === None
* res2: Boolean = true
* }}}
*/
def groupByNeSeqA[F[_], B](
f: A => F[B]
)(implicit F: Applicative[F], B: Order[B]): F[SortedMap[B, NonEmptySeq[A]]] = {
implicit val ordering: Ordering[B] = B.toOrdering
val mapFunctor = Functor[SortedMap[B, *]]
val nesTraverse = Traverse[NonEmptySeq]

toNeSeq.fold(F.pure(SortedMap.empty[B, NonEmptySeq[A]])) { nes =>
F.map(nesTraverse.traverse(nes)(a => F.tupleRight(f(a), a))) { seq =>
mapFunctor.map(seq.groupBy(_._1))(_.map(_._2))
}
}
}

/**
* Produces a `NonEmptySeq` containing cumulative results of applying the
* operator going left to right.
*
* Example:
* {{{
* scala> import cats.data.NonEmptySeq
* scala> import cats.syntax.all._
* scala> import scala.collection.immutable.Seq
*
* scala> val seq1 = Seq(1, 2)
* scala> seq1.scanLeftNeSeq(100)(_ + _)
* res1: NonEmptySeq[Int] = NonEmptySeq(100, 101, 103)
*
* scala> val seq2 = Seq.empty[Int]
* scala> seq2.scanLeftNeSeq(123)(_ + _)
* res2: NonEmptySeq[Int] = NonEmptySeq(123)
* }}}
*/
def scanLeftNeSeq[B](b: B)(f: (B, A) => B): NonEmptySeq[B] =
NonEmptySeq.fromSeqUnsafe(va.scanLeft(b)(f))

/**
* Produces a `NonEmptySeq` containing cumulative results of applying the
* operator going right to left.
*
* Example:
* {{{
* scala> import cats.data.NonEmptySeq
* scala> import cats.syntax.all._
* scala> import scala.collection.immutable.Seq
*
* scala> val seq = Seq(1, 2)
* scala> seq.scanRightNeSeq(100)(_ + _)
* res0: NonEmptySeq[Int] = NonEmptySeq(103, 102, 100)
*
* scala> val seq2 = Seq.empty[Int]
* scala> seq2.scanRightNeSeq(123)(_ + _)
* res1: NonEmptySeq[Int] = NonEmptySeq(123)
* }}}
*/
def scanRightNeSeq[B](b: B)(f: (A, B) => B): NonEmptySeq[B] =
NonEmptySeq.fromSeqUnsafe(va.scanRight(b)(f))
}
130 changes: 130 additions & 0 deletions core/src/main/scala/cats/syntax/vector.scala
Original file line number Diff line number Diff line change
@@ -1,11 +1,141 @@
package cats.syntax

import cats.data.NonEmptyVector
import cats.{Applicative, Functor, Order, Traverse}

import scala.collection.immutable.SortedMap

trait VectorSyntax {
implicit final def catsSyntaxVectors[A](va: Vector[A]): VectorOps[A] = new VectorOps(va)
}

final class VectorOps[A](private val va: Vector[A]) extends AnyVal {

/**
* Returns an Option of NonEmptyVector from a Vector
*
* Example:
* {{{
* scala> import cats.data.NonEmptyVector
* scala> import cats.implicits._
*
* scala> val result1: Vector[Int] = Vector(1, 2)
* scala> result1.toNev
* res0: Option[NonEmptyVector[Int]] = Some(NonEmptyVector(1, 2))
*
* scala> val result2: Vector[Int] = Vector.empty[Int]
* scala> result2.toNev
* res1: Option[NonEmptyVector[Int]] = None
* }}}
*/
def toNev: Option[NonEmptyVector[A]] = NonEmptyVector.fromVector(va)

/**
* Concatenates this `Vector` with a `NonEmptyVector` producing a new `NonEmptyVector`.
*
* Example:
* {{{
* scala> import cats.data.NonEmptyVector
* scala> import cats.implicits._
*
* scala> Vector(1, 2, 3).concatNev(NonEmptyVector.of(4, 5, 6))
* res0: NonEmptyVector[Int] = NonEmptyVector(1, 2, 3, 4, 5, 6)
* }}}
*/
def concatNev[AA >: A](nev: NonEmptyVector[AA]): NonEmptyVector[AA] = nev.prependVector(va)

/**
* Groups elements inside this `Vector` according to the `Order` of the keys
* produced by the given mapping function.
*
* {{{
* scala> import cats.data.NonEmptyVector
* scala> import scala.collection.immutable.SortedMap
* scala> import cats.implicits._
*
* scala> val vector = Vector(12, -2, 3, -5)
*
* scala> val expectedResult = SortedMap(false -> NonEmptyVector.of(-2, -5), true -> NonEmptyVector.of(12, 3))
*
* scala> vector.groupByNev(_ >= 0) === expectedResult
* res0: Boolean = true
* }}}
*/
def groupByNev[B](f: A => B)(implicit B: Order[B]): SortedMap[B, NonEmptyVector[A]] = {
implicit val ordering: Ordering[B] = B.toOrdering
toNev.fold(SortedMap.empty[B, NonEmptyVector[A]])(_.groupBy(f))
}

/**
* Groups elements inside this `Vector` according to the `Order` of the keys
* produced by the given mapping monadic function.
*
* {{{
* scala> import cats.data.NonEmptyVector
* scala> import scala.collection.immutable.SortedMap
* scala> import cats.implicits._
*
* scala> val vector = Vector(12, -2, 3, -5)
*
* scala> val expectedResult = Option(SortedMap(false -> NonEmptyVector.of(-2, -5), true -> NonEmptyVector.of(12, 3)))
*
* scala> vector.groupByNevA(num => Option(0).map(num >= _)) === expectedResult
* res0: Boolean = true
* }}}
*/
def groupByNevA[F[_], B](
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 should live here:
https://github.com/typelevel/cats/blob/main/core/src/main/scala/cats/NonEmptyTraverse.scala

def groupByA[G[_], B](fn: A => G[B])(implicit G: Apply[G], B: Order[B]): G[SortedMap[B, F[A]]]

and then you can call that here:

  toNev.fold(F.pure(SortedMap.empty[B, NonEmptyVector[A]])(_.groupByA(f))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, these particular methods are just an attempt to maintain some degree of consistency and interoperability among all linear "non-empty" collections we have in cats.data. Because initially there was ListOps#groupByNelA only but no corresponding methods in VectorOps nor SeqOps. Which is a bit confusing.

Copy link
Contributor Author

@satorg satorg Oct 1, 2021

Choose a reason for hiding this comment

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

But I like the idea of generalization. I also have a feeling, that keeping stuffing various *Ops classes with almost identical methods is not the best way to deal with it. I like your suggestion in particular, but why just NonEmptyTraverse?

Seems, it should work with just a regular Traverse – shouldn't it?

I mean, when we do grouping on some collection by some predicate, all the groups we will get eventually are going to be non-empty regardless of whether the source collection was empty or not. I.e. we will either get a non-empty group or don't get it at all.

Copy link
Contributor Author

@satorg satorg Oct 1, 2021

Choose a reason for hiding this comment

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

We can use the way you suggested of course:

toNev.fold(F.pure(SortedMap.empty[B, NonEmptyVector[A]])(_.groupByA(f))

But to me it has at least two flaws:

  1. It is too verbose and may require explicit types.
  2. It kind of "disguises" the idea of type safety for grouping: i.e. the grouping over a regular collection ALWAYS results in zero or more groups of corresponding non-empty collections.

Copy link
Contributor

Choose a reason for hiding this comment

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

to your point 2: putting groupByA on NonEmptyTraverse we could actually return NonEmptyMap[B, F[A]], so we know both the map is non-empty and since F[_]: NonEmptyTraverse we also know F[_] is non-empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is a valid case too. But I'd suppose that grouping over a regular Traverse is a more common case anyway. And it's guaranteed to produce a regular Map of NonEmpty* groups. While grouping over NonEmptyTraverse is guaranteed to produce a NonEmptyMap of NonEmpty* groups. I mean both cases looks legit to me and I'd like to think on how to incorporate both of them.

f: A => F[B]
)(implicit F: Applicative[F], B: Order[B]): F[SortedMap[B, NonEmptyVector[A]]] = {
implicit val ordering: Ordering[B] = B.toOrdering
val mapFunctor = Functor[SortedMap[B, *]]
val nevTraverse = Traverse[NonEmptyVector]

toNev.fold(F.pure(SortedMap.empty[B, NonEmptyVector[A]])) { nev =>
F.map(nevTraverse.traverse(nev)(a => F.tupleLeft(f(a), a))) { vector =>
mapFunctor.map(vector.groupBy(_._2))(_.map(_._1))
}
}
}

/**
* Produces a `NonEmptyVector` containing cumulative results of applying the
* operator going left to right.
*
* Example:
* {{{
* scala> import cats.data.NonEmptyVector
* scala> import cats.implicits._
*
* scala> val result1: Vector[Int] = Vector(1, 2)
* scala> result1.scanLeftNev(100)(_ + _)
* res0: NonEmptyVector[Int] = NonEmptyVector(100, 101, 103)
*
* scala> val result2: Vector[Int] = Vector.empty[Int]
* scala> result2.scanLeftNev(1)(_ + _)
* res1: NonEmptyVector[Int] = NonEmptyVector(1)
* }}}
*/
def scanLeftNev[B](b: B)(f: (B, A) => B): NonEmptyVector[B] =
NonEmptyVector.fromVectorUnsafe(va.scanLeft(b)(f))

/**
* Produces a `NonEmptyVector` containing cumulative results of applying the
* operator going right to left.
*
* Example:
* {{{
* scala> import cats.data.NonEmptyVector
* scala> import cats.implicits._
*
* scala> val result1: Vector[Int] = Vector(1, 2)
* scala> result1.scanRightNev(100)(_ + _)
* res0: NonEmptyVector[Int] = NonEmptyVector(103, 102, 100)
*
* scala> val result2: Vector[Int] = Vector.empty[Int]
* scala> result2.scanRightNev(1)(_ + _)
* res1: NonEmptyVector[Int] = NonEmptyVector(1)
* }}}
*/
def scanRightNev[B](b: B)(f: (A, B) => B): NonEmptyVector[B] =
NonEmptyVector.fromVectorUnsafe(va.scanRight(b)(f))
}
18 changes: 18 additions & 0 deletions tests/src/test/scala/cats/tests/ListSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ class ListSuite extends CatsSuite {
assert(List.empty[Int].toNel === None)
}

test("concatNel should be consistent with List#`:::`") {
forAll { (fa: List[Int], nel: NonEmptyList[Int]) =>
assert(fa.concatNel(nel).toList === fa ::: nel.toList)
}
}

test("groupByNel should be consistent with groupBy")(
forAll { (fa: List[Int], f: Int => Int) =>
assert((fa.groupByNel(f).map { case (k, v) => (k, v.toList) }: Map[Int, List[Int]]) === fa.groupBy(f))
Expand All @@ -70,6 +76,18 @@ class ListSuite extends CatsSuite {
}
)

test("scanLeftNel should be consistent with scanLeft")(
forAll { (fa: List[Int], b: Int, f: (Int, Int) => Int) =>
assert(fa.scanLeftNel(b)(f).toList === fa.scanLeft(b)(f))
}
)

test("scanRightNel should be consistent with scanRight")(
forAll { (fa: List[Int], b: Int, f: (Int, Int) => Int) =>
assert(fa.scanRightNel(b)(f).toList === fa.scanRight(b)(f))
}
)

test("show") {
assert(List(1, 2, 3).show === "List(1, 2, 3)")
assert((Nil: List[Int]).show === "List()")
Expand Down
8 changes: 7 additions & 1 deletion tests/src/test/scala/cats/tests/NonEmptyListSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,19 @@ class NonEmptyListSuite extends NonEmptyCollectionSuite[List, NonEmptyList, NonE
}
}

test(":: consistent with List") {
test("NonEmptyList#`::` and prepend consistent with List#`::`") {
forAll { (nel: NonEmptyList[Int], i: Int) =>
assert((i :: nel).toList === (i :: nel.toList))
assert(nel.prepend(i).toList === (i :: nel.toList))
}
}

test("NonEmptyList#prependList consisntent with List#`:::`") {
forAll { (nel: NonEmptyList[Int], l: List[Int]) =>
assert(nel.prependList(l).toList === l ::: nel.toList)
}
}

test("NonEmptyList#distinct is consistent with List#distinct") {
forAll { (nel: NonEmptyList[Int]) =>
assert(nel.distinct.toList === (nel.toList.distinct))
Expand Down
Loading