Skip to content

Typeclass instances for ZipLazyList and ZipStream seem to be unlawful #4830

Description

@satorg

There are Alternative and CommutativeApplicative instances for both ZipLazyList and ZipStream:

Details

implicit val catsDataAlternativeForZipLazyList: Alternative[ZipLazyList] & CommutativeApplicative[ZipLazyList] =

implicit val catsDataAlternativeForZipStream: Alternative[ZipStream] with CommutativeApplicative[ZipStream] =

implicit val catsDataAlternativeForZipStream: Alternative[ZipStream] & CommutativeApplicative[ZipStream] =

However, the corresponding laws have never been verified. The existing tests for both ZipLazyList and ZipStream check laws only up to CommutativeApply, with the following comment:

Can't test applicative laws as they don't terminate

Details

// Can't test applicative laws as they don't terminate
checkAll("ZipLazyList[Int]", CommutativeApplyTests[ZipLazyList].apply[Int, Int, Int])

// Can't test applicative laws as they don't terminate
checkAll("ZipStream[Int]", CommutativeApplyTests[ZipStream].apply[Int, Int, Int])

Moreover, ZipList, ZipVector, ZipSeq, as well as their *NonEmpty* counterparts, define instances only up to CommutativeApply.

Taken together, this strongly suggests that ZipLazyList and ZipStream cannot lawfully provide anything stronger than CommutativeApply.

Perhaps the Alternative and CommutativeApplicative instances for ZipLazyList and ZipStream should be moved to alleycats-core, while cats-core should retain only CommutativeApply.

Activity

  1. satorg commented on Feb 22, 2026

    @satorg
    ContributorAuthor

    @johnynek , wdyt?

  2. johnynek commented on Feb 22, 2026

    @johnynek
    Contributor

    I don't know why they don't terminate, but I think it could be that some methods in alternative would generate infinite streams for some of these values.

    Since they are infinite, we can't use a naive way to check equality of the streams, but they may be equivalent, so not exactly unlawful.

    An alternative way to check this may be this:

    Instead of using Eq[LazyList[A]] we could have Gen[Eq[LazyList[A]] which does:

    Gen.biasedSmallInt.flatMap { takeSize =>
      new Eq[LazyList[A]] {
        def eqv(a: LazyList[A], b: LazyList[A]) =
          a.iterator.take(takeSize).zip(b.iterator.take(takeSize)).forall { case (a, b) => Eq[A].eqv(a, b) }
      }
    }

    If the law passes:

    forall(genEq) { eq =>
    
     }

    Then it is true. The catch is, we would have to tune biasedSmallInt such that it could return a very large value (close to MaxInt), but it usually returns a smaller one (something like an geometric distribution or something tuned with the appropriate mean).

    This could work.

  3. joroKr21 commented on Feb 22, 2026

    @joroKr21
    Member

    You are correct. The reason is that pure for lazy list and stream are infinite:

    def pure[A](x: A): ZipLazyList[A] = new ZipLazyList(LazyList.continually(x))

    The other collections cannot define Applicative because they can't be infinite.

  4. satorg commented on Feb 23, 2026

    @satorg
    ContributorAuthor

    Ok, now I got it, thank you.
    I tried an Eq[ZipLazyList[A]] version with a length limit:

    implicit def customZipLazyListEq[A: Eq]: Eq[ZipLazyList[A]] = Eq.by(_.value.take(100_000))

    For the sake of smoke testing, the length was hardcoded to some big yet finite number.

    It works out for CommutativeApplicativeTests – all the checks pass indeed:

    checkAll("ZipLazyList", CommutativeApplicativeTests[ZipLazyList].commutativeApplicative[Int, Int, Int])

    However, AlternativeTests[ZipLazyList] still fail on "right distributivity":

    checkAll("ZipLazyList", AlternativeTests[ZipLazyList].alternative[Int, Int, Int])

    which is defined here:

    "right distributivity" -> forAll(laws.nonEmptyAlternativeRightDistributivity[A, B] _),

    and then here:
    def nonEmptyAlternativeRightDistributivity[A, B](fa: F[A], ff: F[A => B], fg: F[A => B]): IsEq[F[B]] =
    ((ff |+| fg).ap(fa)) <-> ((ff.ap(fa)) |+| (fg.ap(fa)))

    And it is not related to infinite values – the law fails even when fa, ff and fg all have size 1.

    Looking at the implementation of ap and combineK for ZipLazyList, I'm not sure if it really can be achieved.
    If we assume that ap is defined correctly, then I guess there's something wrong with combineK for ZipLazyList.

    Thoughts?

  5. joroKr21 commented on Feb 23, 2026

    @joroKr21
    Member

    Nice catch - yeah it doesn't make sense because it's forwarding to the non-zip instance:

          def combineK[A](x: ZipLazyList[A], y: ZipLazyList[A]): ZipLazyList[A] =
            ZipLazyList(cats.instances.lazyList.catsStdInstancesForLazyList.combineK(x.value, y.value))

    But of course that's wrong - it's doing concatenation here, not zipping so that changes the length of the list (when finite) and breaks ap which is zipping.

  6. joroKr21 commented on Feb 23, 2026

    @joroKr21
    Member

    So I think you could define a zipping semigroup (when the element forms a semigroup) but not a generic SemigroupK

  7. satorg commented on Feb 23, 2026

    @satorg
    ContributorAuthor

    We obtain a Semigroup instance with this method:

    def algebra[A]: Semigroup[F[A]] = combineK(_, _)

    which in its current form doesn't allow to provide Semigroup for elements.

  8. satorg commented on Feb 23, 2026

    @satorg
    ContributorAuthor

    So I guess what it implies is that ZipLazyList cannot be Alternative. But it can be CommutativeApplicative and Semigroup (which can be provided as a separate instance).

    I wonder if it is possible to strip Alternative from ZipLazyList without breaking source compatibility.

  9. joroKr21 commented on Feb 23, 2026

    @joroKr21
    Member

    No, of course source compatibility will be broken. We can keep binary compatibility by making it non-implicit and deprecating it.

  10. self-assigned this
    on Mar 3, 2026
  11. satorg commented on Mar 7, 2026

    @satorg
    ContributorAuthor

    Found this very old issue that seems to be related to this one: #3082

  12. johnynek commented on Mar 7, 2026

    @johnynek
    Contributor

    Parallel has always been rather adhoc. Why are any of the laws what they are? Why is the Applicative map2 allowed to be different but not the pure? Not very clear.

  13. satorg commented on Mar 8, 2026

    @satorg
    ContributorAuthor

    Also, there's another curious dicsussion in #3777. It looks like Parallel can imply either fail-fast or fail-last (error-accumulating) strategy, and currently it is up to the implementation which one is actually implied.

    I feel that if Typelevel ever considers a new major Cats version, then the whole Parallel paradigm should be significantly revisited.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions