DEV Community

loading...
Cover image for Tenets of Functional Programming - A Beginner's Guide

Tenets of Functional Programming - A Beginner's Guide

James Walsh
I live & work in Salt Lake City as a full-stack engineer. I love being outdoors, synthwave, & playing video games🕹
・11 min read

Over the last year, I've experimented with a handful of different programming languages. Some of these have been functional (which I will refer to as FP), some object-oriented, and most of which are hybrid languages. A few days ago I asked myself the question, if I were first being introduced to FP concepts today, what would I want to know first? The following is my response to that question.

First, Some FP Wins 💙

I'm going to make four claims that I'll back up later. So here's the elevator pitch on why embracing FP concepts is going to make you as happy as a dog eating a hot dog.

Alt Text

By adhering to these tenets I believe you will win:

  1. Testability
  2. Refactorability
  3. Readability
  4. Better Control Flow

Next, Some Baby Steps 🐣

"James, what makes a language an FP language, OOP language, or a hybrid language?" I'll try to make this clear as mud by illustrating some language features from technologies I've explored.

Functions as a First-Class Citizen

All popular programming languages have support for functions. But not all of them have first-class support for functions. So what exactly does it mean for a function to be "first-class?".

Here are some simple rules for you to suss that out:

  1. Functions can be declared at the top level.
  2. Functions can be assigned as values to variables.
  3. A function may accept another function as a parameter.
  4. You may declare nested functions within functions (turtles all the way down).
  5. The language allows you to "compose", or arrange smaller functions into larger functions.

While FP patterns DO exist in the languages below, functions are treated as 2nd class citizens. With that in mind let's eliminate them as a thought exercise.

  • C# (OOP):
    • We have the ability to assign functions to variables, and pass functions to other functions (using anonymous methods, LINQ, etc).
    • But only within the scope of a method inside of a class inside of a namespace. So while some functional patterns exist, functions are not "first-class".
  • Ruby (OOP):
    • Similar to C#, lambda expressions (anonymous functions) exist. These can be defined inline and assigned as a value to a variable.
    • You can declare methods within methods (function nesting)
    • But ultimately whatever is being defined must exist within the scope of a class and its methods. Functions cannot exist on their own.
  • JS (Hybrid): You can declare functions, objects, and classes at the topmost level. Functions can be assigned as values to other variables and accepted as parameters. Function nesting is also allowed. But there is a fascinating little quirk about JS that isn't immediately recognized.
    • In your browser devtools run the JS code (function(){}).constructor. Did you catch it? Every JS function is actually just a Function object. Function itself is not the base type.
    • That's right, at the end of the day you are actually constructing JS objects when you declare new functions. This has led many engineers to say "JS is actually Object-Oriented".
    • While I think there is merit to the statement above, because these Function objects behave like traditional functions, pass every check in the first-class citizen test, and the language does not enforce the same strict namespacing/class encapsulation strictness in other OOP languages, I sit firmly in the "hybrid language" camp.
  • Scala (Hybrid): In Scala it's the same story as JS, but with a slightly different flavor.
    • Every function is a value. Functions may be nested. Functions may be declared at the top level and passed as parameters. For brevity, the first-class test is met.
    • However, types and behaviors in Scala are defined by classes, traits, sub-classes, mixins, and all those other mechanisms you've come to expect from an OOP language.
    • As you can see in Scala, similar to JS, they leave things completely up to you how deeply you decide to take one approach.
  • Elixir (Strict FP):
    • functions are first-class citizens.
    • Functions actually replace classes & objects as the root level data type.
    • No support for classical inheritance but instead, expect you to use something called "Function Composition" to achieve similar goals.
    • Functions may be treated as values, but when you inspect under the hood you won't find an "object" type being used ephemerally.
    • No support for popping back into OOP Land when you want to use traditional dependency injection, inheritance, or class instantiation.

After reading this blog post, I don't expect you to run off and convert your Ruby project to Elixir. In fact, the opposite. I wanted to introduce the idea that most modern tech stacks have sprinkles of FP across their language features. As we jump into the meat of these tenets I hope you can find something that applies to you wherever you're working.

Tenet 1: Higher-Order Functions 🧗

If you have experience with React you've probably heard the term "we favor composition over inheritance". What's being described here isn't just a React best practice, it's the first Tenet of FP.

Imperative VS Declarative Programming

In classical OOP, shared functionality & state is often defined in classes that can be inherited. The Dog class may inherit from the Canine class which defines useful universal methods like Run(), Bark(), Pant(). You can write something like Dog extends Canine and some level of base functionality & state management will be inherited. This type of thinking is referred to as "imperative" thinking.
In the following article, CodeBurst Declarative vs Imperative Programming, the bit of knowledge to glean is:

Imperative programming is a programming paradigm that uses statements that change a program’s state.
Declarative programming is a programming paradigm that expresses the logic of a computation without describing its control flow.

In functional programming, developers prefer "declarative" thinking. Functions aren't concerned about when they are called or by whom they are called, and aren't concerned about the program state.

  1. Where an imperative approach may look like: new Dog().Bark()
  2. The declarative approach may look more like: bark(() => dog).

