Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Already existing functional way for a retry-until in Scala?

Is there a functional/Scala way to call a function repeatedly until it succeeds, while reacting to failed attempts?

Let me illustrate with an example. Suppose I want to read an integer from standard-in, and retry if the user did not in fact enter an integer.

Given this function:

def read_int(): Either[String, Int] = {
  val str = scala.io.StdIn.readLine()
  try {
    Right(str.trim().toInt)
  } catch {
    case _: java.lang.NumberFormatException => Left(str)
  }
}

And this anonymous functions:

val ask_for_int = () => {
  println("Please enter an Int:")
  read_int()
}

val handle_not_int = (s: String) => {
  println("That was not an Int! You typed: " + s)
}

I would use them like this:

val num = retry_until_right(ask_for_int)(handle_not_int)
println(s"Thanks! You entered: $num")

My questions are:

  • Does something like retry_until_right already exist in Scala?
  • Could it be solved with existing facilities? (Streams, Iterators, Monads, etc.)
  • Do any of the FP libraries (scalaz?) offer something like this?
  • Could I do anything better / more idiomatic? (*)

Thanks!

*) Apart from the snake_case. I really like it.

like image 358
Sebastian N. Avatar asked Feb 13 '15 18:02

Sebastian N.


4 Answers

def retry[L, R](f: => Either[L, R])(handler: L => Any): R = {
    val e = f
    e.fold(l => { handler(l); retry(f)(handler) }, identity)
}
like image 65
Lee Avatar answered Nov 18 '22 22:11

Lee


I think the Try monad together with the Iterator.continually method is suitable for this general problem. Of course, this answer could be adopted to use Either if you are so inclined:

def retry[T](op: => Try[T])(onWrong: Throwable => Any) = 
    Iterator.continually(op).flatMap { 
        case Success(t) => Some(t)
        case Failure(f) => onWrong(f); None 
    }.toSeq.head

Then you can do:

retry { Try(scala.io.StdIn.readLine.toInt) }{ _ => println("failed!") }

Or you can even hide the Try part of the implementation, and give onWrong a default value, and make it a second parameter instead of a curried function:

def retry[T](op: => T, onWrong: Throwable => Any = _ => ()) = 
    Iterator.continually(Try(op)).flatMap { 
        case Success(t) => Some(t)
        case Failure(f) => onWrong(f); None 
    }.toSeq.head

So then you can simply:

retry { scala.io.StdIn.readLine.toInt } { _ => println("failed") }

Or

retry { scala.io.StdIn.readLine.toInt }
like image 29
Ben Reich Avatar answered Nov 18 '22 20:11

Ben Reich


Here's an alternative solution using scalaz.concurrent.Task:

import scalaz.concurrent.Task

def readInt: Task[Int] = {
  Task.delay(scala.io.StdIn.readLine().trim().toInt).handleWith {
    case e: java.lang.NumberFormatException =>
      Task.delay(println("Failure!")) flatMap (_ => readInt)
  }
}

And a retry wrapper (a bit less flexible):

def retry[A](f: Task[A])(onError: PartialFunction[Throwable, Task[_]]): Task[A] =
  f handleWith (onError andThen (_.flatMap(_ => retry(f)(onError))))

val rawReadInt: Task[Int] = Task.delay(scala.io.StdIn.readLine().trim().toInt)

val readInt: Task[Int] = retry(rawReadInt) {
  case e: java.lang.NumberFormatException => Task.delay(println("Failure!"))
}

Explanation

scalaz.concurrent.Task[A] is a monadic structure that eventually returns a A. It uses a trampoline to (usually) avoid stack overflows. It also handles exceptions, and can either rethrow the exception, or represent the exception via \/ (scalaz's right-biased Either).

handleWith allows one to write a handler for a Throwable which was raised by the Task. The result of this handler is a new Task to run afterwards. In this case, we will just print out an error message, and call the original Task again using flatMap. Since Task is a trampolined construct, this should be safe.

Try this with readInt.run - this will run the task on the current thread, and eventually return the Int value passed in.

like image 5
Gary Coady Avatar answered Nov 18 '22 21:11

Gary Coady


This was my first tail-recursive implementation:

@scala.annotation.tailrec
def retry_until_right[WRONG, RIGHT](generator: () => Either[WRONG, RIGHT])(on_wrong: WRONG => Any): RIGHT = {
  generator() match {
    case Right(right) =>
      right
    case Left(wrong) =>
      on_wrong(wrong)
      retry_until_right(generator)(on_wrong)
  }
}

But wanting to re-use existing libraries, I then switched to this approach using iterators:

def retry_until_right[WRONG, RIGHT](generator: => Either[WRONG, RIGHT])(on_wrong: WRONG => Any): RIGHT =
  Iterator.continually(generator).flatMap {
    case Left(value) =>
      on_wrong(value)
      None
    case Right(value) =>
      Some(value)
  }.toSeq.head

Which can be used like this:

val num = retry_until_right(ask_for_int()) { str =>
  println("Ivalid input: " + str)
}
println("Thanks! You entered: " + num)

However, it could be argued that hiding the Iterator from plain view inside the implementation can be inflexible. What if the developer wants to perform further operations on it? (mapping, etc.)

Instead, the operations that react on "wrong" values and finally select the first "right" one can be abstracted into an "extension" class specific for iterators of the Either[L,R] type, like this:

implicit class EitherIteratorExtensions[L, R](it: Iterator[Either[L, R]]) {
  def onLeft(callback: L => Any) =
    it.map {
      case left @ Left(value) =>
        callback(value)
        left
      case right => right
    }

  // take only Right elements
  def takeRight: Iterator[R] = 
    it.flatMap {
      case Left(_) =>
        None
      case Right(value) => Some(value)
    }

  // iterate and fetch the first Right element
  def firstRight: R = {
    takeRight.toSeq.head
  }
}

Now we can comfortably use the needed methods, in succinct code, while retaining control of the Iterator like this:

val num = Iterator.continually(ask_for_int()).onLeft(handle_not_int).firstRight

println("Thanks! You entered: " + num)

Though I'm happy with this approach, I still wonder if this is not part of an existing library already...

like image 3
Sebastian N. Avatar answered Nov 18 '22 21:11

Sebastian N.