diff --git a/core/src/main/scala/cats/syntax/functor.scala b/core/src/main/scala/cats/syntax/functor.scala index fcb9d1ce46..fe9ff040ff 100644 --- a/core/src/main/scala/cats/syntax/functor.scala +++ b/core/src/main/scala/cats/syntax/functor.scala @@ -1,4 +1,64 @@ package cats package syntax -trait FunctorSyntax extends Functor.ToFunctorOps +trait FunctorSyntax extends Functor.ToFunctorOps { + implicit final def catsSyntaxFunctorTuple2Ops[F[_], A, B](fab: F[(A, B)]): FunctorTuple2Ops[F, A, B] = + new FunctorTuple2Ops[F, A, B](fab) +} + +final class FunctorTuple2Ops[F[_], A, B](private val fab: F[(A, B)]) extends AnyVal { + + /** + * Lifts `Tuple2#_1` to Functor + * + * {{{ + * scala> import cats.data.Chain + * scala> import cats.syntax.functor._ + * + * scala> Chain((1, 2), (3, 4), (5, 6))._1F == Chain(1, 3, 5) + * res0: Boolean = true + * }}} + */ + def _1F(implicit F: Functor[F]): F[A] = F.map(fab)(_._1) + + /** + * Lifts `Tuple2#_2` to Functor + * + * {{{ + * scala> import cats.data.Chain + * scala> import cats.syntax.functor._ + * + * scala> Chain((1, 2), (3, 4), (5, 6))._2F == Chain(2, 4, 6) + * res0: Boolean = true + * }}} + */ + def _2F(implicit F: Functor[F]): F[B] = F.map(fab)(_._2) + + /** + * Lifts `Tuple2#swap` to Functor + * + * {{{ + * scala> import cats.data.Chain + * scala> import cats.syntax.functor._ + * + * scala> Chain((1, 2), (3, 4), (5, 6)).swapF == Chain((2, 1), (4, 3), (6, 5)) + * res0: Boolean = true + * }}} + */ + def swapF(implicit F: Functor[F]): F[(B, A)] = F.map(fab)(_.swap) + + /** + * Un-zips an `F[(A, B)]` consisting of element pairs or Tuple2 into two separate F's tupled. + * + * NOTE: Check for effect duplication, possibly memoize before + * + * {{{ + * scala> import cats.data.Chain + * scala> import cats.syntax.functor._ + * + * scala> Chain((1, 2), (3, 4), (5, 6)).unzip == ((Chain(1, 3, 5), Chain(2, 4, 6))) + * res0: Boolean = true + * }}} + */ + def unzip(implicit F: Functor[F]): (F[A], F[B]) = F.unzip(fab) +} diff --git a/tests/src/test/scala/cats/tests/FunctorSuite.scala b/tests/src/test/scala/cats/tests/FunctorSuite.scala index b80c91f5b3..4c5a123dba 100644 --- a/tests/src/test/scala/cats/tests/FunctorSuite.scala +++ b/tests/src/test/scala/cats/tests/FunctorSuite.scala @@ -2,6 +2,8 @@ package cats.tests import cats.Functor import cats.syntax.functor._ +import cats.data.{NonEmptyList, NonEmptyMap} +import cats.laws.discipline.arbitrary._ import cats.syntax.eq._ import org.scalacheck.Prop._ @@ -34,10 +36,33 @@ class FunctorSuite extends CatsSuite { } test("unzip preserves structure") { - forAll { (l: List[Int], o: Option[Int], m: Map[String, Int]) => - Functor[List].unzip(l.map(i => (i, i))) === ((l, l)) - Functor[Option].unzip(o.map(i => (i, i))) === ((o, o)) - Functor[Map[String, *]].unzip(m.map { case (k, v) => (k, (v, v)) }) === ((m, m)) + forAll { (nel: NonEmptyList[Int], o: Option[Int], nem: NonEmptyMap[String, Int]) => + val l = nel.toList + val m = nem.toSortedMap + + assert(Functor[List].unzip(l.map(i => (i, i))) === ((l, l))) + assert(Functor[Option].unzip(o.map(i => (i, i))) === ((o, o))) + assert(Functor[Map[String, *]].unzip(m.map { case (k, v) => (k, (v, v)) }) === ((m, m))) + + //postfix test for Cats datatypes + assert(nel.map(i => (i, i)).unzip === ((nel, nel))) + assert(nem.map(v => (v, v)).unzip === ((nem, nem))) + } + + //empty test for completeness + val emptyL = List.empty[Int] + val emptyM = Map.empty[String, Int] + + assert(Functor[List].unzip(List.empty[(Int, Int)]) === ((emptyL, emptyL))) + assert(Functor[Map[String, *]].unzip(Map.empty[String, (Int, Int)]) === ((emptyM, emptyM))) + } + + test("_1F, _2F and swapF form correct lists for concrete list of tuples") { + forAll { (l: List[(Int, Int)]) => + val (l1, l2) = l.unzip + assertEquals(l._1F, l1) + assertEquals(l._2F, l2) + assertEquals(l.swapF, l2.zip(l1)) } }