The bark function isn't really concerned about who is barking when they are barking during program execution, or why. It's only a function that takes in another function as a parameter, and barks on whatever the return value of that function parameter is. What you may have already realized is that we just inadvertently composed our first function.

Function Composition 🎼

Function composition is the process of combining two or more functions to produce a new function. Composing functions together is like snapping together a series of pipes for our data to flow through.

Or to illustrate the point differently, I like to think of function composition in similar terms to music composition:

Musical notes simply exist, much like functions. But it is up to the composer to arrange the notes in their proper order to make up a song.

To go back to the Dog example, bark(), run(), bite() are all arranged as individual pieces. They can be arranged in any order. You may arrange (compose) them so that a dog runs up to someone, barks at them, and then bites them, but you may also "compose" these functions in any number of interesting ways. It may not be a dog that bites the user, but it may actually be biteUser(() => cat). In this case, biteUser is the Higher-Order Function which takes in the entity that is doing the biting.

  • Testing 🧪: You can imagine how using these higher-order functions allow for a pretty great testing experience. biteUser() can be tested in isolation, as can dog(). Later on, you can write an additional unit or integration test to figure out what happens when these functions are composed together in different ways.
  • Control Flow ⚙️: You can imagine how we might change the order of bite, run, etc. That is because what is now separated from when.

Tenet 2: Function Purity 😇 (Side Effects)

What makes a function pure? There is a simple test you can run to determine if a function is "pure" or "impure".

A function is considered pure if given the same input it always produces the same output.

To illustrate this point:

// pure
function addTwoNumbers(num1, num2) {
    return num1 + num2
}

// impure
function getRandomNumberWithMax(maxNumber) {
    return Math.floor(Math.random() * maxNumber)
}
Enter fullscreen mode Exit fullscreen mode
  • If we run addTwoNumbers(2, 4) we will always get 6.
  • If we run getRandomNumberWithMax(10) we might sometimes get the same output, but very rarely.

Side Effects 💥

It's obvious that using Math.random would make for an impure function. But in the real world, there are usually really good reasons why our functions might not produce the same output.

That reason is side effects. Side effects are things that happen outside your function or local environment that you cannot control including:
- Referencing the global state
- Updating a record in the database
- Making an external HTTP request
- Querying the DOM
- Logging to the file system

The first time I was exposed to this idea, I threw my hands up in the air and said:

"So any time any real work needs to be done, I have a side effect, so what's the point?"

I think it's important to understand that the goal isn't to eliminate all side effects or to shame yourself when you write an impure function. But rather to think about testing, flow control, and encapsulation.

Thinking about things this way leads to questions like:

"How can I encapsulate side effects so that they don't' trickle out across the entire codebase".

Practicing Side Effect Forgiveness

  1. In many cases we can use composition to inject side effects through function parameters rather than declaring them inline in the function body. This helps keep the function pure and side effect free.
  2. There is nothing wrong with a properly encapsulated side-effect producing function. As long as we keep striving to isolate our side effects to just a few spots instead of everywhere.

Tenet 3: Immutability 💾

Immutability is pretty straight forward to understand but has some powerful ramifications. Immutability simply put is defined as:

Data that may not be mutated, only copied

// this works, however, this var can be changed later on
let myMutableString = 'James Walsh'

// first change 😭
myMutableString = ' is a really cool guy!'

function changeString() {
  // second change
  myMutableString = 'something entirely different'
}

const immutableString = 'James Walsh'

// Throws the error Uncaught TypeError: Assignment to constant variable.
immutableString = 'J. Walsh'
Enter fullscreen mode Exit fullscreen mode
  • That's pretty much it. Because we strive to have side-effect-free & "stateless functions", when we need to change data we copy the data first, then modify it. This achieves function purity by eliminating the temptation to modify a state outside of the function's direct scope.
  • In redux, immutability enables features like "time-travel" debugging that allows you to see how data is changing as each function manipulates a given data set.

Tenet 4: Referential Transparency 🧮

"If a function references another function, it may be swapped out with that function's implementation and will still return the same result."

When we work with immutable data and pure functions, we gain referential transparency. We can substitute out a function call with the function body and nothing should change.

Consider this simple example:

function add(num1, num2) {
    return num1 + num2
}

function divide(num1, num2) {
    return num1 / num2
}

// Same result is produced in many different ways with referential transparency

// Ex 1: Using function references
const result = divide(add(2, 2), add(2,4))
// Ex 2: Replace function references with values that are function references
const num1 = add(2, 2,)
const num2 = add(2, 4)
const result = divide(num1, num2)
// Ex 2: Replace values with add function refs, with the function implementation
const num1 = 2 + 2
const num2 = 4 + 2
const result = divide(num1, num2)
// Ex 3: Replace divide function ref with the function implementation
const num1 = 2 + 2
const num2 = 4 + 2
const result = num1 / num2
// Ex 4: Replace all references with pure single line implementation
const result = (2 + 2) / (4 + 2)
Enter fullscreen mode Exit fullscreen mode
  • No matter which implementation or reference we substituted, the value of the result will always be 0.66 (repeating).
  • Referential transparency delivers on the promise of being able to refactor, as well as write comprehensive tests where you can use test data in your test case, or mock functions which return appropriate test data, without fear that results will look wildly different in production.

