Skip to content

Separate Calculating From Doing

Decompose your code into actions (side-effects) and calculations (pure functions).

Actions depend on the ever-changing outside world. Assume the outside world is always changing, so actions depend on when you call them.

Calculations are pure and transparent, they only depend on their explicit inputs, and they produce observable outputs. Call them multiple times at any time with the same input and they will always produce the same output. Calculations depend only on their inputs.

Push the actions to the edges of the program. Read once at the start, compute with pure functions, write once at the end. What’s left in the middle needs no environment, so it can be read, reused and tested on its own.

Calculations are testable without mocks because a calculation has no dependencies to substitute. Its inputs are plain values, so a test is as simple as “call it, compare the result”. Actions, by contrast, touch the outside world, so testing them needs a test double or a real environment.

Calculations can often be made constexpr in modern versions of C++, so the same test can run at compile time. Evaluate the calculation in a static_assert and a wrong result — or undefined behavior such as signed overflow or an out-of-bounds index — becomes a compile error, caught before any code runs.

constexpr std::array prices{10.0, 20.0, 30.0};
static_assert(total_after_discount(prices) == 54);

Calculations are more easily reusable.

Read a comma-separated list of prices from stdin, print each price as it is processed, then print the total after a 10% discount.

#include <cstdlib>
#include <iostream>
#include <string>
int main() {
std::string line;
std::getline(std::cin, line); // action: read
// Action and calculation tangled: parse, report, sum, discount and print in one body.
double sum = 0.0;
std::size_t start = 0;
while (start <= line.size()) {
auto end = line.find(',', start);
if (end == std::string::npos) end = line.size();
if (end > start) {
double price = std::strtod(line.substr(start, end - start).c_str(), nullptr);
std::cout << "price: " << price << '\n'; // action in the middle of the loop
sum += price;
}
start = end + 1;
}
double total = sum * 0.9; // 10% discount, buried right here
std::cout << "Total: " << total << '\n'; // action: write
}

The parse, the discount rule, the per-price output and the final output all live in main. To test the discount you must feed stdin and read stdout. To find out whether parsing handles a trailing comma, you must run the whole program.

#include <cstdlib>
#include <format>
#include <iostream>
#include <span>
#include <string>
#include <string_view>
#include <vector>
// Calculation: pure. Same prices -> same report lines.
std::vector<std::string> report(std::span<const double> prices) {
std::vector<std::string> lines;
for (double p : prices)
lines.push_back(std::format("price: {}", p));
return lines;
}
// Calculation: pure. Same prices -> same total
double total_after_discount(std::span<const double> prices) {
double sum = 0.0;
for (double p : prices) sum += p;
return sum * 0.9; // 10% discount, one place
}
// Calculation: pure. Same input string -> same prices.
std::vector<double> parse_prices(std::string_view line) {
std::vector<double> prices;
std::size_t start = 0;
while (start <= line.size()) {
auto end = line.find(',', start);
if (end == std::string_view::npos) end = line.size();
if (end > start)
prices.push_back(
std::strtod(std::string(line.substr(start, end - start)).c_str(), nullptr));
start = end + 1;
}
return prices;
}
// Action: knows about stdin and stdout.
int main() {
std::string line;
std::getline(std::cin, line); // action: read
auto prices = parse_prices(line); // calculation
auto lines = report(prices); // calculation
double total = total_after_discount(prices); // calculation
for (auto& l : lines)
std::cout << l << '\n'; // action: write
std::cout << "Total: " << total << '\n'; // action: write
}

All the output now happens at the end, after every calculation is done. The loop that prints is not mixed into the loop that computes, so the report is just data that can be asserted on.

main is now the only action; everything else is pure and directly testable without mocking, no setup, just values in and a value out:

assert(total_after_discount(std::array{10.0, 20.0, 30.0}) == 54);
assert((parse_prices("1.5,,2") == std::vector<double>{1.5, 2}));