Search code examples
scalafunctional-programmingioio-monadcats-effect

Infinite loop when trying to read from Java Scanner inside an IO Monad


I was trying to do an exercise for a Cats Effect course in which I was required, among other things, to read a document line by line and wait a certain amount of time between lines.

The teacher's solution was the following:

def readLines(sc: Scanner): IO[Unit] =
  if sc.hasNextLine then IO(sc.nextLine).debug *> IO.sleep(1.second) *> readLines(sc)
  else IO.unit

However, I attempted to create a tail-recursive function from the same function above, and it looked something like this:

def readLines(sc: Scanner): IO[Unit] =

  @tailrec
  def tailReader(accum: IO[Unit]): IO[Unit] =

    if sc.hasNextLine then tailReader(accum *> IO(sc.nextLine).debug *> IO.sleep(1.second))
    else accum

  tailReader(IO.unit)

end readLines

The problem is, the first implementation does read line by line and finishes. On the other hand, my implementation never finishes and never reads even the first line of the document, and is then followed by an OutOfMemoryError.

My question is, is there any way to implement the first function in a way that is tail-recursive and purely functional?

I know there is a way to make the first implementation stack-safe by using the >> operator instead of the *> one. However that's not what I'm looking for, instead I'm looking to be able to use the @tailrec annotation.

The way I was able to partially fix the error was to create a value at the beginning of the function that saves the current line from the scanner, like this:

def readLines(sc: Scanner): IO[Unit] =

  @tailrec
  def tailReader(accum: IO[Unit]): IO[Unit] =

    val currentLine = sc.nextLine

    if sc.hasNextLine then tailReader(accum *> IO(currentLine).debug *> IO.sleep(1.second))
    else accum >> IO(currentLine).debug.void

  tailReader(IO.unit)

end readLines

Although this solved the problem, I am pretty sure that the solution is not purely functional.

I would be very grateful if you could give me a hand with this problem, I am just starting to understand the world of effects, and your help would come in handy!

For everything else, have a really nice day.


Solution

  • Some clarifications:

    1. Regarding >> and *>:
      def *>[B](that: IO[B]): IO[B] =
        productR(that)
    
      def productR[B](that: IO[B]): IO[B] =
        flatMap(_ => that)
    
      def >>[B](that: => IO[B]): IO[B] =
        flatMap(_ => that)
    
    1. You can do stack safe recursion with flatMap because of how Monad is designed in Cats.

    I'll swap >> and *> with flatMap in the following examples and drop @tailrec as stack safety is already guaranteed.

    Now what's wrong here

    def readLines(sc: Scanner): IO[Unit] = {
    
      def tailReader(accum: IO[Unit]): IO[Unit] = {
        if (sc.hasNextLine)
          tailReader { 
            accum
              .flatMap(_ => IO(sc.nextLine()))
              .flatMap(_ => IO.sleep(1.second))
          }
        else
          tailReader(accum)
      }
    
      tailReader(IO.unit)
    }
    

    We're checking sc.hasNextLine continually while not evaluating sc.nextLine. IO(sc.nextLine) is only a description at this point, nothing is evaluated.

    Make the recursive call part of the flatMap chain to solve this:

      def tailReader(accum: IO[Unit]): IO[Unit] = {
        if (sc.hasNextLine)
          IO(sc.nextLine())
            .flatMap(_ => IO.sleep(1.second))
            .flatMap(_ => tailReader(accum)) //make the call here
        else
          tailReader(accum)
      }
    

    then there's another small issue with the recursive method.
    tailReader(..) returns an IO only after it checks for next line once.

    Use IO.suspend to completely separate definition from evaluation.

       def tailReader(accum: IO[Unit]): IO[Unit] = IO.suspend {
        if (sc.hasNextLine)
          IO(sc.nextLine())
            .flatMap(_ => IO.sleep(1.second))
            .flatMap(_ => accum)
        else
          tailReader(accum)
      }
    

    Now tailReader(IO.unit) returns the IO without calling hasNextLine even once.