- Published on
Point-Free Programming
- Author
- Name
- Keith Brewster
- @switchcasebreak
I like functional programming. I like it just enough to adopt the functions and patterns, but not enough to commit myself to
try and understand whatever infixr :!; data L x = x :! L [x] | Nil deriving (Eq, Functor)
does in Haskell (this is not an
invitation to you mathematicians, keep scrolling). I think functional programming has a ton of useful applications when
working with JavaScript—it's a language that lends itself well to FP paradigms, especially when the more esoteric FP languages
(Lisp, Haskell, etc.) have far fewer real-world applications. One of the most interesting and divisive paradigms in FP
is point-free style.
At a high-level, tacit (point-free) programming occurs when your function definition doesn't reference any of its arguments. Tacit means "understood or implied without being stated", so we're more concerned about what the function does over the data it's operating on (a "point" refers to a function's parameter input, so point-free implies being free from the terrible burden of naming them). Our goal is to eliminate any unecessary parameters and arguments from our code. If that doesn't make sense yet, that's totally okay. Let's take a very basic example:
const numbers = [1, 2, 3]
const numbersPlusOne = numbers.map((num) => num + 1)
Here we define a numbers
array and an inline mapping function that increments each number in that array by one. We can take
the logic from that inline function and abstract it into its own function:
const numbers = [1, 2, 3]
// our previous mapping logic
const incrementByOne = (num) => num + 1
const numbersPlusOne = numbers.map((num) => incrementByOne(num))
That's better, now we can reuse this function in the event we have any other pesky numbers needing to be incremented by 1.
However, we still haven't achieved point-free style—we still have an explicit reference to num
in our inline function
(and remember, we're trying not to be concerned about the data we're operating on).
const numbersPlusOne = numbers.map((num) => {
// we reference our num argument here
return incrementByOne(num)
})
The callback function provided
to .map() is invoked with three
arguments: the value of the element, the index of the element, and the array being mapped over. Since we're only concerned
about the first element (the value num
), we can remove the wrapping declaration and pass our function reference directly in.
+ const numbersPlusOne = numbers.map(incrementByOne)
- const numbersPlusOne = numbers.map((num) => incrementByOne(num))
This works because the signature of our callback function matches the arguments passed from .map()
(well, not exactly, but we'll get to that
in a bit). We're expecting a single argument in incrementByOne()
, the value to increment. On each iteration of .map()
we're
calling this function and invoking it with the element, index, and array. However, since incrementByOne()
has an arity of 1
(meaning it accepts a single argument), it's only concerned with the first argument it receives—in this case, the
element being mapped over. That sounds like a lot, but hopefully it will make sense soon. This example demonstrates how both are
functionally equivalent:
// our point-free function doesn't reference the callback arguments
const numbersPlusOne = numbers.map(incrementByOne)
// this is functionally equivalent to the first example
const numbersPlusOne = numbers.map(function (element, index, array) {
return incrementByOne(element, index, array)
})
This works because JavaScript functions are variadic, meaning they technically have an indefinite arity—any number of parameters can be provided to the function irregardless of what's defined in the signature. You can see this happening when you look at a function's arguments object:
function addTwo(a, b) {
console.log(arguments) // Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]
return a + b
}
addTwo(1, 2, 3)
Despite being a binary function (arity = 2), n
number of parameters can be provided. This makes JavaScript an
incredibly flexible language—we don't need to work with strictly-defined function signatures. This means we can unlock incredibly
powerful patterns using rest parameters,
allowing our functions to accept an arbitrary number of arguments without needing to do things like creating overloaded methods.
Unfortunately, this same flexibility can create problems when using point-free style. Consider the following example where we
create a greet
function. It takes a single argument (a name) and returns a string that says "hello [name]". Super useful stuff!
We can call the function independently, or use it as the callback when mapping over an array of names:
const greet = (name) => `hello ${name}`
greet('Steve') // hello Steve
const greetings = ['Bill', 'Sally', 'Duane'].map(greet) // ["hello Bill", "hello Sally", "hello Duane"]
This works great, but what if someone comes in and decides that this function should also optionally take in a last name?
Sure, they could just pass the first and last name as a single string to our greet
function, but then I would need to think
of a different example. So I ask that you please ignore how contrived the following code snippet is:
function greet(firstName, lastName = '') {
return `hello ${firstName} ${lastName}`.trim()
}
greet('Steve') // hello Steve
greet('Steve', 'Smith') // hello Steve Smith
This still works as intended, and all is well with our application! But maybe we should check back on that code mapping over the array of names, just in case.
const greetings = ['Bill', 'Sally', 'Duane'].map(greet)
// ["hello Bill 0", "hello Sally 1", "hello Duane 2"]
Wait, what happened here? We're not passing a last name, so shouldn't it be defaulting to an empty string? Not quite—remember,
the .map()
callback function is invoked with three arguments: the element, the index, and the array. When our greet function
had an arity of 1 (a unary function), we were only concerned with the first
argument of the callback function (the value). After we created the scoped variable for our lastName
argument,
it became initialized by the second argument, the index. Uh oh—changing the arity of our function has now created a bug in
our application!
So what can we do? We have to make sure that the function signatures match, i.e. share a common arity. Remember earlier in the article when I said this?
This works because the signature of our callback function matches the arguments
passed from `.map()` (well, not exactly, but we'll get to that in a bit)
Well here we are! We already know that .map()
passes 3 arguments to the callback function. This was fine when our function
arity was 1 because we only wanted to use the first argument it received. So what if we created a function that would help enforce
calling the .map()
callback as a unary function? That way it would always only use the first argument, no matter how many
parameters are provided. Let's see what that might look like:
const unary = (f) => (arg) => f(arg)
const greetings = ['Bill', 'Sally', 'Duane'].map(unary(greet))
Lets break this down. The first thing is to look at the function signature for our unary function:
const unary = (f) => (arg) => f(arg)
unary
is a curried function, which means it's a function that returns another function
with arguments partially applied. While it's out of scope for this article (and deserves an entire post to itself),
it's a technique for converting a function that takes multiple arguments into a series of functions that each takes a single
argument. We now have something like this:
const unaryGreet = unary(greet)
console.log(unaryGreet) // (arg) => f(arg)
At first, this might not seem like it's doing much, but we've actually done something magical. We've partially applied our unary
function and created a new function, unaryGreet
. Let's take a look at the signature: (arg) => f(arg)
. It expects a single
argument arg
, and returns the result of calling f
with it. That might be a little bit confusing, so let's look at what
our unaryGreet
function looks like (I've taken the liberty of filling in the inner function and naming the arguments to
make it a little clearer):
function unaryGreet(name) {
greet(name)
}
That's a lot simpler to comprehend: unary
wraps our greet
function with another function that only accepts
a single argument. Lets take a look at how this works with our previous example:
const unaryGreet = unary(greet)
const greetings = ['Bill', 'Sally', 'Duane'].map(function (element, index, array) {
// unaryGreet is called with three arguments
unaryGreet(element, index, array)
})
// we are receiving the three arguments (element, index, array)
function unaryGreet(name) {
// we pass through only the first argument to our greet function
greet(name)
}
// greet now only receives a single argument meaning
// we are no longer mapping lastName to the array index
function greet(firstName, lastName = '') {
return `hello ${firstName} ${lastName}`.trim()
}
And it's not just unary
, we can create functions for enforcing an arity of any size. Two arguments, three arguments, even
ten arguments (but probably not ten arguments). You can also see how currying helps us create point-free functions.
Some people find tacit programming to be unnecessarily obscure, or that it creates a needless obfuscation. A lot of programming is about figuring out the right level of abstraction—in the right circumstances, I believe that point-free style creates highly reasonable, declarative code. Adopting functional programming paradigms can give you a new set of mental models for structuring your applications, and like any tool, it's up to you to decide when it's the right time to use it.
Share (or don't):