Tenet 5: Functors ⚡️

Now that you have a good foundation, we can learn about Functors which are going to give you an insane amount of readability with tons of expressiveness. Functors are the primary way we are going to modify any immutable data.

Functor Definitions

So what is a functor? Ask any developer and I guarantee they will each give you a different answer. So in order to cover my bases, I'm going to give you a few different definitions to work with:

  1. A functor is a set of values arranged in a shape that can be mapped over (or changed).
  2. A functor is a collection that can be passed a function and apply it non-destructively to all of its elements, returning another functor.
  3. Functions or Objects which have implemented .map or variation of map.

Little confused? You're not alone. Let's take a look at an example you may already be using. Let's explore the JavaScript Array.map.

Scenario

Let's say our user is an animal shelter that wants to be able to provide a new pet owner with a list of the names of all the cats that are female that don't have long hair. They'd also like this list sorted by kitties that are used to being indoors.

Here is our data set:

const cats = [
  { name: 'Tony', coat: 'long', gender: 'male', isIndoorCat: false },
  { name: 'Tinkerbell', coat: 'short', gender: 'female', isIndoorCat: true },
  { name: 'Whiskers', coat: 'short', gender: 'male', isIndoorCat: false },
  { name: 'Snickers', coat: 'short', gender: 'female', isIndoorCat: true },
  { name: 'Nala', coat: 'long', gender: 'female', isIndoorCat: false },
  { name: 'Selina', coat: 'short', gender: 'female', isIndoorCat: false },
  { name: 'Gusto', coat: 'short', gender: 'male', isIndoorCat: true },
]
Enter fullscreen mode Exit fullscreen mode

Imperative Code 🤮

let catsToShow = [] //mutatable state we can reference from inside our loop
  for (let cat of cats) {
    if (cat.gender === 'female' && cat.coat === 'short') {
      if (cat.isIndoorCat === true) {
        catsToShow.unshift(cat) // sort indoor first
      } else if (cat.isIndoorCat === false) {
        catsToShow.push(cat) // sort outdoor last
      }
    }
  }

  for (let cat of catsToShow) {
    console.log(cat.name) // only display the names
  }
Enter fullscreen mode Exit fullscreen mode

What's wrong with this code?

  1. We're mutating a lot of state.
  2. Control flow is hard to keep track of.
  3. We can't reason about one part of our requirements without reasoning about the whole of the program.

Let's see how Functors can make simple tasks much easier to look at and think about.

Using Functors (Round 1)

Now since Array is a functor it returns (itself) another functor.
The Array Functor provides a few useful methods that perform mappings.

  1. .filter() which takes a function as a parameter that returns a truthy value to specify which items in the collection should be included in the new Functor.
  2. .map() which takes a function as a parameter that returns a new copied and changed version of index in the collection which will be included in the new Functor.
  3. .sort() which takes a function that's return value specifies the sort order of the items returned by the new Functor.
const filteredCats = cats.filter(cat => {
    return cat.gender === 'female' && cat.coat === 'short'
  })
const sortedCats = filteredCats.sort(cat => {
    return cat.isIndoorCat
})
const namesOfCats = sortedCats.map(cat => {
    return cat.name
})

console.log(namesOfCats)
Enter fullscreen mode Exit fullscreen mode

Isn't that a little easier on the eyes?

Using Functors (Round 2)

We can simplify this further.

  1. Functors always return a new Functor, so we can use function chaining to pipe outputs to new functors as inputs.
  2. Let's also add some syntax sugar that lots of popular languages support including implicit function returns, & removing function braces.
const result = cats
    .filter(cat => cat.gender === 'female' && cat.coat === 'short')
    .sort(cat => cat.isIndoorCat)
    .map(cat => cat.name)

console.log(result)
Enter fullscreen mode Exit fullscreen mode

TLDR;

  1. Function Composition (Higher Order Functions which compose other functions) help us achieve things we'd achieve through Inheritance but with the advantage of achieving some sweet de-coupling.
  2. Pure Functions help us increase our code predictability, testing, simplicity, and force us to think about how to properly encapsulate volatile parts of the codebase.
  3. Object Immutability helps us achieve function purity through "copying data first" before changing state. And also helps enable us to practice time travel debugging.
  4. Referential Transparency helps us mock data during testing, refactor our code with confidence, and structure our code in flexible ways.
  5. Functors help us change immutable data with ease, usually end up shortening the amount of code we need to write, and further help us de-couple control flow.

Extra Credit: Monads, Memoization, Currying, Recursion

I view the 5 tenets above as a good way to begin exploring FP concepts. But you will find that they serve as a good baseline to explore more interesting behaviors.

  • Exploring Functors will eventually lead you to Monads (which are a type of functor which makes working with side effects easier).
  • Composition will lend itself well to function currying
  • Function Purity can lead to memoization which has the potential to offer system performance and tuning wins.
  • Recursion is useful when performing data branching logic, but use cases outside of that may be on the rarer side.

Discussion (0)