Calculation and Proof
Learning Objectives
- You know of sets, domains and codomains, predicates and relations.
- You can perform function application step by step using syntactic substitution.
- You know why pure functions enable optimizations like memoization.
- You understand the definition of a function, and how functionality and totality is reflected in programming.
- You know a notion of program correctness and can reason about properties of functional programs.
The essence of this chapter is to cover how programs are evaluated, and how that relates to calculation in mathematics. We also define a function more rigorously and finally discuss program correctness from a functional point-of-view.
Functions, Sets and Defining Equations
A function maps elements from its domain to elements in its codomain . The image of a function is the set of all outputs it actually produces β a subset of the codomain. Given a subset , we write to mean the image of under , i.e. in set-builder notation.
Throughout this chapter, we use set-builder notation to describe sets. The notation means βthe set of all such that holdsβ.
For example:
- is the set of positive integers
- is the set of even integers
- equals
- For any function and predicate ,
The vertical bar can be read as βsuch thatβ or βwhereβ, and can be thought of as filtering a set.
In the last example, maps over the set filtered by .
Set-builder notation is just a convenient way of composing filter and map from functional programming, but for sets!
Letβs consider the following function from integers to integers
f : Int -> Int
f = fun x : Int, 2 * x
We can look at the image of the function acting on any subset of the domain. For example
The defining equation of the function is for all .
f : Int -> Int
f = fun x : Int, 2 * (x - x)
The above function takes each integer to . By calculating, we can see that , and so on, but we can even prove that for all . To do this we can employ simplification.
Simplification
Simplification is a mathematical process most people learn early in their education. More precisely, simplification is the process of transforming a term into an equivalent term using a set of simplification rules. The terms are equal if they evaluate to the same value.
For example, in a context where , we can simplify into . Because of referential transparency, this means that everywhere where we can βreplaceβ by without affecting the behavior of the code.
Using this simplification rule, we can rewrite the defining equation of as follows
If the simplification rules are correct, this proves that for all .
Problematic Optimization
f : Int -> Int
f = fun x : Int, x * x / xWhat is ?
At first glance, you might simplify to just , making . But thereβs a catch: what happens when ?
Mathematically speaking, division by zero is undefined, so doesnβt mean anything β is not in the domain of . Therefore the image of under is undefined: .
In , and in most programming languages, division by zero causes a runtime panic.
This brings up an important point: simplification in the presence of partial functions is problematic as we must not simplify away situations where the code would crash. This is quite an important problem in the area of optimizing compilers: how to ensure that the optimized code still preserves the wanted side effects of the code.
As an interesting case study, a user on the Rust forums asked how to stop the compiler from optimizing away the following code:
let mut delay: u32 = 0xffffff;
while delay > 0 {
delay -= 1
}Mathematically speaking, the code does absolutely nothing, and we can even prove that delay = 0 after the code block, so the compiler optimizes it away.
However the code has the desired side effect of delaying the next computation, i.e. sleeping, so we donβt want the code to get optimized away.
This brings up an interesting philosophical question: is the code pure (functional) or impure?
The next chapter goes into more detail on simplification.
Calculation
Simplification is not the only way to reduce a term to another equal term. When all variables are realized to concrete values, we can just calculate the result. The process of calculating a resulting value from a term is quite simple and fundamentally recursive: we look at every subterm and calculate their value.
For example, the subterms of 2 * (5 + 1) are 2 and 5 + 1.
We first calculate the value of 2 which, because itβs an atomic term, is .
However, 5 + 1 is not atomic, so we proceed recursively, calculating the value of its subterms and adding them to get .
The result of the calculation is therefore .
Reminder: atomic terms are characterized by not consisting of subterms. For example, variable names and other literals are atomic terms.
Calculation, like simplification, creates a chain of equal terms: 2 * (5 + 1) = 2 * 6 = 12.
The terms are equal because they have the same value.
Clearly the process of calculating went from left to right, so the rightmost term1 should be the atomic term whose value is what we are looking for.
Therefore we can even say that calculating a term results in another term β which is akin to simplification.
This realization lifts the concept of a value to the term level, and is done in lambda calculus to make calculation purely syntactic: the underlying meaning of terms like 2 and 5 + 1 is almost made invisible.
Of course the underlying values are still present in defining the operators such as + and *.
Arithmetic is not the heart of functional programming β rather functions and function applications are. Next, letβs look at how calculating with functions works in the spirit of lambda calculus.
Calculating with Functions
Letβs look at a simple function add1.
To understand how we get add1 5 = 6, we need to understand how to calculate with functions.
As we are in the realm of programming, we prefer to use the word evaluate instead of calculate, although they effectively mean the same thing.
From the defining equation of add1 we get add1 5 = (fun x : Int, x + 1) 5.
Now we have an application of two terms.
As with arithmetic operators, we recursively evaluate the values of both sides β but this immediately leads to the following question: what is the value of a function?
The standard answer in lambda calculus and functional programming is: every function is a value2!
Now we are in a similar situation as when evaluating 1 + 5: both subterms are values.
The next step is to perform syntactic substitution: we substitute the functionβs parameter by the right-hand-side value in the functionβs body.
Skipping some details regarding substitution, we observe that substituting x by 5 in x + 1 results in 5 + 1.
Therefore
Letβs frame this cornerstone of functional programming in a definition.
Notice that the definition says nothing about the types involved. This is because type checking is a pre-condition to evaluation β i.e. the code must type check before itβs run. Therefore we can (and do) assume that the types match, i.e. is a function type and is in the domain of .
In untyped programming languages, including the untyped lambda calculus, there is no type checker, but pretty much the same definition for function applications is still used.
Next, letβs look at a function called twice : (Int -> Int) -> Int -> Int.
Looking at its signature, it takes an integer function as input and returns an integer function as output (remember that -> associates to the right).
Applying twice to the add1 function creates a function which adds 2 to the argument.
We want to evaluate the value of main, so we start with its definition: twice add1 10.
Function application associates left, so we first evaluate twice add1:
The second equality comes from the definition of function application: add1, a value, is substituted in place of f.
We donβt unfold the definition of add1 to keep the calculation cleaner.
As fun x : Int, add1 (add1 x) is a function, itβs a value, and therefore we are ready to evaluate the outer application.
Here, the second equality comes from the function application and the third comes from calculating the subterm add1 10 which we have skipped for brevity.
In lambda calculus, each of the above equals signs is called a -reduction, and its rules will be covered in detail later on this course.
Predicates and Relations
Letβs establish some mathematical notions and vocabulary that helps us reason about programs. These concepts are very important and used throughout the course.
As shown in the previous chapter, we can write predicates on a type by writing functions from to booleans.
Here are some examples of predicates:
- is a predicate on integers, and defined as follows: .
- is a predicate on integers, and can be defined recursively (as shown next), or by using the remainder operator: .
- is a predicate on lists, and defined as follows: is true if and only if .
In , predicates are written as functions returning Bool:
positive : Int -> Bool
positive = fun n : Int, n > 0
even : Int -> Bool
even = fun n : Int, let n = int.abs n in
if n == 0 then true else ! (even (n - 1))
empty : [Int] -> Bool
empty = fun xs : [Int], lcase xs of
| nil => true
| cons _ _ => false
A similar concept, where instead of one variable we have two, is a relation.
Here are some familiar relations.
- Let be a set. Equality () is a relation on : is true (i.e. ), but is not
- Less than () is a relation on : holds, but does not
- Set membership () is a relation between elements3 and sets: holds
To express relations in code, we write them with a function of type .
lt : Int -> Int -> Bool
lt = fun x : Int, fun y : Int, x < y
contains : Int -> [Int] -> Bool
contains = fun x : Int, fun xs : [Int], lcase xs of
| nil => false
| cons y xs => if x == y then true else contains x xs
As contains is a relation, itβs mathematically a set of pairs.
For example, the following are true statements about contains:
Sometimes predicates and relations end up mixed together. The convention used on this course is that a predicate is a statement over one free variable, and relation over at least two variables:
- When writing , we can think of as just the predicate .
- A relation can be thought of as a predicate over a pair: , and is mathematically equivalent to a relation.
- Likewise for any triple , we can think of it as a predicate over a triple, which is equivalent to a relation over , and .
Functionality and Totality in Depth
So far we have been talking about both functionality and totality quite informally. Letβs now give the definition of a function, and after that conceptualize those ideas using programming.
A functional relation is also known as a partial function, denoted , as it doesnβt require the following ingredient.
These two concept are now ready to be packaged together in the key definition.
Notice that all functions are also partial functions.
Next, we prove that we are allowed to write for the unique value that relates with.
The mathematical definition of functionality is often referred to as βpurityβ in the realm of programming. Some functions in imperative languages can still be pure, i.e. functional, from the mathematical perspective. This is one reason why functional programming is more than a classification of programming languages: itβs a paradigm where we choose to restrict ourselves to the constrained definition given in mathematics.
Letβs explore some practical implications of these concepts.
Functionality
Simply put, functionality allows us to replace βequals with equalsβ, meaning that, if we see two identical subterms in a program, we can make the assumption that they are equal, compute the value of just one of them and if the value exists, substitute the result to both places.
In the program below, for instance, we could evaluate expensive_computation 42 once then reuse that value for the second operand of the addition.
expensive_computation : Int -> Int
expensive_computation = // something expensive
main : Int
main = expensive_computation 42 + expensive_computation 42
More generally, in cases like this, the interpreter could apply the memoization optimization technique in order to avoid re-evaluating terms.
At the moment of writing this does not do this, but it could very well do so, because evaluation in the language is mostly pure4.
Letβs explore some examples of trying similar optimizations on impure terms.
rng : Unit -> Int
rng = fun unused : Unit, random.randInt 1 100
main : Int
main = rng () + rng ()
In the example above, we assume that the standard library provides a function random.randInt similarly to how, for example, the Python standard library does. If we were to assume that computation and by extension random.randInt is a pure function, we would behave accordingly and only evaluate it once. This would probably not provide us the result we expected, since we would just reuse the value of the first rng () term in the place of the second term. A random number generator always generating the same number isnβt particularly useful.
In programming, the way to distinguish impure and pure functions is the presence of side effects. A side effect is anything a function does besides returning a value: reading from or writing to files, modifying global variables, printing to the console, making network requests, or generating random numbers.
Perhaps surprisingly, impure functions are not more expressive than pure functions β although they might be more concise. Any program with side effects can be rewritten as a pure program by making the βhiddenβ state explicit. The trade-off is verbosity: pure code must explicitly thread state through every function call.
Side effects include any and all expressions that rely on or modify a global state, that is, some value that is not explicitly passed as an argument, but is somehow implicitly included. In the above example, the rng βfunctionβ clearly doesnβt depend only on the Unit value passed to, but actually depends on some implicitly passed argument, letβs sketch out a version where we would control this argument as an explicit argument.
rng : RandomState -> Int
rng = fun s : RandomState, random.randInt s 1 100
main : RandomState -> Int
main = fun s : RandomState, rng s + rng sNow weβre passing around a random state but weβre not completely there yet (we also got rid of the Unit argument since we were not using it in any case).
As you probably noticed, the rng function is still called twice with exactly the same argument and now, since it is a function, we still have the same problem with memoization as above. Weβre still missing one crucial part.
We know that a random number generator in addition to relying on a global state also modifies it. Therefore our rng function should return the new random state. With this adjustment our code now looks something like this:
rng : RandomState -> (RandomState, Int)
rng = fun s : RandomState, random.randInt s 1 100
main : RandomState -> (RandomState, Int)
main = fun s : RandomState,
let result1 = rng s in
let result2 = rng (fst result1) in
(fst result2, (snd result1) + (snd result2))In the above code, weβve successfully taken an impure program and turned it into a pure program. This same exact technique of spelling out the implicit state as explicit arguments can be used to convert any impure program into a pure one, and in fact, one could say that the impure programs are just pure programs that lie about their inputs. Therefore, being able to distinguish pure and impure programs boils down to the ability to model state and side effect explicitly rather than implicitly. If there exists no implicit state, then all functions are pure.
Side effects, and how to achieve them with pure code, are discussed in more detail in Input/Output and Side Effects.
Pure side effects with the IO monad
The pattern of passing state through tuples (NewState, Result) works, but itβs verbose and error-prone.
What if we forget to use the updated state?
What if we accidentally use the old state twice?
Haskell solves this elegantly with the monad pattern and do-notation.
In the case of the State monad,
type State a represents βa locally stateful computation may that depends on the state of type aβ.
Below is our rng example implemented with a RandomState monad and do notation.
The state is automatically forwarded to subsequent calls of rng when doing it inside of the do block with the <- syntax.
Finally, the return function is used to return a value and the final state.
The function example implements the same behavior as our main function above, while example2 generates 3 random values and returns a list of them.
import Control.Monad.State
type RandomState = State Int
rng :: RandomState Int
rng = do
n <- get
put (n + 1) -- Sophisticated RNG logic
return $ n + 1
example :: RandomState Int
example = do
result1 <- rng
result2 <- rng
return $ result1 + result2
example2 :: RandomState [Int]
example2 = do
result1 <- rng
result2 <- rng
result3 <- rng
return [result1, result2, result3]
main :: IO ()
main = do
print $ evalState example 0 -- <- rng seed
print $ evalState example2 0This topic is revisited when the IO monad is covered in the chapter on Input and Output.
Totality
Totality of functions can be expressed simply via the following statement: a function is total if it can not panic or go into an infinite loop.
These are both perhaps common sense requirements for βhigh quality codeβ even in programming languages where functions are not functional in general, as is the case with Rust. In Rust, a function which panics while inside its βoperating parametersβ, is usually considered a bug. However, a panic caused by a problem outside the operating parameters, like memory or disk space running out, is not usually itself a bug.
Letβs return back to the factorial example from the previous chapter.
factorial : Int -> Int
factorial = fun n : Int,
if n == 1 then n
else n * factorial (n - 1)
Even though the function doesnβt return a value for inputs less than one, it still works for all positive integers β itβs βtotal on inputs β, but partial overall. As doesnβt have a type for positive integers, we canβt express this assumption in the domain of the function signature.
Even if had a type for positive integers, what would be the result of
1 - 1? Options are: 0, 1 or a panic.
In functional languages, one can trust the function signatures, up to termination.
The type Int -> Int means that, provided with an integer, the function may return an integer.
It might work for all inputs or for some particular inputs or equally work for no inputs as well β a finer notion of correctness is needed to reason about functions.
Totality at the Cost of Incompleteness
In languages including Agda, Idris 2, Lean and Rocq, core/kernel5 functions must be total. This means that the core of these languages can not be Turing complete because every function and evaluation must terminate. However, they also support partiality, but only outside the logical core of the language/type system, so they are far from useless for real life programming applications.
These languages are usually dependently typed languages with proof assistant capabilities, as one sometimes needs to prove to the language that a function is total when the language canβt figure it out on its own.
Partial functions in these languages are sometimes called colored functions as they βspreadβ to the calling function requiring the caller to be marked as partial too. On the contrary, total functions are not colored, i.e. donβt require the caller to be total.
The same philosophy works for pure and impure as well: an impure function (in any programming language) is allowed to use pure functions, but not vice versa.
These ideas are mostly explained by the following basic properties of relation composition:
- the composition of two left-total relations is left-total, and
- the composition of two functional relations is functional.
Proving these properties is left as an exercise for the reader.
Program Correctness
We have all the tools and knowledge to now learn about program correctness, a widely studied field of computer science. For our purposes it suffices to look at correctness of functional programs β or just functions.
First, to even define what βcorrectβ means, we need a notion of a specification. A specification for a function of type consists of two predicates: and , where is a variable of type and is of type .
The two predicates and are often called the pre-condition and the post-condition. The definition of correctness intuitively says that all inputs that satisfy the pre-condition are mapped to values that satisfy the post-condition.
A trivial pre-condition could be and a post-condition are a specification which holds for all total functions.
The range of properties expressible with only types is quite limited in . This is why the pre- and post-conditions are expressed at the meta-level instead of using types. We also avoid talking about exceptions, or panics separately and consider a panic at input
xto mean that the function is not defined atx.
As an example, letβs show that for all positive integers, factorial returns the factorial of the input.
In the proof, we were able to freely move between mathematical values and terms in as the language is functional and using the assumption every use of βevaluates toβ produces a value.
Notice that even though factorial wasnβt defined for negative integers, itβs still correct, according to the given specification.
Relaxing the precondition would have made the requirement that is defined at false on negative inputs.
Footnotes
Footnotes
-
The rightmost term in a sequence of calculation steps is guaranteed to exist for finite arithmetic expressions. However, with more complex terms involving functions, think about whether a final value is guaranteed to exist. β©
-
In some textbooks a function is a value only if its body is βnormalizedβ. This leads to interesting problems when dealing with recursive functions which we wonβt discuss here. On this course we take the stance that functions are values regardless of their bodies. β©
-
In set theory, βeverythingβ is a set, so elements are sets too. See for example this Wikipedia article where natural numbers are defined recursively starting from zero which is the empty set. β©
-
has
trace, which although functional, does print to the standard output β which we consider a side effect. Because mathematically it doesnβt mean anything to print to the standard output, and becausetracedoesnβt introduce an outside value into the evaluation, we choose to ignore the side effects oftracewhen analyzing programs. This is standard in functional programming languages. See Debug.Trace in Haskell. β© -
You can find a description of the kernel of the Lean programming language the Lean language reference. β©