From 61f13d08e58c6b090283f33692a6316fdc6a35c8 Mon Sep 17 00:00:00 2001 From: Luka Jacobowitz Date: Mon, 8 Jan 2018 18:07:21 +0100 Subject: [PATCH] Add NonEmptySet --- .../main/scala/cats/data/NonEmptySet.scala | 218 +++++++++++++++++ .../cats/laws/discipline/Arbitrary.scala | 7 + .../scala/cats/tests/NonEmptySetSuite.scala | 225 ++++++++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 core/src/main/scala/cats/data/NonEmptySet.scala create mode 100644 tests/src/test/scala/cats/tests/NonEmptySetSuite.scala diff --git a/core/src/main/scala/cats/data/NonEmptySet.scala b/core/src/main/scala/cats/data/NonEmptySet.scala new file mode 100644 index 0000000000..5aa00a520d --- /dev/null +++ b/core/src/main/scala/cats/data/NonEmptySet.scala @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2018 Luka Jacobowitz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats +package data + +import cats.instances.sortedSet._ +import cats.kernel._ +import cats.{Always, Eq, Eval, Foldable, Later, Now, Reducible, SemigroupK, Show} + +import scala.collection.immutable._ + +final class NonEmptySet[A] private (val set: SortedSet[A]) extends AnyVal { + + private implicit def ordering: Ordering[A] = set.ordering + private implicit def order: Order[A] = Order.fromOrdering + + def +(a: A): NonEmptySet[A] = new NonEmptySet(set + a) + def ++(as: NonEmptySet[A]): NonEmptySet[A] = concat(as) + + def concat(as: NonEmptySet[A]): NonEmptySet[A] = new NonEmptySet(set ++ as.set) + + def -(a: A): SortedSet[A] = set - a + def map[B: Order](f: A ⇒ B): NonEmptySet[B] = + new NonEmptySet(SortedSet(set.map(f).to: _*)(Order[B].toOrdering)) + + def toNonEmptyList: NonEmptyList[A] = NonEmptyList.fromListUnsafe(set.toList) + + def head: A = set.head + def tail: SortedSet[A] = set.tail + def last: A = set.last + + def apply(a: A): Boolean = set(a) + def contains(a: A): Boolean = set.contains(a) + + + def union(as: NonEmptySet[A]): NonEmptySet[Ag] = new NonEmptySet(set.union(as.toSet)) + def size: Int = set.size + def forall(p: A ⇒ Boolean): Boolean = set.forall(p) + def exists(f: A ⇒ Boolean): Boolean = set.exists(f) + def find(f: A ⇒ Boolean): Option[A] = set.find(f) + def collect[B](pf: PartialFunction[A, B]): Set[B] = set.collect(pf) + def filter(p: A ⇒ Boolean): SortedSet[A] = set.filter(p) + def filterNot(p: A ⇒ Boolean): SortedSet[A] = filter(t => !p(t)) + + + /** + * Left-associative fold using f. + */ + def foldLeft[B](b: B)(f: (B, A) => B): B = + set.foldLeft(b)(f) + + /** + * Right-associative fold using f. + */ + def foldRight[B](lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + Foldable[SortedSet].foldRight(set, lb)(f) + + /** + * Left-associative reduce using f. + */ + def reduceLeft(f: (A, A) => A): A = + set.reduceLeft(f) + + def reduceLeftTo[B](f: A => B)(g: (B, A) => B): B = { + tail.foldLeft(f(head))((b, a) => g(b, a)) + } + + /** + * Left-associative reduce using f. + */ + def reduceRight(f: (A, Eval[A]) => Eval[A]): Eval[A] = + reduceRightTo(identity)(f) + + def reduceRightTo[B](f: A => B)(g: (A, Eval[B]) => Eval[B]): Eval[B] = + Always((head, tail)).flatMap { case (a, ga) => + Foldable[SortedSet].reduceRightToOption(ga)(f)(g).flatMap { + case Some(b) => g(a, Now(b)) + case None => Later(f(a)) + } + } + + /** + * Reduce using the Semigroup of A + */ + def reduce[AA >: A](implicit S: Semigroup[AA]): AA = + S.combineAllOption(set).get + + def toSet: SortedSet[A] = set + + /** + * Typesafe stringification method. + * + * This method is similar to .toString except that it stringifies + * values according to Show[_] instances, rather than using the + * universal .toString method. + */ + def show(implicit A: Show[A]): String = + s"NonEmpty${Show[SortedSet[A]].show(set)}" + + /** + * Typesafe equality operator. + * + * This method is similar to == except that it only allows two + * NonEmptySet[A] values to be compared to each other, and uses + * equality provided by Eq[_] instances, rather than using the + * universal equality provided by .equals. + */ + def ===(that: NonEmptySet[A]): Boolean = + Eq[SortedSet[A]].eqv(set, that.toSet) + + def length: Int = size + + /** + * Zips this `NonEmptySet` with another `NonEmptySet` and applies a function for each pair of elements. + * + * {{{ + * scala> import cats.data.NonEmptySet + * scala> val as = NonEmptySet.of(1, 2, 3) + * scala> val bs = NonEmptySet.of("A", "B", "C") + * scala> as.zipWith(bs)(_ + _) + * res0: cats.data.NonEmptySet[String] = NonEmptySet(1A, 2B, 3C) + * }}} + */ + def zipWith[B, C: Order](b: NonEmptySet[B])(f: (A, B) => C): NonEmptySet[C] = + new NonEmptySet(SortedSet((set, b.toSet).zipped.map(f).to: _*)(Order[C].toOrdering)) + + def zipWithIndex: NonEmptySet[(A, Int)] = + new NonEmptySet(set.zipWithIndex) +} + +private[data] sealed abstract class NonEmptySetInstances { + implicit val catsDataInstancesForNonEmptySet: SemigroupK[NonEmptySet] with Reducible[NonEmptySet] = + new SemigroupK[NonEmptySet] with Reducible[NonEmptySet] { + + def combineK[A](a: NonEmptySet[A], b: NonEmptySet[A]): NonEmptySet[A] = + a ++ b + + override def size[A](fa: NonEmptySet[A]): Long = fa.length.toLong + + override def reduceLeft[A](fa: NonEmptySet[A])(f: (A, A) => A): A = + fa.reduceLeft(f) + + override def reduce[A](fa: NonEmptySet[A])(implicit A: Semigroup[A]): A = + fa.reduce + + def reduceLeftTo[A, B](fa: NonEmptySet[A])(f: A => B)(g: (B, A) => B): B = fa.reduceLeftTo(f)(g) + + def reduceRightTo[A, B](fa: NonEmptySet[A])(f: A => B)(g: (A, Eval[B]) => Eval[B]): Eval[B] = + fa.reduceRightTo(f)(g) + + override def foldLeft[A, B](fa: NonEmptySet[A], b: B)(f: (B, A) => B): B = + fa.foldLeft(b)(f) + + override def foldRight[A, B](fa: NonEmptySet[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + fa.foldRight(lb)(f) + + override def foldMap[A, B](fa: NonEmptySet[A])(f: A => B)(implicit B: Monoid[B]): B = + B.combineAll(fa.toSet.iterator.map(f)) + + override def fold[A](fa: NonEmptySet[A])(implicit A: Monoid[A]): A = + fa.reduce + + override def find[A](fa: NonEmptySet[A])(f: A => Boolean): Option[A] = + fa.find(f) + + override def forall[A](fa: NonEmptySet[A])(p: A => Boolean): Boolean = + fa.forall(p) + + override def exists[A](fa: NonEmptySet[A])(p: A => Boolean): Boolean = + fa.exists(p) + + override def toList[A](fa: NonEmptySet[A]): List[A] = fa.toSet.toList + + override def toNonEmptyList[A](fa: NonEmptySet[A]): NonEmptyList[A] = + NonEmptyList(fa.head, fa.tail.toList) + } + + implicit def catsDataEqForNonEmptySet[A: Order]: Eq[NonEmptySet[A]] = + new Eq[NonEmptySet[A]]{ + def eqv(x: NonEmptySet[A], y: NonEmptySet[A]): Boolean = x === y + } + + implicit def catsDataShowForNonEmptySet[A](implicit A: Show[A]): Show[NonEmptySet[A]] = + Show.show[NonEmptySet[A]](_.show) + + implicit def catsDataBandForNonEmptySet[A]: Band[NonEmptySet[A]] = new Band[NonEmptySet[A]] { + def combine(x: NonEmptySet[A], y: NonEmptySet[A]): NonEmptySet[A] = x ++ y + } +} + +object NonEmptySet extends NonEmptySetInstances { + def fromSet[A: Order](as: SortedSet[A]): Option[NonEmptySet[A]] = + if (as.nonEmpty) Option(new NonEmptySet(as)) else None + + def fromSetUnsafe[A: Order](set: SortedSet[A]): NonEmptySet[A] = + if (set.nonEmpty) new NonEmptySet(set) + else throw new IllegalArgumentException("Cannot create NonEmptySet from empty set") + + + def of[A: Order](a: A, as: A*): NonEmptySet[A] = + new NonEmptySet(SortedSet(a)(Order[A].toOrdering) ++ SortedSet(as: _*)(Order[A].toOrdering) + a) + def apply[A: Order](head: A, tail: SortedSet[A]): NonEmptySet[A] = new NonEmptySet(SortedSet(head)(Order[A].toOrdering) ++ tail) + def one[A: Order](a: A): NonEmptySet[A] = new NonEmptySet(SortedSet(a)(Order[A].toOrdering)) +} diff --git a/laws/src/main/scala/cats/laws/discipline/Arbitrary.scala b/laws/src/main/scala/cats/laws/discipline/Arbitrary.scala index 54f2238353..7b1775e355 100644 --- a/laws/src/main/scala/cats/laws/discipline/Arbitrary.scala +++ b/laws/src/main/scala/cats/laws/discipline/Arbitrary.scala @@ -51,6 +51,13 @@ object arbitrary extends ArbitraryInstances0 { implicit def catsLawsCogenForNonEmptyVector[A](implicit A: Cogen[A]): Cogen[NonEmptyVector[A]] = Cogen[Vector[A]].contramap(_.toVector) + implicit def catsLawsArbitraryForNonEmptySet[A: Order](implicit A: Arbitrary[A]): Arbitrary[NonEmptySet[A]] = + Arbitrary(implicitly[Arbitrary[SortedSet[A]]].arbitrary.flatMap(fa => + A.arbitrary.map(a => NonEmptySet(a, fa)))) + + implicit def catsLawsCogenForNonEmptySet[A: Order: Cogen]: Cogen[NonEmptySet[A]] = + Cogen[SortedSet[A]].contramap(_.toSet) + implicit def catsLawsArbitraryForZipVector[A](implicit A: Arbitrary[A]): Arbitrary[ZipVector[A]] = Arbitrary(implicitly[Arbitrary[Vector[A]]].arbitrary.map(v => new ZipVector(v))) diff --git a/tests/src/test/scala/cats/tests/NonEmptySetSuite.scala b/tests/src/test/scala/cats/tests/NonEmptySetSuite.scala new file mode 100644 index 0000000000..e6b4a34c12 --- /dev/null +++ b/tests/src/test/scala/cats/tests/NonEmptySetSuite.scala @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2018 Luka Jacobowitz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats +package tests + +import cats.laws.discipline._ +import cats.laws.discipline.arbitrary._ +import cats.data.NonEmptySet +import cats.kernel.laws.discipline.{BandTests, EqTests} + +import scala.collection.immutable.SortedSet + +class NonEmptySetSuite extends CatsSuite { + + checkAll("NonEmptySet[Int]", SemigroupKTests[NonEmptySet].semigroupK[Int]) + checkAll("NonEmptySet[Int]", ReducibleTests[NonEmptySet].reducible[Option, Int, Int]) + checkAll("NonEmptySet[String]", BandTests[NonEmptySet[String]].band) + checkAll("NonEmptySet[String]", EqTests[NonEmptySet[String]].eqv) + + + test("Show is not empty and is formatted as expected") { + forAll { (nes: NonEmptySet[Int]) => + nes.show.nonEmpty should === (true) + nes.show.startsWith("NonEmptySortedSet(") should === (true) + nes.show should === (implicitly[Show[NonEmptySet[Int]]].show(nes)) + nes.show.contains(nes.head.show) should === (true) + } + } + + test("Show is formatted correctly") { + val nonEmptySet = NonEmptySet("Test", SortedSet.empty[String]) + nonEmptySet.show should === ("NonEmptySortedSet(Test)") + } + + test("Creating NonEmptySet + toSet is identity") { + forAll { (i: Int, tail: SortedSet[Int]) => + val set = tail + i + val nonEmptySet = NonEmptySet(i, tail) + set should === (nonEmptySet.toSet) + } + } + + test("NonEmptySet#filter is consistent with Set#filter") { + forAll { (nes: NonEmptySet[Int], p: Int => Boolean) => + val set = nes.toSet + nes.filter(p) should === (set.filter(p)) + } + } + + test("NonEmptySet#filterNot is consistent with Set#filterNot") { + forAll { (nes: NonEmptySet[Int], p: Int => Boolean) => + val set = nes.toSet + nes.filterNot(p) should === (set.filterNot(p)) + } + } + + test("NonEmptySet#collect is consistent with Set#collect") { + forAll { (nes: NonEmptySet[Int], pf: PartialFunction[Int, String]) => + val set = nes.toSet + nes.collect(pf) should === (set.collect(pf)) + } + } + + test("NonEmptySet#find is consistent with Set#find") { + forAll { (nes: NonEmptySet[Int], p: Int => Boolean) => + val set = nes.toSet + nes.find(p) should === (set.find(p)) + } + } + + test("NonEmptySet#exists is consistent with Set#exists") { + forAll { (nes: NonEmptySet[Int], p: Int => Boolean) => + val set = nes.toSet + nes.exists(p) should === (set.exists(p)) + } + } + + test("NonEmptySet#forall is consistent with Set#forall") { + forAll { (nes: NonEmptySet[Int], p: Int => Boolean) => + val set = nes.toSet + nes.forall(p) should === (set.forall(p)) + } + } + + test("NonEmptySet#map is consistent with Set#map") { + forAll { (nes: NonEmptySet[Int], p: Int => String) => + val set = nes.toSet + nes.map(p).toSet should === (set.map(p)) + } + } + + test("reduceLeft consistent with foldLeft") { + forAll { (nes: NonEmptySet[Int], f: (Int, Int) => Int) => + nes.reduceLeft(f) should === (nes.tail.foldLeft(nes.head)(f)) + } + } + + test("reduceRight consistent with foldRight") { + forAll { (nes: NonEmptySet[Int], f: (Int, Eval[Int]) => Eval[Int]) => + val got = nes.reduceRight(f).value + val last = nes.last + val rev = nes - last + val expected = rev.foldRight(last)((a, b) => f(a, Now(b)).value) + got should === (expected) + } + } + + test("reduce consistent with fold") { + forAll { (nes: NonEmptySet[Int]) => + nes.reduce should === (nes.fold) + } + } + + + test("reduce consistent with reduceK") { + forAll { (nes: NonEmptySet[Option[Int]]) => + nes.reduce(SemigroupK[Option].algebra[Int]) should === (nes.reduceK) + } + } + + test("reduceLeftToOption consistent with foldLeft + Option") { + forAll { (nes: NonEmptySet[Int], f: Int => String, g: (String, Int) => String) => + val expected = nes.tail.foldLeft(Option(f(nes.head))) { (opt, i) => + opt.map(s => g(s, i)) + } + nes.reduceLeftToOption(f)(g) should === (expected) + } + } + + test("reduceRightToOption consistent with foldRight + Option") { + forAll { (nes: NonEmptySet[Int], f: Int => String, g: (Int, Eval[String]) => Eval[String]) => + val got = nes.reduceRightToOption(f)(g).value + val last = nes.last + val rev = nes - last + val expected = rev.foldRight(Option(f(last))) { (i, opt) => + opt.map(s => g(i, Now(s)).value) + } + got should === (expected) + } + } + + test("reduceLeftM consistent with foldM") { + forAll { (nes: NonEmptySet[Int], f: Int => Option[Int]) => + val got = nes.reduceLeftM(f)((acc, i) => f(i).map(acc + _)) + val expected = f(nes.head).flatMap { hd => + nes.tail.foldM(hd)((acc, i) => f(i).map(acc + _)) + } + got should === (expected) + } + } + + test("reduceMapM consistent with foldMapM") { + forAll { (nes: NonEmptySet[Int], f: Int => Option[Int]) => + nes.reduceMapM(f) should === (nes.foldMapM(f)) + } + } + + test("fromSet round trip") { + forAll { l: SortedSet[Int] => + NonEmptySet.fromSet(l).map(_.toSet).getOrElse(SortedSet.empty[Int]) should === (l) + } + + forAll { nes: NonEmptySet[Int] => + NonEmptySet.fromSet(nes.toSet) should === (Some(nes)) + } + } + + test("fromSetUnsafe/fromSet consistency") { + forAll { nes: NonEmptySet[Int] => + NonEmptySet.fromSet(nes.toSet) should === (Some(NonEmptySet.fromSetUnsafe(nes.toSet))) + } + } + + test("fromSetUnsafe empty set") { + val _ = intercept[IllegalArgumentException] { + NonEmptySet.fromSetUnsafe(SortedSet.empty[Int]) + } + } + + test("+ consistent with Set") { + forAll { (nes: NonEmptySet[Int], i: Int) => + (nes + i).toSet should === (nes.toSet + i) + } + } + + test("NonEmptySet#zipWithIndex is consistent with Set#zipWithIndex") { + forAll { nes: NonEmptySet[Int] => + nes.zipWithIndex.toSet should === (nes.toSet.zipWithIndex) + } + } + + test("NonEmptySet#size and length is consistent with Set#size") { + forAll { nes: NonEmptySet[Int] => + nes.size should === (nes.toSet.size) + nes.length should === (nes.toSet.size) + } + } + + test("NonEmptySet#concat is consistent with Set#++") { + forAll { (nes: NonEmptySet[Int], l: SortedSet[Int], n: Int) => + nes.concat(NonEmptySet(n, l)).toSet should === (nes.toSet ++ (l + n)) + } + } + + test("NonEmptySet#zipWith is consistent with Set#zip and then Set#map") { + forAll { (a: NonEmptySet[Int], b: NonEmptySet[Int], f: (Int, Int) => Int) => + a.zipWith(b)(f).toSet should ===(a.toSet.zip(b.toSet).map { case (x, y) => f(x, y) }) + } + } + +}