Why this matters
A function is a named block of code that you call with some inputs. Most beginners think of a function as a box that takes the inputs and gives back a result. Some functions do more than that. They print text, change a number that lives outside the function, or remember something from the last call. Those extra actions make the function harder to understand, harder to test and easier to break.
A pure function is a function that depends only on its inputs and does nothing else. If you call it with the same inputs, you always get the same result. You can call it in any order, and you can call it again without worrying about what else ran before. This lesson explains the idea with simple examples, shows how to spot the hidden state that makes a function impure, and shows how to split an impure function into a pure part and a small impure part.
The lesson uses the earlier ideas from this module. Scope and lifetime, from the lesson on scope, explain where a variable can be seen and how long it lives. Pass by value and reference, from the lesson on that topic, explain why changing a parameter can change the caller's data. The lesson on static locals and counters covers static variables in more depth, so this lesson shows only what matters for purity.
The samples are in C++ and use the standard library. They compile with g++ -std=c++20 -Wall. The output shown was produced by running the programs with g++ 15.2 on Linux.
The idea, with one small example
Here is a function that adds two numbers.
int add(int a, int b) {
return a + b;
}It is pure. Its result depends only on a and b. It does not read a file, print text, change a variable outside itself, or remember a value from the last call. If you call add(2, 3) ten times, you get 5 every time.
std::cout << add(2, 3) << " " << add(2, 3) << '\n';Output:
5 5A pure function has two properties.
- Same input, same output. Given the same arguments, the function always returns the same value.
- No side effects. A side effect is any change the function makes outside its own work. Printing to the screen, writing to a file, changing a global variable, changing data the caller passed in and reading a value that can change between calls are all side effects. Reading a value is not always a side effect, but reading hidden state is the reason the output can change.
The second property is the one beginners miss. A function with no side effects can still be impure if its result depends on something that is not in its parameter list.
Example 1: a global value that changes the result
A global variable is a variable declared outside every function. Any function in the file can read it and change it. Here is a function that adds tax to a price, with the tax rate kept in a global.
int tax_rate = 18;
int price_with_tax(int price) {
return price + price * tax_rate / 100;
}The function looks simple. But its result depends on two things: the price argument and the current value of tax_rate. The second one is hidden. The caller cannot see it by looking at the function's parameter list.
std::cout << price_with_tax(200) << '\n';
tax_rate = 10;
std::cout << price_with_tax(200) << '\n';Output:
236
220The same call, price_with_tax(200), gave 236 the first time and 220 the second time. Nothing in the call changed. The global changed in between. A reader of the call site cannot know this without reading the whole file.
The fix is to pass the rate in as a parameter. The function then depends only on its inputs.
int price_with_tax(int price, int rate) {
return price + price * rate / 100;
}Now the rate is visible at every call. The caller decides which rate to use, and the same arguments always give the same result.
Example 2: changing data you were given
A function can change the caller's data through a reference. That is a side effect, because the caller's data changes outside the function. Here is a function that adds a bonus to every score in a list.
void add_bonus(std::vector<int>& scores, int bonus) {
for (int& s : scores) s += bonus;
}The & in std::vector<int>& scores means the function gets the caller's list, not a copy. Changes made in the loop change the caller's list. The function returns nothing, and the change is the only result. That makes the function impure.
std::vector<int> scores{50, 60};
add_bonus(scores, 5);
std::cout << scores[0] << " " << scores[1] << '\n';Output:
55 65A pure version builds a new list and leaves the input alone.
std::vector<int> with_bonus(const std::vector<int>& scores, int bonus) {
std::vector<int> out;
out.reserve(scores.size());
for (int s : scores) out.push_back(s + bonus);
return out;
}The const in const std::vector<int>& scores promises that the function does not change the list. The function creates out, fills it and returns it. The caller decides what to do with the new list. If the caller wants the old list replaced, the caller writes scores = with_bonus(scores, 5);. The change is then visible in the code that makes it.
Passing data by reference is not a bug. It is useful for large data, because the list is not copied. The rule for purity is simpler: when a function changes something it did not create, that change is a side effect, and the reader should be able to see it in the function's name, its return type or its parameter list.
Example 3: a counter that remembers
A static local variable is a variable declared inside a function that keeps its value between calls. Here is a function that gives out ticket numbers, one per call.
int next_ticket() {
static int counter = 0;
return ++counter;
}The first call returns 1, and the second call returns 2. The function's result depends on how many times it has been called before. A caller that calls it twice in a test gets different values from the same call.
std::cout << next_ticket() << " " << next_ticket() << '\n';Output:
1 2Run the same line again in the same program, and the numbers would be 3 and 4. The function is impure because its result depends on a hidden history, which is the counter.
A pure version takes the state as a parameter and returns the new state. The caller keeps the counter.
int next_ticket_pure(int counter) {
return counter + 1;
}std::cout << next_ticket_pure(7) << " " << next_ticket_pure(7) << '\n';Output:
8 8The function now gives 8 for 7 every time. The caller holds the counter, so the caller can see and control the history. This style is common in code that must be tested or run in parallel. The caller decides how to store the state.
Spotting hidden state
Hidden state is any input the function uses that does not appear in its parameter list. When you read a function, check these places.
- Global variables. A name used in the function that is declared outside any function, such as
tax_ratein Example 1, is hidden input. staticlocal variables. Astaticvariable inside a function keeps its value, as in Example 3.- Data members of a class. A member function that reads or changes a member variable depends on the object's state, not only on its arguments. Classes are covered in the object-oriented programming lessons, so this lesson names the idea only.
- Reference parameters that change. A
¶meter that is notconst, as in Example 2, can be a side effect. - Input and output. Reading from
std::cin, reading a file or printing withstd::coutmakes the function depend on the outside world or change it. - Time and random numbers. A function that calls a clock or a random number generator gives a different result each time, even with the same arguments.
A quick test helps. Ask whether you could call the function from a test, with the same arguments, and get the same answer every time, with no setup before the call. If the answer is no, something hidden is involved.
Here is a function that looks like a plain sum but keeps a total.
int total = 0;
int add_to_total(int x) {
total += x;
return total;
}Calling it twice with the same argument gives different results.
std::cout << add_to_total(5) << " " << add_to_total(5) << '\n';Output:
5 10The second call returns 10 because the first call changed total. The function's job is to add to a running total. If you want a pure sum, pass the current total in and return the new total:
int add_to_total_pure(int total, int x) {
return total + x;
}The caller now owns the total, and the function's result depends only on its two arguments.
Splitting an impure function
Many real functions do both a calculation and an action. The best habit is to split them: write the calculation as a pure function that returns a value, then write a small impure function that performs the action. The action is only printing, saving or reading. The calculation stays easy to test.
Here is a function that averages a list of scores and prints the result. It is hard to test, because a test can see only the screen output, not a value.
void report_average_impure(const std::vector<int>& scores) {
int sum = std::accumulate(scores.begin(), scores.end(), 0);
double avg = static_cast<double>(sum) / static_cast<int>(scores.size());
std::cout << "Average: " << avg << '\n';
}The split version has a pure average function that returns a value, and a formatting function that returns text.
double average(const std::vector<int>& scores) {
int sum = std::accumulate(scores.begin(), scores.end(), 0);
return static_cast<double>(sum) / static_cast<double>(scores.size());
}
std::string format_report(double avg) {
return "Average: " + std::to_string(avg);
}The caller prints the text, and only that line does output.
std::vector<int> scores{70, 80, 90};
double avg = average(scores);
std::cout << format_report(avg) << '\n';Output:
Average: 80.000000The average function now has a test that is easy to write: call it with {70, 80, 90} and check that the result is 80. Nothing needs to be captured from the screen. The std::to_string call is a standard function that turns a double into text. It writes six digits after the decimal point, which is why the output shows 80.000000.
The static_cast<double> in the code converts the sum to a double, so the division keeps the decimal part. Without it, the division would be done on two whole numbers and the fraction would be dropped.
The full program
The complete program for the examples is shown below. Each example appears in order, and the output is shown after the code.
#include <iostream>
#include <numeric>
#include <string>
#include <vector>
int add(int a, int b) {
return a + b;
}
double celsius_to_fahrenheit(double c) {
return c * 9.0 / 5.0 + 32.0;
}
std::vector<int> doubled(const std::vector<int>& values) {
std::vector<int> out;
out.reserve(values.size());
for (int v : values) out.push_back(v * 2);
return out;
}
int tax_rate = 18;
int price_with_tax(int price) {
return price + price * tax_rate / 100;
}
void add_bonus(std::vector<int>& scores, int bonus) {
for (int& s : scores) s += bonus;
}
void show_total(int total) {
std::cout << "Total: " << total << '\n';
}
int next_ticket() {
static int counter = 0;
return ++counter;
}
int next_ticket_pure(int counter) {
return counter + 1;
}
int main() {
std::cout << add(2, 3) << " " << add(2, 3) << '\n';
std::cout << celsius_to_fahrenheit(100.0) << '\n';
std::vector<int> nums{1, 2, 3};
auto d = doubled(nums);
std::cout << nums.size() << " " << d.size() << " " << d[2] << '\n';
std::cout << price_with_tax(200) << '\n';
tax_rate = 10;
std::cout << price_with_tax(200) << '\n';
std::vector<int> scores{50, 60};
add_bonus(scores, 5);
std::cout << scores[0] << " " << scores[1] << '\n';
show_total(42);
std::cout << next_ticket() << " " << next_ticket() << '\n';
std::cout << next_ticket_pure(7) << " " << next_ticket_pure(7) << '\n';
return 0;
}Output:
5 5
212
3 3 6
236
220
55 65
Total: 42
1 2
8 8The line 212 is celsius_to_fahrenheit(100.0). The value 100 degrees Celsius is 212 degrees Fahrenheit. The line 3 3 6 shows that doubled did not change nums, which still has three values, and that the new list d has the doubled third value, 6.
Testing a pure function
A test is a small program that calls a function and checks the answer. The standard header <cassert> gives assert, a check that stops the program with an error message when its condition is false. A test for a pure function needs no setup. It passes the arguments and compares the result.
#include <cassert>
#include <iostream>
int add(int a, int b) { return a + b; }
int next_ticket_pure(int counter) { return counter + 1; }
int main() {
assert(add(2, 3) == 5);
assert(add(-1, 1) == 0);
assert(next_ticket_pure(7) == 8);
std::cout << "all checks passed\n";
}Output:
all checks passedThe same test on the impure next_ticket would need to know how many times it was called before, and the order of the checks would matter. The pure version can be checked in any order, and each check stands alone.
When a check fails, assert stops the program. This example has a wrong expected value on purpose.
#include <cassert>
int add(int a, int b) { return a + b; }
int main() {
assert(add(2, 2) == 5);
}Output on Linux with g++ 15.2, where the program stops with exit code 134:
check: check.cpp:6: int main(): Assertion `add(2, 2) == 5' failed.The message names the program, then the file and line number of the failed assert, then the failed condition. The name check is the program name, which comes from the -o check part of the compile command.
Common mistakes
Assuming a function with no return value is pure
A void function, such as show_total, has no result, so its whole effect is a side effect. The compiler does not treat it as an error. A reader must see the void and the printing in the body to know what it does. Pure functions almost always return a value.
Forgetting that a global can change between calls
A function that reads a global variable looks the same at the call site, but the result changes when the global changes. The output in Example 1 showed 236 and then 220 for the same call. Look for names in the function body that are not parameters and not declared inside the function.
Using a static local without expecting the history
The first call to next_ticket returns 1. A test that calls it after another test has called it twice gets 3, and the test fails in a way that depends on the order of the tests. The compiler gives no warning for this, because the code is valid. The fix is to pass the state in, as in next_ticket_pure.
Ignoring the return value of a pure function
A pure function does its work only through its return value. If you call it and throw the result away, nothing happens. The compiler warns you when the function is marked with [[nodiscard]]. For a function such as add(2, 3); without the attribute, the compiler gives no warning, and the call does nothing visible.
int add(int a, int b) { return a + b; }
int main() {
add(2, 3);
}This compiles and does nothing. The code is valid, but the result is lost. Store the result, print it or return it.
Reading input inside a calculation
A function that calls std::cin >> n depends on what the user types. Its result cannot be tested without a keyboard. Read the value in main or in a separate input function, and pass the number into a pure calculation.
int square(int n) {
return n * n;
}The calculation is now pure. The input step, which reads n, is kept apart from it.
Practice
Each exercise has its answer below it. Try the exercise before you read the answer.
Exercise 1
Say whether each function is pure or impure, and give the reason in one sentence.
double area(double radius) {
return 3.14159 * radius * radius;
}
int lucky = 7;
int lucky_number() {
return lucky;
}
void log_value(int x) {
std::cout << "value: " << x << '\n';
}Show the answer
area is pure. Its result depends only on radius, and it changes nothing. lucky_number is impure, because its result depends on the global lucky, which another part of the program can change. log_value is impure, because it prints to the screen, which is a side effect.
Exercise 2
Rewrite this function so it is pure. The caller should keep the list, and the function should return a new list with each value squared.
void square_all(std::vector<int>& v) {
for (int& x : v) x = x * x;
}Show the answer
std::vector<int> squared(const std::vector<int>& v) {
std::vector<int> out;
out.reserve(v.size());
for (int x : v) out.push_back(x * x);
return out;
}The input is const, and the function returns a new list. The caller decides whether to replace the old list.
Exercise 3
The function below depends on a hidden value. Find the hidden value, and rewrite the function so the value is a parameter.
int discount = 10;
int final_price(int price) {
return price - price * discount / 100;
}Show the answer
The hidden value is the global discount. The rewrite passes it in:
int final_price(int price, int discount) {
return price - price * discount / 100;
}With this version, final_price(200, 10) always gives 180.
Exercise 4
Split this function into a pure calculation and a small printing function. The calculation should return the number of items that are even.
void count_even_and_print(const std::vector<int>& v) {
int count = 0;
for (int x : v) {
if (x % 2 == 0) ++count;
}
std::cout << "Even: " << count << '\n';
}Show the answer
int count_even(const std::vector<int>& v) {
int count = 0;
for (int x : v) {
if (x % 2 == 0) ++count;
}
return count;
}
std::cout << "Even: " << count_even(nums) << '\n';The function count_even can be tested with a list such as {1, 2, 4}, which should give 2.
Exercise 5
A function next_id should give 1, then 2, then 3, on each call. The function below does this with a static variable. Write a pure version that takes the last id as a parameter and returns the next one. Then write the calls that give 1, 2 and 3 in order, storing the last id in main.
int next_id() {
static int last = 0;
return ++last;
}Show the answer
int next_id(int last) {
return last + 1;
}
int main() {
int id = 0;
id = next_id(id);
std::cout << id << '\n';
id = next_id(id);
std::cout << id << '\n';
id = next_id(id);
std::cout << id << '\n';
}Output:
1
2
3The caller stores the state. The function itself does not remember anything.
Summary
- A pure function returns a value that depends only on its arguments, and it changes nothing outside itself.
- Hidden state is any input that is not in the parameter list, such as a global, a
staticlocal, a class member, input from the keyboard or the clock. - A function that changes data passed by reference has a side effect, so a copy or a new return value is often clearer.
- Pass state in as a parameter, and return the new state, when a function must remember something between calls.
- Split a function that both calculates and prints into a pure calculation that returns a value and a small printing function.