Skip to content
academia.sh

Lesson 09 / 14

Pure Functions

Freedom from side effects and referential transparency: testing, by actually running it, whether a call can be replaced with its value; how behavior shifts in the impure version; and comparing the two versions by the line count of the fixture their tests require.

Contents

The previous lesson completed object responsibility: data and the behavior that changes it sit in the same unit, and the outside world calls behavior, not state. Throughout that topic the unit was the object. State existed, behavior that protected it existed, and the two were held together.

A different model removes state from the unit entirely. Once data is produced it is not changed, behavior is reduced to a mapping from input to output, and side effects are pushed to the boundary of the program. This topic builds that model; the first question concerns its smallest piece. What does it mean for a function to be pure, and how is purity tested by running it rather than claimed?

The Two Conditions of a Pure Function

A pure function was defined in the Programming Fundamentals course with two conditions: it always produces the same output for the same input, and it produces no side effect. A side effect is any observable change other than the value the function returns: changing a variable outside itself, writing to a file, opening a network request, printing to the screen.

The core of the shipment fee library fits this definition. The fee is calculated from the shipment’s weight and zone, and from the tariff’s tiers and zone factors.

// fee.mjs — the core of shipment fee calculation: a mapping from input to output
export const TARIFF = {
  name: "domestic-standard",
  minFee: 4990,
  tiers: [
    { maxWeightGrams: 1000, fee: 4990 },
    { maxWeightGrams: 5000, fee: 6490 },
    { maxWeightGrams: 15000, fee: 10900 },
  ],
  zoneFactor: { 1: 1.0, 2: 1.15, 3: 1.35 },
};

export const SHIPMENTS = [
  { code: "TR-4471", weightGrams: 800, zone: 1 },
  { code: "TR-4472", weightGrams: 3200, zone: 2 },
  { code: "TR-4473", weightGrams: 12400, zone: 3 },
];

// Fee is an integer number of cents; no floating-point money math is done.
export function calculateFee(shipment, tariff) {
  const tier = tariff.tiers.find((t) => shipment.weightGrams <= t.maxWeightGrams)
    ?? tariff.tiers.at(-1);
  const factor = tariff.zoneFactor[shipment.zone];
  return Math.max(tariff.minFee, Math.round(tier.fee * factor));
}

Both conditions can be read from the function’s signature. The tariff is a parameter, not a value held at module level; the function writes nowhere, it only returns.

The same computation can also be written in a common shape. In the version below, the tariff is not taken from outside, monthly volume is accumulated at module level, and each call produces a log line.

// impure-fee.mjs — the same computation, but a monthly volume counter is held at module level
import { TARIFF } from "./fee.mjs";

const VOLUME_THRESHOLD = 20000;
let monthlyVolume = 0;

export function calculateFeeImpure(shipment) {
  const tier = TARIFF.tiers.find((t) => shipment.weightGrams <= t.maxWeightGrams)
    ?? TARIFF.tiers.at(-1);
  const base = Math.max(TARIFF.minFee,
    Math.round(tier.fee * TARIFF.zoneFactor[shipment.zone]));
  monthlyVolume += base;
  console.log(`[log] ${shipment.code} charged`);
  return Math.round(base * (monthlyVolume > VOLUME_THRESHOLD ? 0.9 : 1));
}

export const reset = () => { monthlyVolume = 0; };

The second version also produces correct results. The volume discount is a real business rule, the log line is a real requirement. The difference is not in the correctness of the behavior but in what the behavior depends on: the result of a call to calculateFeeImpure depends on how many calls preceded it.

Referential Transparency

This dependency has a measurable test. Referential transparency is the property that a program’s behavior does not change if an expression is replaced by the value it produces. The test procedure follows directly from this definition: the same call is written three times, then the call is computed once and its result embedded in three places, and the results of the two runs are compared.

// transparency.mjs — does replacing the call with its value change the behavior
import { TARIFF, SHIPMENTS, calculateFee } from "./fee.mjs";
import { calculateFeeImpure, reset } from "./impure-fee.mjs";

const g = SHIPMENTS[1];

const viaCalls = [calculateFee(g, TARIFF), calculateFee(g, TARIFF), calculateFee(g, TARIFF)];
const value = calculateFee(g, TARIFF);
const inlined = [value, value, value];
console.log(`pure   / viaCalls : ${viaCalls.join(", ")}`);
console.log(`pure   / inlined  : ${inlined.join(", ")}`);
console.log(`pure   / equal    : ${viaCalls.join() === inlined.join()}`);

reset();
const iViaCalls = [calculateFeeImpure(g), calculateFeeImpure(g), calculateFeeImpure(g)];
reset();
const iValue = calculateFeeImpure(g);
const iInlined = [iValue, iValue, iValue];
console.log(`impure / viaCalls : ${iViaCalls.join(", ")}`);
console.log(`impure / inlined  : ${iInlined.join(", ")}`);
console.log(`impure / equal    : ${iViaCalls.join() === iInlined.join()}`);
pure   / viaCalls : 7463, 7463, 7463
pure   / inlined  : 7463, 7463, 7463
pure   / equal    : true
[log] TR-4472 charged
[log] TR-4472 charged
[log] TR-4472 charged
[log] TR-4472 charged
impure / viaCalls : 7463, 7463, 6717
impure / inlined  : 7463, 7463, 7463
impure / equal    : false

