Search code examples
scalafunctional-programmingevaluationscala-streams

Is it a library bug in a functional language when a function with the same name but for different collections produces different side effects?


I'm using Scala 2.13.1 and evaluate my examples in a worksheet.

At first, I define two functions that return the range of a to (z-1) as a stream or respectively a lazy list.

def streamRange(a: Int, z: Int): Stream[Int] = {
  print(a + " ")
  if (a >= z) Stream.empty else a #:: streamRange(a + 1, z)
}

def lazyListRange(a: Int, z: Int): LazyList[Int] = {
  print(a + " ")
  if (a >= z) LazyList.empty else a #:: lazyListRange(a + 1, z)
}

Then I call both functions, take a Stream/LazyList of 3 elements and convert them to List:

streamRange(1, 10).take(3).toList    // prints 1 2 3
lazyListRange(1, 10).take(3).toList  // prints 1 2 3 4

Here I do the same again:

val stream1 = streamRange(1, 10)     // prints 1
val stream2 = stream1.take(3)
stream2.toList                       // prints 2 3

val lazyList1 = lazyListRange(1,10)  // prints 1
val lazyList2 = lazyList1.take(3)
lazyList2.toList                     // prints 2 3 4

The 1 is printed because the function is visited and the print statement is at the start. No surprise.

But I don't understand why the additional 4 is printed for the lazy list and not for the stream.

My assumption is that at the point where 3 is to be concatenated with the next function call, the LazyList version visits the function, whereas in the Stream version the function is not visited. Otherwise the 4 would not have been printed.

It seems like unintended behaviour, at least it is unexpected. But would this difference in side effects be considered a bug or just a detailed difference in the evaluation of Stream and LazyList.


Solution

  • Stream implements #:: using Deferer:

      implicit def toDeferrer[A](l: => Stream[A]): Deferrer[A] = new Deferrer[A](() => l)
    
      final class Deferrer[A] private[Stream] (private val l: () => Stream[A]) extends AnyVal {
        /** Construct a Stream consisting of a given first element followed by elements
          *  from another Stream.
          */
        def #:: [B >: A](elem: B): Stream[B] = new Cons(elem, l())
        /** Construct a Stream consisting of the concatenation of the given Stream and
          *  another Stream.
          */
        def #:::[B >: A](prefix: Stream[B]): Stream[B] = prefix lazyAppendedAll l()
      }
    

    where Cons:

    final class Cons[A](override val head: A, tl: => Stream[A]) extends Stream[A] {
    

    Whereas LazyList implements #:: with its own Deferer:

      implicit def toDeferrer[A](l: => LazyList[A]): Deferrer[A] = new Deferrer[A](() => l)
    
      final class Deferrer[A] private[LazyList] (private val l: () => LazyList[A]) extends AnyVal {
        /** Construct a LazyList consisting of a given first element followed by elements
          *  from another LazyList.
          */
        def #:: [B >: A](elem: => B): LazyList[B] = newLL(sCons(elem, l()))
        /** Construct a LazyList consisting of the concatenation of the given LazyList and
          *  another LazyList.
          */
        def #:::[B >: A](prefix: LazyList[B]): LazyList[B] = prefix lazyAppendedAll l()
      }
    

    where sCons:

    @inline private def sCons[A](hd: A, tl: LazyList[A]): State[A] = new State.Cons[A](hd, tl)
    

    and Cons:

    final class Cons[A](val head: A, val tail: LazyList[A]) extends State[A]
    

    It means that on the very definition level:

    • Steam lazily evaluates it tail's creation
    • LazyList lazily evaluates its tail's content

    Difference is noticeable among other in side-effects... which neither of these if made for.

    If you want to handle potentially infinite sequences of impore computations, use a proper streaming library: Akka Streams, FS2, ZIO Streams. Build-in streams/lazy list are made for pure computations and if you step into impure directory you should assume that no guarantees regarding side effects are provided.