What is function purity and why is it beneficial

Pure functions

Artūras Šlajus

2022

How do we define a pure function?

A pure function must:

  • Always return the same return value given the same inputs.
     
  • Do not do anything else:
    • No printing to console.
    • No writing to file.
    • No playing sounds.
    • You get the idea.

How do we define a pure function?

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);
}

How do we define a pure function?

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();
}

How do we define a pure function?

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;
}

Why pure functions matter?

When we write programs, we very often have to:

  • do some computation to know what to do;
    • These are called pure functions.
  • do some actions based on those computations.
    • These are called effects or effectful functions.

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.

Why pure functions matter?

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;
    }
  }
}

Why pure functions matter?

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();
}

Why pure functions matter?

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;
    }
  }
}

Why pure functions matter?

Even though there is slightly more code, there are significant benefits:

  • There is a Choice type that clearly lists what are the possible choices. We can even attach documentation to it!
     
  • The parse() method is an easily testable pure function.
     
  • We can provide ask() function as a parameter to make the menu() function testable.
     
  • Each function does a single, well-defined job that is easy to
    write documentation for.

What are the other benefits of pure functions?

  • Allows equational reasoning
  • Simpler to understand
  • Testing-friendly
  • Caching-friendly
  • Multithreading-friendly

Allows equational reasoning

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

Allows equational reasoning

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);

Allows equational reasoning

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);

Allows equational reasoning

Where does this come in handy?

 

  • When refactoring code it is incredibly freeing to be able to move code around without worrying that changing the position of the code will break it.
     
  • When debugging you can change your code according to equational rules without breaking it to make it easier to debug.
     
  • Code in this style usually forms clear expression trees, where the code is about inputs and outputs instead of about state and statements.

    The code looks like an Unreal Engine Blueprints script!

Simpler to understand

The only things you have to worry about with a pure function are:

  • It's inputs:
    • Function parameters.
    • Enclosed values from class / struct scope.
  • It's outputs.

The code becomes local.

You don't have to worry about where the values came from or where are they going to.

Testing-friendly

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);
}

Caching-friendly

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)];

Multi-threading friendly

Because pure functions do not share any data or only share immutable data, they can be run in parallel easily without worrying about:

  • deadlocks;
  • data corruption;
  • race conditions.

Albeit, Unity games are often single-threaded, it still comes in handy to use all CPU cores when optimizing for performance.

Some architectural guidelines to follow

  • Pure functions should never call effectful functions.
    • If they do - they become effectful functions themselves.
       
  • Effectful functions are free to call other pure or effectful functions.
     
  • Effectful functions should be invoked near the program entry point, such as Update() function or mouse click callback.
    • This helps to make sure effectful functions invoke pure functions and not vice-versa.
  • Most of your code should be preferably made out of pure functions.

So instead of this

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;
    }
  }
}

Do this

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)
    };
}

Do this

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.

Thank you!

U4. Pure functions

By Artūras Šlajus

U4. Pure functions

  • 419