The output holds two separate deviations. The first is in the returned values: in the impure version, the third call returns 6717 instead of 7463 because the accumulated volume has crossed the threshold. The change is not in the shipment, it is in the call history. The second is in the log lines. Four lines are printed in total: three from the three-call run, one from the single-call run. Replacing the expression with its value changed not only the result but also the number of records the program produced.

Referential transparency has three practical consequences: a call’s result can be cached, because later calls return the same value; calls can be reordered or skipped when unnecessary, because order does not affect the result; and the input recorded in a bug report is enough to reproduce it, without rebuilding the call history. None of these three hold for the impure version.

The Cost of Purity Visible in Testing

The difference shows up most concretely in testing. The same three assertions are written for both versions: the fee for the three shipments should be 4990, 7463, and 14715 respectively.

// pure.test.mjs — three shipments, three assertions; setup is only the input itself
import test from "node:test";
import assert from "node:assert/strict";
import { TARIFF, SHIPMENTS, calculateFee } from "./fee.mjs";

test("fee is calculated from the tier and zone factor", () => {
  assert.equal(calculateFee(SHIPMENTS[0], TARIFF), 4990);
  assert.equal(calculateFee(SHIPMENTS[1], TARIFF), 7463);
  assert.equal(calculateFee(SHIPMENTS[2], TARIFF), 14715);
});
// impure.test.mjs — the same three assertions; the test has to police the counter and log stream
import test, { beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { SHIPMENTS } from "./fee.mjs";
import { calculateFeeImpure, reset } from "./impure-fee.mjs";

const realLog = console.log;

beforeEach(() => {
  reset();
  console.log = () => {};
});

afterEach(() => {
  console.log = realLog;
});

test("up to one kilogram, first zone", () => {
  assert.equal(calculateFeeImpure(SHIPMENTS[0]), 4990);
});

test("up to five kilograms, second zone", () => {
  assert.equal(calculateFeeImpure(SHIPMENTS[1]), 7463);
});

test("up to fifteen kilograms, third zone", () => {
  assert.equal(calculateFeeImpure(SHIPMENTS[2]), 14715);
});
node --test --test-reporter=tap 2>&1 | grep -E '^# (tests|pass|fail)'
for f in pure.test.mjs impure.test.mjs; do
  printf '%-17s %2d lines\n' "$f" "$(awk 'END{print NR}' "$f")"
done
# tests 4
# pass 4
# fail 0
pure.test.mjs     10 lines
impure.test.mjs   28 lines

Both files pass, but the same three assertions take 10 lines in one file and 28 in the other. The excess accumulates not in the assertions but in the fixture: resetting the module-level counter before every attempt, silencing and restoring the log stream, and splitting the three assertions across separate test cases. The split is not optional; if the assertions stay in one body, the accumulated volume drops the third one.

All of these lines exist to set up, in the test, an arrangement that does not exist in production. The pure version needs no such arrangement, because everything the function needs is already in its parameters.

The Limits of Purity

Purity does not mean nothing inside the function changes. The criterion is observability: no matter how many times a variable defined inside the function’s body is changed, the function stays pure as long as the change is not seen from outside. The loop below uses a local accumulator and does not break purity.

// total.mjs — the local variable changes, but it is not observed from outside: the function stays pure
export function totalFee(shipments, chargeFee) {
  let total = 0;
  for (const s of shipments) total += chargeFee(s);
  return total;
}

Two further limits should be noted. First, a pure function must not change an object it receives as a parameter; doing so would change the data the caller sees, which is a side effect. How to guarantee this constraint is the subject of the next lesson. Second, time and randomness are the natural enemies of purity: a call to Date.now inside the body breaks the same-input-same-output condition. The fix is not to forbid these values but to produce them outside and pass them in as parameters. The rule for the entire fee library fits one sentence: the core reads only what it is given.

Summary

  • A pure function satisfies two conditions: same input to same output, and no side effects. The conditions are read from the function’s signature; the tariff is a parameter, not a module variable.
  • Referential transparency means that replacing a call with its value does not change behavior. The test is run: in the pure version the two runs are equal, in the impure version the third value comes out as 6717 instead of 7463 and the log line count comes out as three instead of one.
  • In the impure version, all three of caching, reordering calls, and reproducing a bug from its input alone are lost.
  • The same three assertions take 10 lines in the pure version and 28 in the impure version; the difference is not in the assertions but in the fixture that resets the counter and silences the log stream.
  • A variable defined in the function’s body changing does not break purity; the criterion is whether the change is observed from outside.

Next Step

This lesson’s pure function did not change its own parameter, but nothing enforced that as a rule. The tariff object is a single object shared with the caller; a single line that changes it ties together the results of two calls that know nothing of each other. The next lesson takes on this shared data: how many silent changes result from not copying a nested structure across two run paths that use the same tariff, how many objects copying costs, and where that cost may not need to be paid.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close