Who is this article for?
Someone who likes to learn new ways to solve problems, and is curious about other perspectives in the software field
What does this article assume you know?
- How to read Javascript (classes, functions, and objects)
- Nothing about Object-Oriented programming
- Nothing about Functional programming
What will we cover (TLDR)?
- Why are some people very passionate about sharing the functional style with others?
- TLDR: the more you invest in the functional style, the more your program becomes simple, predictable, and easy to reason about.
- Why would you benefit from exploring the functional style?
- TLDR: see above
- How can you explore the functional style?
- TLDR: Solve a simple problem that excites you (e.g. "give me a random emoji," "download the top post from reddit.com/r/aww") with the limitations described after the "functional vanilla" example below. If you made it this far and enjoyed the process, check out the Learn More section.
Foreword
Let me pose a question for you to ask yourself before reading this article:
For whatever project you're working on right now, what kinds of errors do you want to have happen at runtime, and what kinds of errors do you want to happen while you write code?
or in other words,
How much complexity are you willing to sign up for right now in exchange for a more predictable Production environment?
If you're anything like me, there's not much you wouldn't do to prevent runtime errors in production.
Motivating Example
Consider the following exercise:
"As a user, I need to regularly back up some important file on my computer, so that I can refer to previous versions and make edits to it fearlessly.
Let's write a very simple script that for some foo.txt:
- creates foo_backups/ if not exists
- compares the current state of foo.txt against the most recent backup in foo_backups/
- if foo.txt has changed:
- create a new file in foo_backups/ with a name of "right now" in ISO format (
new Date().toISOString()
)
- create a new file in foo_backups/ with a name of "right now" in ISO format (
For the sake of brevity, here is a trimmed-down version of a procedural way of implementing this:
full working sample available here
Some qualities to make a note of:
- This is incredibly simple. Reaching for something like OOP or FP may actually introduce more complexity than value, especially for a simple script like this. Keep that in mind, that no paradigm is one-size-fits-all, and that even procedural styles like Go can be useful sometimes. This is why multi-paradigm languages like Javascript, Rust, and Python make great general-purpose languages, because you can change the problem-solving style on the fly to suit the problem at hand.
- It's fast. This is probably the most naturally performant way of writing code, because there's nothing we've added on top of "think like a computer" so that we can write code that "thinks like a human." One of the costs of abstraction are usually dings to performance.
- The individual steps here are very verbose and order-dependent
- Our main function knows a little too much about what goes into backing up a file
- None of this logic is reusable, in the full sample there's a lot of repeated logic and statements that could be re-used and abstracted
Let's organize things a bit with classes:
full working sample available here
That's a bit better! We've created useful abstractions around ideas like "file," "path," and "backup actor." There are still some issues, though:
- This has suffered from code explosion as a result of the boilerplate associated with writing classes. Usually there's a rule of 1 class-per-file which means our 100-line script has turned into 5 files, each 40 lines long, which makes the flow of who-depends-on-who harder to follow.
- We've added a lot of bespoke code and business logic, introducing surfaces for bugs to appear
- This has the appearance of being less order-dependent, but in actuality we're still writing very statement-oriented procedural code. we've just organized our procedures into ideas that have state.
- Because we're still very imperative, the actual implementation is hard to reason about because it's complex and order-dependent. If the implementation were more expressive, the code would be much easier to read and understand.
- By mixing state and behavior, we've made it more difficult to reason about what each function does; does Path.join mutate the path in-place or return a new Path? How do we test the File class? Does the File constructor throw an exception if you try to make a new one on a path that doesn't exist?
It's important to note that adding a type system would make this example a bit more transparent, but this would come at the cost of even more complexity, code explosion, and time spent in development. In the case of this trivial script, a type system like typescript probably doesn't make sense on its own merit, but in a production application it definitely would.
Now let's take the same concept and remove all the imperative statements and mixing of data and behavior:
full working sample available here
Let's review some of the constraints that were placed on this code sample before we start comparing:
- No
if/else
- No explicit looping
- No writing
class
es or methods - No
throw
ing - All side-effecting functions start with
do
- No function statement bodies (meaning no braces on arrow functions
() => {}
except when absolutely necessary) - We use "module"-style objects like
const Path = {..}
to organize functions and keep implementation details private
What do all these limitations give us?
- The code is more expressive. as mentioned earlier, this makes the code much easier to reason about
- Functions clearly advertise when they do something, making the code easier to reason about
- We've kept the useful "Path," "File," and "Backup actor" ideas from the object-oriented sample, meaning we have the same benefits of reusability, but we've removed all the state from the ideas and made callers give us data to transform. This makes the code easier to reason about.
- We've removed all exceptions, making the code easier to reason about.
You may have picked up on a theme; the value-add of the limitations we've placed on ourselves makes our code easier to read, write, understand, and debug. Our code gets closer to our silly human way of thinking and abstracts the details of what-goes-where-when.
Note: if you plan on reading (or have already read) the full code samples, let me warn you that the functional examples are not pretty and aren't supposed to be. There are many tools and shorthands that fall out of the core principles of FP that make code even more concise, allowing you to only say what you mean.
One of the things that falls out of functional programming is that managing complexity becomes very simple because the paradigm itself is super simple; it's just functions®️!
Types
A quality of imperative environments is that a seemingly innocent abstraction could do a number of things that are not expressed in a type system like C#'s, Java's, or Typescript's.
declare class Adder {
/** I promise I won't delete `C:\Windows\System32` 🤭 */
add(num: number): number;
}
In this example, Adder#add
could throw an exception, it could stop our process altogether, it could log to the console when we don't want it to, it could change some shared mutable state that we rely on, etc.
When choosing a language, one thing we must evaluate is whether it is really good at delivery (As an engineer I want to implement features quickly), safety (As an engineer I want as much complexity to happen while I write the code instead of in production), or strikes a balance inbetween.
Let's suppose you're tasked with developing an autopilot system for the new Boeing 757 Max air control unit. The solution to this problem bears responsibility for hundreds of actual human lives. There's an enormous ethical burden on you to prove that the system is reliable, so you would most likely want to reach for languages and tooling that maximize safety and security. On the other hand, if you were developing a personal blog site, there's much more room for mistakes and runtime issues.
We should strive to evaluate each problem we need to solve and choose the tool that strikes the right balance of safety & delivery for that problem. One benefit to using multi-paradigm languages (e.g. JS, TS, Python, Rust) is that we have lots of flexibility to choose a problem-solving style without introducing a new language to an existing ecosystem.
Here is an entirely subjective spectrum of "design-time" safety in programming tools / environments based on my experience in the industry:
Expand
no complexity as much complexity
at design-time at design-time as
| possible
| ---------------------------------------------|
[1] | | | [6]
[2] [4] [5]
[3]
[1]: "dynamic" languages;
ruby, javascript, python (w/o type annotations)
[2]: type checking on top of "dynamic" languages;
flow, typescript, python (w/ types)
[3]: languages w/ full control of memory;
C & C++
[4]: languages with strict runtime guarantees;
C#, Java, Kotlin, Go
[5]: Exception and null-less languages;
Rust
[6]: Arbitrary side-effect-less languages;
Haskell/Purescript
Learn More
Learning Rust, Haskell, and OCaml have had a profound impact on my well-roundedness as an engineer and allow me to reach for a number of styles of problem-solving for each task at hand.
Like many others, I see the industry shifting slowly away from statement and effectful to expressive and functional, and I couldn't be more excited.
If you're interested in learning more, here are some resources I found helpful:
YouTube(0h 46m) .. Why Isn't Functional Programming the Norm? - Richard Feldman
YouTube(1h 05m) .. Functional Design Patterns - Scott Wlaschin
YouTube(1h 50m) .. Implement a JSON parser in 100 lines of Haskell - Tsoding
Book (free) ...... Learn you a Haskell for a Great Good
Paper (free) ..... Programming Paradigms for Dummies
Glossary
You don't need to read anything from this section, but I feel it's important to define the language I'm using so that you can clearly understand me.
Procedure, Methods, and Functions
-
Procedure: A function that only has access to the global scope (not a parent procedure's), and does not operate on a
this
. -
Method: A function that is attached to some data and can operate on it (it uses the
this
keyword and is invoked asinstance.foo()
). - Function: In this article I will refer to closures, procedures, methods, and functions as simply "functions."
- Side effects: A function performs a side effect if it does anything other than return a value derived from its inputs. Examples include logging to the console, reading from the file system, modifying a value that exists somewhere else.
- Purity: A function is pure if it does not perform side effects.
Data structures
- Object: a thing that is both state and can do things with that state (ex. a JS Array is an Object because it has methods attached to the raw array data)
-
Record: a list of key-value pairs with no behavior (ex. JS "plain objects"
{foo: 'bar'}
are Records, since it's unusual to attach methods to them)
Philosophy
- Abstraction: A fundamental concept in all programming languages & paradigms, it's incredibly valuable to refer to ideas rather than specifics. For example, an idea called FileReader allows me to not care about the specifics of reading a file.
- Modularity / Encapsulation: keeping the brittle nitty-gritty details about how we solve a problem from the person with the problem. If we had a class (or module) named File, that was purpose-built to answer questions like "What are the contents of this file?," "does this file exist?," we would not want users to know how we read files on *nix vs windows. Note that encapsulation and modularity are a specific method of Abstraction because we can provide a very abstract way to answer these questions without the asker having to know how we answer them.
- Expressive: Often hand-in-hand with declarative, expressive code is defined by replacing statements with expressions (see Figure A). Small expressions are always easier to reason about than imperative code, but large expression trees can be equally as difficult to reason about as a complex procedure.
- Inheritance: A tool that lets us say "A Dog is an Animal" and "A Folder is a File." Inheritance's "is-a" relationships are often used to describe Objects in terms of each other using "is-a" relationships. This lets us re-use functionality (which should actually be avoided) and serves as a powerful tool for abstraction.
Styles
- Procedural / Imperative style (Figure B): functions and data are totally different & separate, and code is a sequence of effects; "do x then y then z." Mutating some shared state is usually the "output" of procedures, rather than returning some new state or expression.
- Declarative style: code does not care about the order or context it is run in, and allows us to write code that reads as a conversation, rather than a recipe.
- Object-Oriented (Figure C): In OOP, we structure our program as a neat tree of Objects, where Objects are our central tool for encapsulating and abstracting.
- Functional (Figure D): Avoid side-effects. Use functions and modules as our tools for abstracting.
Figure A
Expand
// This is not expressive because this function
// needs to run 3 steps in a row in order to calculate the sum.
//
// Note that the statements are time-dependent, and in order
// to understand what this is doing, we need to stop thinking
// like a human and think like a computer, walking through the
// control flow step-by-step. This isn't bad, but it's good to
// be aware of & manage the cognitive load associated with this.
const sumImperative = numbers => {
let sum = 0;
for (let n of numbers) sum += n;
return sum;
};
// In contrast to the above, this is much more in line
// with the way humans think & reason about solving problems;
// deferral. As long as you understand how `reduce` works, you
// can quickly intuit what this code is doing without needing to
// think about **how** this is actually calculating the sum.
const sumExpressive = numbers => numbers.reduce((sum, n) => sum + n, 0);
Figure B
Expand
// an animal is just an object with a `type` field.
function noise(animal) {
let noise;
if (animal.type === 'fish') {
noise = 'blub'
} else if (animal.type === 'dog') {
noise = 'woof'
}
console.log(noise);
}
noise({type: 'dog'});
noise({type: 'fish'});
Figure C
Expand
abstract class Animal {
noise();
}
class Fish extends Animal { // A fish **is an** animal
noise() {console.log('blub')}
}
class Dog extends Animal { // A dog **is an** animal
noise() {console.log('woof')}
chase() { ... }
}
let fish = new Fish();
let dog = new Dog();
dog.noise();
fish.noise();
Figure D
Expand
// an animal is just an object with a `type` field.
const dog = () => ({type: 'dog'});
const fish = () => ({type: 'fish'});
const noise = thing => thing.type === 'fish' ? 'blub' : 'woof';
// note that we moved the **side-effect**
// of logging to the console as high up
// in the program as possible,
// instead of hiding it in `noise`.
console.log(noise(dog()));
// one of the nice things about thinking in functions
// are the novel and concise ways we can combine functions.
//
// For example, we can /compose/ functions by piping
// the output of one function into the input of another, e.g.
pipe(fish(), noise, console.log);
// is equivalent to
console.log(noise(fish()));
// You can find a pipe helper function in lodash, ramda, and fp-ts.
Top comments (0)