What is function purity and why is it beneficial
Artūras Šlajus
2022
A pure function must:
Here are some examples:
string repeat(string s, uint times) {
var sb = new StringBuilder();
for (var idx = 0; idx < times; idx++) sb.Append(s);
return sb.ToString();
}
int add(int a, int b) => a + b;
(Rng rng, ulong number) nextULong(Rng rng) {
var x = rng.seed;
x ^= x >> 12; // a
x ^= x << 25; // b
x ^= x >> 27; // c
return (new Rng(x), x * 0x2545F4914F6CDD1D);
}
Here are some counter-examples of functions that are not pure:
int counter;
int count() {
counter++;
return counter;
}
void shout(string text) {
Console.WriteLine($"!!! {text} !!!");
}
string prompt(string question) {
Console.WriteLine(question);
return Console.ReadLine();
}
Here are some counter-examples of functions that are not pure:
int checkIfPositive(int number) {
if (number < 0)
throw new Exception($"{number} is not positive!");
return number;
}
When we write programs, we very often have to:
When we do not strive for functional purity it's very easy to write programs that intermix computation and effects, resulting in code that is hard to follow.
For example:
void menu() {
var keepGoing = true;
while (keepGoing) {
Console.WriteLine("Please enter your order:");
switch (Console.ReadLine().Trim()) {
case "1":
handleOption1();
break;
case "exit";
keepGoing = false;
break;
default:
Console.WriteLine("Unknown option!");
break;
}
}
}
We can rewrite that into more purely functional style:
enum Choice { _1, Exit }
Choice? parse(string input) =>
input.Trim() switch {
"1" => Choice._1,
"exit" => Choice.Exit,
_ => null
};
string ask() {
Console.WriteLine("Please enter your order:");
return Console.ReadLine();
}
We can rewrite that into more purely functional style:
void menu() {
Choice? choice = null;
while (choice != Choice.Exit) {
choice = parse(ask());
switch (choice) {
case Choice._1:
handleOption1();
break;
case Choice.Exit:
break;
default:
Console.WriteLine("Unknown option!");
break;
}
}
}
Even though there is slightly more code, there are significant benefits:
Choice
type that clearly lists what are the possible choices. We can even attach documentation to it!parse()
method is an easily testable pure function.ask()
function as a parameter to make the menu()
function testable.Equational reasoning is the capability to think about the code as if it was a mathematical equation.
In math left and right sides of the expression are interchangeable.
x + 5 = y
2x - 5 = y
x = y - 5
y = 2x - 5
x = 2x - 5 - 5
10 = 2x - x
10 = x
x = 10
y = 2x - 5
y = 2 * 10 - 5
y = 20 - 5
y = 15
When you program with pure functions, same rules apply.
int compute() => 3;
var result1 = compute() + compute();
var x = compute();
var result2 = x + x;
Assert.Equals(result1, result2);
However, when the functions are side-effectful, it breaks.
var counter = 0;
int compute() {
counter++;
return counter + 3;
}
var result1 = compute() + compute();
var x = compute();
var result2 = x + x;
Assert.NotEquals(result1, result2);
Where does this come in handy?
The only things you have to worry about with a pure function are:
The code becomes local.
You don't have to worry about where the values came from or where are they going to.
Just pass in the parameters and check the output. What could be easier?
[Test] void TestMyFunc() {
var result = myFunc(5, 20, "Johhny");
result.shouldEqualTo(42);
}
Because pure functions always return same output for same inputs, they are very easy to cache.
int veryExpensiveFunction(int a, int b) =>
/* intense calculations */;
var cached = Memo.a((int a, int b) tpl =>
veryExpensiveFunction(tpl.a, tpl.b)
);
var expensive = cached[(3, 4)];
var cheap = cached[(3, 4)];
var expensiveAgain = cached[(5, 6)];
Because pure functions do not share any data or only share immutable data, they can be run in parallel easily without worrying about:
Albeit, Unity games are often single-threaded, it still comes in handy to use all CPU cores when optimizing for performance.
Update()
function or mouse click callback.
public class Player : MonoBehavior {
void Update() {
if (Input.GetKeyDown(KeyCode.A)) {
transform.position += Vector3.left * Time.deltaTime;
}
else if (Input.GetKeyDown(KeyCode.D)) {
transform.position += Vector3.right * Time.deltaTime;
}
}
}
public class Player : MonoBehavior {
public enum Direction { Left, Right }
static Option<Direction> movementDirection(
KeyCode pressedKey
) =>
pressedKey switch {
KeyCode.A => Some.a(Direction.Left),
KeyCode.D => Some.a(Direction.Right),
_ => None._
};
static Vector3 directionToVector(
Direction direction
) =>
direction switch {
Direction.Left => Vector3.left,
Direction.Right => Vector3.right,
_ => throw ExhaustiveMatch.failed(direction)
};
}
public class Player : MonoBehavior {
static Option<KeyCode> getKeyOpt(KeyCode code) =>
Input.GetKey(code).opt(code);
void Update() {
var pressedKey =
getKeyOpt(KeyCode.A) || getKeyOpt(KeyCode.D);
var direction =
pressedKey.flatMap(movementDirection)
.map(directionToVector);
if (direction.valueOut(out var dir)) {
transform.position += dir * Time.deltaTime;
}
}
}
Even if it looks like a lot more code, it is simple, reusable and testable code. Trust me, it is worth it in the long run.