More Performant, Testable Code with Functional Programming. (FP language optional!)
Is functional programming worth learning?
Functional programming can make code in your favorite language more succinct, testable, performant, and maintainable. (Functional programming language optional!)
First, let’s quickly define functional programming (FP):
A programming paradigm that avoids mutation and side effects.
Notice FP does not require a functional programming language. FP is a way of thinking: you can do FP in your favorite languages like Python, JavaScript, Java, C, etc… (Of course, languages designed for FP make it easier with built-ins like first-class functions and immutable data structures.)
So how does avoiding mutation and side-effects improve your code?
FP results in more performant code.
- Functions without side-effects (interactions with external mutable state) are trivial to parallelize. Reading/writing to a global variable results in side-effects. So does all I/O: logging to the console, rendering a DOM element, clicking a button, etc… Google’s MapReduce is an example of FP that can distribute a computation over thousands of computers because there are no side-effects to manage.
- Reference equality testing of immutable objects can assume objects have not changed if they refer to the same object. If the object is not immutable, this assumption cannot be made and a deep comparison must be made. Om is an example of using FP reference equality checks to make React’s virtual DOM 2-3X faster.
FP results in more testable code.
Side-effects are difficult to test. How do you confirm a new DOM element was correctly rendered after clicking a button? Sure, you could manually click and inspect yourself, but… many people reach for a headless browser like Playwright to automate this type of testing.
A simple FP trick “pushes” the side-effects to the edges, freeing the core code from side-effects:
- Instead of accessing global variables, replace them with explicit inputs/outputs of the unit being tested.
- Instead of I/O, generate JSON descriptions of the expected inputs and outputs. JSON describing a click and new DOM element are much easier to (automatically) test.
At some point these JSON descriptions have to be turned into real I/O, so isn’t this just extra, wasted effort?
Not quite! An extra step has been introduced, but it allows us to separate the core business logic from the I/O. So this method is called Functional Core, Imperative Shell:
- The functional core is where the (important) complex branching of the business logic happens.
- The imperative shell contains harder to test side-effects, but should generally have little to no complex branching logic. (Often unit testing of the imperative shell can be skipped; any bugs will be caught during integration testing.)
- Complex branching and side-effects become easier to test when they don’t happen at the same time!
While we’re at it, let’s get rid of errors from the functional core: pretend database/network requests never time out; all functions always return without throwing exceptions. This allows the functional core to focus on the main business logic’s happy path. Like side-effects, errors can be pushed to the edges, where they are easier to test thanks to the lack of complex branching logic:
- Function inputs/outputs are wrapped in objects that hold either valid data or information about the error(s).
- If a function receives an input with error information, it just passes it on to be dealt with later.
- Scott Wlaschin calls this “railway-oriented programming” because it can be visualized as two parallel tracks: a happy path and an error path. (With switches from the happy path to the error path.)
Finally, let’s make your state immutable. Once a state is set, it can never change. To change the state of your application or module, you assign a whole new state. Generally functions input the current state, then output the new state. Immutable state has many benefits:
- Easier to reason about. The value of a state doesn’t depend on when you check it. If a state has a certain value, it must have always had that value.
- Easier to test: test setup simply becomes constructing the appropriate input state. Test harnesses, stubs, and other complex test setup are no longer needed to manipulate mutable state into the desired initial state for specific test scenarios.
- Reference equality testing can improve program performance.
- “Undo,” “Redo,” “Resume,” and “Time travel” are powerful features that are almost free with immutable state.
- It becomes trivial to port your functional core to multiple platforms: CLI, web, mobile app, native app, etc… You can simply implement new imperative shells to connect to the same functional core.
Learn more about Functional Core, Imperative Shell:
FP results in more succinct code.
FP tends to be more declarative than instructive. FP describes the final data transformation desired, but it doesn’t specify exactly how the transformation should be done.
For example, map()
describes how to transform an array, but it doesn’t specify where to start or what order to process the array. Those are (usually) unnecessary details that may be omitted. Compare to a for
loop with an index variable.
FP results in more maintainable code.
- The increased maintainability of FP code comes from a combination of more succinct and more testable code. There is less code surface where errors can be introduced and it is easier to write tests for this code. (So more tests will be written, right?)
- Immutable data is also easier to reason about.
Comments
Post a Comment