Why Functional Programming Matters

HKT HKT
Views

The title of the post might seem clickbait. But I intend to show you why functional programming matters with a more relatable and realistic example than the ones in John Hughes’s seminal paper.

Here is a scenario. Imagine you have to …

  • Read from a buffer a certain of numbers items batchSize within a specific timeout
  • Else, take whatever is available in the buffer after the timeout
  • Invoke a callback with the Set of items read
  • Don’t invoke the callback if no items were read (after a timeout)
  • Keep repeating this forever
  • Canceling the process should flush all the remaining items in the buffer to the callback
  • Expose thread-safe APIs to write to the buffer.

That is quite a complex algorithm. Several things are at playβ€”thread-safe reads, timeouts, cancelation, etc. There is more than one way to implement it. The question is which way is comprehensible and maintainable for both the author and the reader.

Fork in the road is imperative vs functional.

Let me show you the functional way. The following is a plausible implementation1 of the buffer read loop using Cats Effect (v3).

import cats.effect.std.Queue

final class QueueOps(private val queue: Queue) extends AnyVal {
	def takeN(n: Int, timeout: FiniteDuration): F[List[A]] =
	  for {
	    ref <- Ref.of[F, List[A]](List.empty)
	    takeOne: F[Unit]         = queue.take.flatMap(a => ref.update(_ :+ a))
	    readItems: F[List[Unit]] = List.fill(n)(takeOne).sequence
	    _      <- Async[F].race(Async[F].sleep(timeout), readItems)
	    result <- ref.get
	  } yield result
}

def readLoop(
  source: Queue[F, A],
  timeout: FiniteDuration,
  callback: Set[A] => F[Unit]
): F[Fiber[F, Throwable, Unit]] =
  source
  .takeN(batchSize, timeout)
  .iterateUntil(_.nonEmpty)
  .map(_.toSet)
  .flatMap(callback)
  .foreverM[Unit]
  .onCancel(flush(source, callback))
  .start

def flush[A](source: Queue[F, A], sink: Set[A] => F[Unit]): F[Unit] =
  source
  .tryTakeN(None)  // read all elements from the queue without blocking
  .flatMap(_.grouped(batchSize).map(_.toSet).toList.traverse_(sink))

The above code is self-explanatory, straightforward2, concise, and follows the steps in the original description.

At first glance, it might seem to be just fluent API. But it is pure functional code. As Hughes notes in his paper, lazy evaluation and higher order functions contribute to the modularity of software. The above code is lazy3 and takes the callback function as user input.

The publish API implementation could be as follows:

def publish[A](value: A): F[Unit] =
	buffer
	  .offer(value)
	  .handleErrorWith { _ =>
      Sync[F].delay(println(s"Value not published because buffer is full: $value"))
    }

The amount of imperative code you must write to set up the necessary infrastructure is daunting. It does not matter if you use popular and well-built imperative libraries. In contrast, functional code is relatively small and much more modular, using lawful patterns. That’s why when you find yourself in a situation where the above code does not work, you go back to first principles and check if you have laid out your program as desired instead of debugging mutations.

Also note while I mentioned the use of Cats Effect, I did not explicitly mention IO. The above code is generic and works for any F4. Of course, IO is the de facto choice. The point is that I did not have to mention anything about IO explicitly.

In conclusion, functional programming matters, particularly for large and/or complex programs. Such programs should be simple5 to write, comprehensible, and easy to debug.

Most programmers have seen them, and most good programmers realize they’ve written at least one. They are huge, messy, ugly programs that should have been short, clean, beautiful programs - Jon Bentley (Programming Pearls).


  1. Choice of the library is not so important. You could implement with ZIO or roll out your own. ↩︎

  2. There is a bit of learning curve no matter the choice of the library ↩︎

  3. Here I have started the Fiber using .start, otherwise the construction of the Fiber is lazy. ↩︎

  4. … as long as the F has an implicit Async instance ↩︎

  5. Simple does not discount the learning curve for Cats Effect or any other library and the machinery that powers our code. ↩︎

scala fp