Let's continue our series of short posts about code refactoring! In it, we discuss technics and tools that can help you improve your code and projects.
Today we will talk about the benefits of pure functions and referential transparency.
Problems with Effects
Any interaction between an app and the real world is a side effect.
When we store data in the DB or render it on the screen we produce a side effect. We can't create a useful app without side effects.
On the other hand, the main problem with side effects is that they're unpredictable. They change the state, so we can't be sure that the result of the code will always be the same.
Uncontrolled side effects make the code “dangerous” and “fragile.” So we need a strategy for working with them.
Pure Functions
In short, pure functions are those that have 2 properties:
- they don't produce side effects
- and always return the same result when called with the same arguments.
Pure functions make the code predictable and reproducible. It in turn removes this feeling of “fragility” because if we can reproduce a bug we can sooner isolate and fix it.
Let's take a look at an example. In the function prepareExport
, we calculate the total prices and the latest shipping date for each item in the product list:
function prepareExport(items) {
let latestShipmentDate = 0;
for (const item of items) {
item.subtotal = item.price * item.count;
if (item.shipmentDate >= latestShipmentDate) {
latestShipmentDate = item.shipmentDate;
}
}
for (const item of items) {
item.shipmentDate = latestShipmentDate;
}
return items;
}
The subtotal
calculation within the function changes the items
array—it is a side effect.
Pay attention, however, that other calculations also depend on this array, and changing it will affect them too. It means that when calculating subtotal
we'll have to consider how it will affect the shipmentDate
calculation.
The more extensive the function, the more actions will be affected, and the more details we must keep in mind. The more details there are, the more of our working memory the code occupies.
Composing changes (i.e. effects) is difficult to follow, keep in mind and work with. Let's try to rewrite and improve the function.
Improving the Code
Instead of keeping track of the effects, we can try to avoid them. Let's rewrite the code not to change the shared state but to express the problem as a sequence of steps.
function prepareExport(items) {
// 1. Calculate the subtotals.
// The result is a new array.
const withSubtotals = items.map((item) => ({
...item,
subtotal: item.price * item.count,
}));
// 2. Calculate the shipment date.
// The result of the previous step is the input.
let latestShipmentDate = 0;
for (const item of withSubtotals) {
if (item.shipmentDate >= latestShipmentDate) {
latestShipmentDate = item.shipmentDate;
}
}
// 3. Append the date to each position.
// The result is yet another new array.
const withShipment = items.map((item) => ({
...item,
shipmentDate: latestShipmentDate,
}));
// 4. Return the result of step 3,
// as the result of the function.
return withShipment;
}
We also can extract the steps into separate functions and, if necessary, refactor each one separately:
function calculateSubtotals(items) {
return items.map((item) => ({ ...item, subtotal: item.price * item.count }));
}
function calculateLatestShipment(items) {
const latestDate = Math.max(...items.map((item) => item.shipmentDate));
return items.map((item) => ({ ...item, shipmentDate: latestDate }));
}
Then the prepareExport
function will look like the result of a data transformation sequence:
function prepareExport(items) {
const withSubtotals = calculateSubtotals(items);
const withShipment = calculateLatestShipment(withSubtotals);
return withShipment;
}
// items
// -> itemsWithSubtotals
// -> itemsWithSubtotalsAndShipment
Or even like this, if we use the Hack Pipe Operator, which at the time of writing is in Stage 2:
const prepareExport =
items |> calculateSubtotals(%) |> calculateLatestShipment(%);
This arrangement of the code is called the functional pipeline. It helps us represent the code in a way that's easier for us to correlate with the real world.
We, sort of, describe the problem as if we were describing it to another person:
“First, we do A; then, we make B; finally, we have C as the result.”
Such a description helps us with searching for a needed piece of code when modifying the app or searching for a bug in the code base.
Referential Transparency
The resulting code isn't only easier to read but also easier to test and search for bugs in it.
The power of pure functions is that they are reproducible. It means that when we pass the same arguments to a pure function, it will always return the same result.
So if we have a sequence of pure functions we can split it at any point and replace all the previous calls with their result and the overall functionality won't change:
1. If we have a sequence of pure transformations
from 🍇 to 🍌:
🍇 → 🍏 → 🍒 → 🍊 → 🍌
2. Then we can remove all the calls before 🍊
and replace them with only their result,
and the result (🍌) won't change:
🍒 → 🍊 → 🍌
This property is called the referential transparency and it helps with making the code much easier to debug and test because we can “chop” the sequence at any point and run the rest of the function with any data we want:
// Let's say we're searching for a bug
// in the `prepareExport` function.
function prepareExport(items) {
const withSubtotals = calculateSubtotals(items);
const withShipment = calculateLatestShipment(withSubtotals);
return withShipment;
}
// If we don't yet know where exactly the problem is,
// we can test each of the substeps in isolation:
expect(calculateSubtotals(items)).toEqual(expectedTotals);
// And if we know that the first step is fine,
// we can “chop” the function and “start” it
// from a particular point with the specific data:
function prepareExport(items) {
// Comment this out:
// const withSubtotals = ...
// “Feed” the next step with particular data
// and “start” the function from this point:
const withShipment = calculateLatestShipment(specificData);
return withShipment;
}
The steps inside prepareExport
are connected only by their input and output data. They have no shared state that can affect their operation. The function becomes a chain of data transformations, each of which is isolated from the others and can't be impacted from the outside.
More About Refactoring in My Book
In this post, we only discussed a single aspect of pure functions and their benefits for refactoring code.
We haven't mentioned how they help with fixing the abstraction and encapsulation of modules or how they improve the code testability.
If you want to know more about these aspects and refactoring in general, I encourage you to check out my online book:
The book is free and available on GitHub. In it, I explain the topic in more detail and with more examples.
Hope you find it helpful! Enjoy the book 🙌
Top comments (0)