Functional programming observations

A few of the motivating ideas

29 August 2021

Since beginning my role at SafeGraph, I have been learning, reading, and writing a lot of Scala, which is a functional language. I have observed a few things that seem self-evident to functional programmers, but might seem strange from a more traditional, object-oriented approach.

Mutable state prohibition

There is an idea in functional programming that if a variable can change value, it becomes difficult to deal with. As a programmer you must hold in your mind all of the places where it can be changed and how it might be changed. A common phrase to express this idea is that "it is difficult to reason about." Wouldn't programming be easier if variables never changed?

From an object oriented perspective, this seems very strange. Having variables that can vary seems almost inherent to programming. However, I would say that object-oriented programmers feel the same sense of frustration, even if its not stated explicitly. This frustration is the primary reason for encapsulating mutable state and providing strict access via an API. Object-oriented programming is a different approach to solving the same problem.

class IAmMutable {
  int i = 0;
  function changeMe () {
    i += 1
  }
}

Going a bit further, what happens when you prohibit mutable state entirely? Well, for-loops are now off limits because they require an mutable iterator. Instead, the functional approach relies on recursion, where each function call passes the next iteration to be processed. Additionally, getter/setter methods are no longer needed on custom data types, since the data never changes. Common data structures such as linked-lists and random-access arrays need to be handled in a completely different fashion.

def sum (arr: Vector[Int]): Int = {
  def loop(i: Int, total: Int): Int = {
    if (i == array.length())
      total
     else
      loop(i+1, total+arr[i]) // next iteration
  }
  loop(0, 0)
}

Allergic to side affects

In an object-oriented approach, side-affects run rampant. For instance, myData.doSomething() affects the state of the application in a way that is not apparent in its return value. Functional programmers are severely allergic to this type of design to the point that there is a strong effort to purify side-effects entirely out of the application. This means that every function must be "pure", so that the only thing it can modify is its return value. Writing pure code has strong implications on the methods for handling logs, exceptions, and access to external services.

Of course, you cannot completely rid the application of side affects; at some point, something has to happen to outside world. Otherwise, your program is completely useless. The functional approach typically deals with this in two ways ...

  • push all side-effects towards the "edge" of the application
  • make it very explicit where the side-effects happen

This means structuring the application such that all of the business logic is consolidated to a pure transform in the middle, which is sandwhiched by side-affects before and after. Since side-affects are not represented in a function's return value, they can often be made explicit by voiding the return.

def main(): Unit {
  // side effects happen here
  val data = readData() // this data may change depending on read time

  // pure business logic
  val betterData = transform(data)

  // side effects happen here, no return value
  writeData(betterData)
}

Extreme abstraction

Software development is ultimately an exercise in abstraction. This is true for any language and design method. But, it seems that functional programmers tend to lean into this idea a bit more aggresively than other communitities. Take for instance the process of looping through an array. Despite the simplicity of this operation, there are details involved that the functional programmer cannot be bothered to reproduce. So, these details get abstracted away into a set of high-order functions such as map, reduce, fold, filter, flatMap, etc.

We take this further by introducing abstract data types, such as the monoid and monad. These abstractions enable the functional programmer to create libraries of operations without requiring detailed knowledge of the structure or purpose of the data that a type represents.

The upside of being aggressive with your abstractions is a very declarative codebase. Large-scale operations read almost like instructions, free of implementation details. However, it also tends to result in very terse and dense code, which can be challenging to process for those who are not familiar with the abstractions.