Unit 30: Side Effect-Free Programming
Learning Objectives
After completing this unit, students should be able to:
- Explain the notion of functions as side-effect-free computations and relate them to mathematical functions, including referential transparency.
- Identify and reason about side effects and purity in Java methods, and explain why purity improves program reasoning and correctness.
- Use functions as first-class values in Java, including defining, assigning, passing, and returning functions via functional interfaces.
- Write and refactor code using lambda expressions and method references, replacing anonymous classes where appropriate.
- Apply currying and partial application to transform multi-argument computations into higher-order unary functions.
- Explain and use closures, including how lambda expressions capture their surrounding environment and the implications of effective finality.
Overview
In earlier units, we emphasized abstraction, immutability, and reasoning about program behavior using types. These ideas allow us to control how objects are used and how data flows through a program, but they do not by themselves guarantee that code is easy to reason about.
As programs grow, reasoning becomes difficult when methods can modify state, depend on hidden variables, or produce unexpected side effects. Even simple expressions can no longer be safely replaced by their values, breaking the local reasoning we rely on in mathematics.
This unit revisits these earlier ideas from a different angle: how restricting side effects allows programs to be reasoned about like mathematical functions. By treating computation as the composition of well-behaved functions, we strengthen the guarantees provided by immutability and abstraction, and make program behavior easier to understand, test, and maintain.
Functions
Recall that, a function, in mathematics, refers to a mapping from a set of inputs (domain) \(X\) to a set of output values (codomain) \(Y\). We write \(f: X \rightarrow Y\). Every input in the domain must map to exactly one output but multiple inputs can map to the same output. Not all values in the codomain need to be mapped.
In mathematics, we follow certain rules to reason about functions. For instance, suppose we have an unknown \(x\) and a function \(f\), we know that applying \(f\) on \(x\), i.e., \(f(x)\) does not change the value of \(x\), or any other unknowns \(y\), \(z\), etc. We say that mathematical functions have no side effects. It simply computes and returns the value.
Another property of a mathematical function is referential transparency. Let \(f(x) = a\). Then in every formula that \(f(x)\) appears in, we can safely replace occurences of \(f(x)\) with \(a\). Conversely, everywhere \(a\) appears, we can replace it with \(f(x)\). We are guaranteed that the resulting formulas are still equivalent.
These two fundamental properties of mathematical functions allow us to solve equations, prove theorems, and reason about mathematical objects rigorously.
Unfortunately, we can't always reason about our program the same way as we reason about mathematical functions. For instance, consider the line:
1 | |
where s is an instance of Seq<T>. Suppose we know that s.get(0) is 5 for some s. When we reason about the behavior of our code, we cannot replace (mentally) every invocation of s.get(0) with the result 5. This is because the sequence s may not be immutable and therefore s.get(0) cannot be guaranteed to be the same.
The converse should also hold. Suppose we have a variable
1 | |
Then everywhere in our code where we use t, we should be able to replace it with s.get(0), and the behavior of the code should still be the same. This behavior is only guaranteed if s.get(0) has no side effects (such as modifying a field or printing something to the standard output).
To be able to reason about our code using the mathematical reasoning techniques we are familiar with, it is important to write our code as if we are writing mathematical functions — our methods should be free of side effects and our code should be referentially transparent. Our program is then just a sequence of functions, chained and composed together. To achieve this, functions need to be a first class citizen in our program, so that we can assign functions to a variable, pass it as parameters, return a function from another function, etc, just like any other variable.
Pure Functions
Ideally, methods in our programs should behave the same as functions in mathematics. Given an input, the function computes and returns an output. A pure function does nothing else — it does not print to the screen, write to files, throw exceptions, change other variables, modify the values of the arguments, etc. That is, a pure function does not cause any side effects.
Here are two examples of pure functions:
1 2 3 4 5 6 7 8 | |
and some examples of non-pure functions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
A pure function must also be deterministic. Given the same input, the function must produce the same output, every single time. This deterministic property ensures referential transparency.
In the OO paradigm, we commonly need to write methods that update the fields of an instance or compute values using the fields of an instance. Such methods are not pure functions. On the other hand, if our class is immutable, then its methods must not have side effects and thus is pure.
In computer science, we refer to the style of programming where we build a program from pure functions as functional programming (FP). Examples of functional programming languages include Haskell, OCaml, Erlang, Clojure, F#, and Elixir.
Many modern programming languages including Java, C++, Python, Rust, and Swift support this style of programming. As these languages are not designed to be functional, we cannot build a program from only pure functions. Java, for instance, is still an OO language at its core. As such, we will refer to this style as functional-style programming. We won't be able to write code consisting of only pure functions in Java, but we can write methods that has no side effects and objects that are immutable, as much as possible.
Function as First-Class Citizen in Java
Let's explore functions as a first-class citizen in Java. We have seen some examples of this when we use the Comparator interface.
1 2 3 4 5 6 7 8 | |
First, let's take a moment to appreciate the beauty of the List::sort method. We can use this method to sort items of any type, in any defined order. We achieve the generality of types with generics, and the generality of sorting order through passing in the comparison function as a parameter. The latter is needed to write one sorting method for every possible sorting order for a list of strings, (sortAlphabeticallyIncreasing, sortByLengthDecreasing, etc..)
The comparison function here is implemented as a method in an anonymous class that implements an interface. We can think of an instance of this anonymous class as the function. Since a function is now just an instance of an object in Java, we can pass it around, return it from a function, and assign it to a variable, just like any other reference type.
Let's look at another example. Consider the following interface:
1 2 3 | |
Transformer<T, R> is a generic interface with two type parameters: T is the type of the input, R is the type of the result. It has one abstract method R transform(T t) that applies the function to a given argument.
We can use this interface to write any function that takes in a value and returns another value. (Java has a similar interface called, unsurprisingly, java.util.function.Function<T, R>). For instance, a function that computes the square of an integer can be written as:
1 2 3 4 5 6 | |
We can write a method chain that composes two given computations together and returns the new computation:
1 2 3 4 5 6 7 8 9 | |
Lambda Expression
While we have achieved functions as first-class citizens in Java, the code is verbose and ugly. Fortunately, there is a much cleaner syntax to write functions that applies to interfaces with a single abstract method.
An interface in Java with exactly one abstract method is called a functional interface. Both Comparator and Transformer are functional interfaces. It is recommended that, if a programmer intends an interface to be a functional interface, they should annotate the interface with the @FunctionalInterface annotation.
1 2 3 4 | |
JShell Caveat
The @FunctionalInterface annotation is not supported in JShell. You can still create functional interfaces without the annotation.
A key advantage of a functional interface is that there is no ambiguity about which method is being overridden by an implementing subclass.
For instance, consider:
1 2 3 4 5 6 7 8 9 10 11 12 | |
You can see that there is much boilerplate code in the two functions above that we can remove. Since we are assigning it to a variable of type Transformer interface, we don't have to write new Transformer<>() { .. }. And since Transformer is an interface, there is no constructor. Since there is only one abstract method to overwrite, we don't have to write @Override public Integer transform(..) { .. }.
What remain after we eliminate the obvious boilerplate code are (i) the parameter Integer x and (ii) the body of transform, which is { return x * x; }. We can use the Java arrow notation -> to now link the parameter and the body:
1 2 | |
You might notice that the type of the parameter is redundant as well since the type argument to Transformer already tells us this function takes in an Integer. We can further simplify it to:
1 2 | |
or simply:
1 2 | |
where there is only one parameter.
Since the body has only a single return statement, we can simplify it further:
1 2 | |
Now, that's much better!
The expressions above, including x -> x * x, are called lambda expressions. You can recognize one by the use of ->. The left-hand side lists the parameters (use () if there is no parameter), while the right-hand side is the computation. We do not need the type in cases where Java can infer the type, nor need the return keyword and the curly brackets when there is only a single return statement.
lambda
Alonzo Church invented lambda calculus (\(\lambda\)-calculus) in 1936, before electronic computers, as a way to express computation. In \(\lambda\)-calculus, all functions are anonymous. The term lambda expression originated from there.
Method Reference
A lambda expression is useful for specifying a new anonymous method. Sometimes, we want to use an existing method as a first-class citizen instead.
Recall the distanceTo method in Point, which takes in another point as a parameter and returns the distance between this point and the given point.
1 2 3 4 5 6 7 | |
We can write our Transformer like this using an anonymous class:
1 2 3 4 5 6 7 | |
or using a lambda expression:
1 2 | |
but since distanceTo takes in one parameter and returns a value, it already fits as a transformer, and we can write it as:
1 2 | |
The double-colon notation :: is used to specify a method reference. We can use method references to refer to a (i) static method in a class, (ii) instance method of a class or interface, (iii) constructor of a class. Here are some examples (and their equivalent lambda expression)
1 2 3 4 | |
The last example shows that the same method reference expression can be interpreted in two different ways. The actual interpretation depends on how many parameters foo takes and whether foo is a class method or an instance method. When compiling, Java searches for the matching method, performing type inferences to find the method that matches the given method reference. A compilation error will be thrown if there are multiple matches or if there is ambiguity in which method matches.
For example, suppose we have:
1 2 3 4 5 6 7 | |
Then B::bar can only be interpreted as (x, y) -> B.bar(x, y), while C::bar can only be interpreted as (c, x) -> c.bar(x).
Note the difference between a lambda expression and a method reference. A lambda expression defines a new anonymous function by explicitly specifying its parameters and computation, while a method reference refers to an existing method and reuses its implementation. Lambdas are used when we need to describe what computation to perform, whereas method references are used when an appropriate method already exists and we only want to treat it as a function value.
Curried Functions
Mathematically, a function takes in only one value and returns one value (e.g., square above). In programming, we often need to write functions that take in more than one argument (e.g., add above).
Even though Transformer only supports functions with a single parameter, we can build functions that take in multiple arguments. Let's look at this mathematically first. Consider a binary function \(f: (X, Y) \rightarrow Z\). We can introduce \(F\) as a set of all functions \(f': Y \rightarrow Z\), and rewrite \(f\) as \(f: X \rightarrow F\), or \(f: X \rightarrow Y \rightarrow Z\).
The arrow \(\rightarrow\) is to be read from right to left. So \(f: X \rightarrow Y \rightarrow Z\) is equivalent to \(f: X \rightarrow (Y \rightarrow Z)\). But what does it actually mean? It means that instead of having a function that takes in two arguments, we can instead have a function that takes in one argument (typically the first argument) and returns another function to accept the second argument.
A trivial example of this is the add method that adds two int values.
1 2 3 | |
This can be written as
1 | |
To calculate 1 + 1, we call
1 | |
Let's break it down a little, add is a function that takes in an Integer object and returns a unary Function over Integer. So add.transform(1) returns the function y -> 1 + y. We could assign this to a variable:
1 | |
Note that add is no longer a function that takes two arguments and returns a value. It is a higher-order function. A higher-order function is one that takes or returns another function.
The technique that translates a general \(n\)-ary function to a sequence of \(n\) unary functions is called currying. After currying, we have a sequence of curried functions.
Curry
Currying is not related to food but rather is named after computer scientist Haskell Curry, who popularized the technique.
How is currying useful? Consider add(1, 1) — we have to have both arguments available at the same time to compute the function. With currying, we no longer have to. We can evaluate the different arguments at a different time (as the incr example above). This feature is useful in cases where some arguments are not available until later. We can partially apply a function first. This is also useful if one of the arguments does not change often, or is expensive to compute. We can save the partial results as a function and continue applying it later. We can dynamically create functions as needed, save them, and invoke them later.
Lambda as Closure
In the example, we showed earlier,
1 2 | |
the variable origin is captured by the lambda expression dist. Just like in local and anonymous classes, a captured variable must be either explicitly declared as final or is effectively final.
A lambda expression stores more than just the function to invoke — it also stores the data from the environment where it is defined. We call such a construct that stores a function together with the enclosing environment a closure.
Being able to save the current execution environment, and then continue to compute it later, adds new power to how we can write our program. We can make our code cleaner with fewer parameters to pass around and less duplicated code. We can separate the logic to do different tasks in a different part of our program more easily.
We will see more examples of this later.
Another difference between a lambda expression and method reference, is that method reference does not capture the surrounding environment. For instance, in the example above, if we wrote:
1 2 3 4 | |
diagonal will be 5.0. Line 4 computes the distance with respect to the original origin at (0, 0). So although origin is not effectively final, the method reference origin::distanceTo is pointing to exactly the method distanceTo within this particular instance when the assignment occurs. So even if the variable origin is reassigned to another value, the method reference is still pointing to the old method.