In the last chapter we learned how to define and call functions. This chapter is about how to think with them. The difference is significant. Knowing the syntax for a function definition is a matter of minutes. Developing the habit of reasoning about functions is a matter of months. Let's find out what's involved in that habit and how it will influence the programming decisions that we make.
Every function makes two implicit commitments. It assumes certain things about its inputs — conditions that must be true for the function to behave as intended. And it promises certain things about its output — conditions that will be true when the function returns, given that the assumptions were met.
Consider a function that computes the average of two real numbers:
nex> function average(a, b: Real): Real
do
result := (a + b) / 2.0
end
What does this function assume? Nothing particularly strong — any two real numbers will do. What does it guarantee? That the result is the arithmetic mean of the two inputs: (a + b) / 2.0.
Now consider a function that computes a percentage:
nex> function percentage(part, total: Real): Real
do
result := (part / total) * 100.0
end
This function has a stronger assumption: total must not be zero. If total is zero, the division will fail. The function’s guarantee — that it returns the percentage of part in total — only holds when the assumption is satisfied.
At this stage, we state these assumptions and guarantees as comments or as clear reasoning in our heads. In Part V, we will write them as executable require and ensure clauses that Nex checks at runtime. The form is different; the thinking is identical.
The habit to build now is this: before writing a function body, ask two questions:
Answering these questions before writing the body is not wasted effort. It is often the fastest path to a correct body, because a clear statement of what the function must produce makes the code that produces it obvious.
These two questions have names in software engineering. The conditions that must be true of the inputs are called preconditions; the conditions that will be true of the output are called postconditions. As noted above, in later chapters we will write these as executable Nex clauses — require for preconditions, ensure for postconditions — that the runtime checks automatically, firing an informative error the moment a violation occurs. For now, the discipline is to answer them in your head, or in a comment, before writing the body. The formal syntax changes nothing about the thinking. A programmer who has the habit of asking these questions before reaching for the keyboard is already doing design by contract; the Nex syntax simply makes that contract visible to the language itself.
A pure function is a function whose output depends only on its inputs and which has no observable effect on the world beyond returning a value. Given the same inputs, a pure function always returns the same output. It does not modify any variable outside itself, does not print anything, does not read from the console, does not write to a file.
The functions defined so far — double, max, average, celsius_to_fahrenheit — are all pure. Each takes values in and returns a value out. Nothing else changes as a result of calling them.
Pure functions have a property that makes them especially valuable: they are easy to reason about. The only thing that determines the output of a pure function is its inputs. To understand what celsius_to_fahrenheit(100.0) returns, you do not need to know what has been called before it, or what the state of any external system is. You need only the inputs and the function’s definition.
This property makes pure functions easy to test. Testing celsius_to_fahrenheit means choosing inputs and verifying outputs:
nex> celsius_to_fahrenheit(0.0)
32.0
nex> celsius_to_fahrenheit(100.0)
212.0
nex> celsius_to_fahrenheit(-40.0)
-40.0
Each call is self-contained: there is nothing to set up and nothing to clean up.
Not all functions are pure. A function that prints output or modifies a variable outside itself has an effect — an observable change to the world beyond its return value. The greet function from Chapter 6 is an effectful function: it prints to the output, which is a change to the outside world.
nex> function greet(name: String)
do
print("Hello, " + name)
end
Effectful functions are necessary — a program that produces no effects produces no output and changes nothing, which makes it useless. But effects make functions harder to reason about and harder to test. The output of greet cannot be captured and checked programmatically the same way that the return value of celsius_to_fahrenheit can.
The practical discipline — which we will formalise in Part VI — is to separate computation from effect. Write pure functions to compute values, then write thin effectful functions that take those values and act on them. The computation can be tested directly; the effect layer is kept as simple as possible.
Compare these two approaches to a temperature reporting function:
nex> -- effectful throughout: harder to test
nex> function report_temperature(c: Real)
do
if c < 0.0 then
print("Freezing: " + c.to_string + " deg C")
elseif c < 15.0 then
print("Cold: " + c.to_string + " deg C")
else
print("Mild or warm: " + c.to_string + " deg C")
end
end
nex> -- pure core, thin effect: easier to test
nex> function temperature_label(c: Real): String
do
if c < 0.0 then
result := "Freezing"
elseif c < 15.0 then
result := "Cold"
else
result := "Mild or warm"
end
end
nex> function report_temperature(c: Real)
do
print(temperature_label(c) + ": " + c.to_string + " deg C")
end
The second version separates the decision — which label applies to this temperature? — from the action — print the label. The decision is pure and can be tested exhaustively:
nex> temperature_label(-5.0)
"Freezing"
nex> temperature_label(10.0)
"Cold"
nex> temperature_label(20.0)
"Mild or warm"
The printing is left to the thin effectful wrapper, which is so simple it barely needs testing. This decomposition — pure core, effectful shell — is one of the most reliable habits in software engineering. The functions involved are often small. The benefit scales with the complexity of the system.
A function is easy to test when its behaviour is fully determined by its inputs and its behaviour is stated precisely enough to verify. Several habits support this:
One responsibility per function. A function that computes a result and also prints it has two responsibilities mixed together. Testing the computation requires dealing with the printing. Separating these concerns — one function per responsibility — makes each piece independently testable.
Inputs as parameters, not globals. A function that reads variables from outside its own scope is harder to test, because testing it requires setting up that external state. A function that receives all its inputs as parameters can be called with any inputs you choose, without setup:
nex> -- harder to test: depends on external variable
nex> let tax_rate := 0.20
nex> function compute_tax(price: Real): Real
do
result := price * tax_rate
end
nex> -- easier to test: all inputs are parameters
nex> function compute_tax(price, rate: Real): Real
do
result := price * rate
end
The second version can be tested with any price and any rate. The first can only be tested with whatever tax_rate happens to be at the time.
Output as return value, not side effect. A function that communicates its result by assigning to an external variable, or by printing, makes that result difficult to capture and verify programmatically. A function that returns its result explicitly is testable directly:
nex> compute_tax(100.0, 0.20)
20.0
One line. No setup. The result is right there.
Well-chosen examples. When testing a function, choose inputs that cover distinct cases: the typical case, the boundary cases, and at least one case where the result is known precisely. For compute_tax, a typical case is a normal price and rate. Boundary cases are a price of zero and a rate of zero (either way, the tax should also be zero). A known case might be price 100 and rate 0.10, where the result is exactly 10.0.
nex> compute_tax(100.0, 0.20)
20.0
nex> compute_tax(0.0, 0.20)
0.0
nex> compute_tax(100.0, 0.0)
0.0
nex> compute_tax(100.0, 0.10)
10.0
Each of these is a small, self-contained experiment. Together they provide evidence that the function behaves correctly across its range of inputs.
A program built from well-designed functions has a particular quality: the code that assembles the pieces is readable at a high level, without requiring the reader to follow the details of every piece. Consider a program that reads a temperature, converts it, classifies it, and reports it:
nex> function celsius_to_fahrenheit(c: Real): Real
do
result := c * 9.0 / 5.0 + 32.0
end
nex> function temperature_label(c: Real): String
do
if c < 0.0 then
result := "Freezing"
elseif c < 15.0 then
result := "Cold"
elseif c < 25.0 then
result := "Mild"
else
result := "Warm"
end
end
nex> function temperature_report(c: Real): String
do
let f := celsius_to_fahrenheit(c)
let label := temperature_label(c)
result := label + ": " + c.to_string + " deg C / " + f.to_string + " deg F"
end
Now the top-level program is one line:
nex> temperature_report(22.0)
"Mild: 22.0 deg C / 71.6 deg F"
The reader of temperature_report does not need to know how Celsius-to-Fahrenheit conversion works or how temperature labels are determined. The function names carry that meaning. The body of temperature_report reads as a sequence of named steps rather than a block of arithmetic.
This is the promise of good function design: each function is a vocabulary word, and programs built from good vocabulary read like clear prose.
A function that is hard to name is often doing too much. If the best name you can find is something like process_and_format_and_print, the function has at least three responsibilities, and each deserves its own function.
A function body that is longer than about ten to fifteen lines is a candidate for decomposition. Not every long function needs to be broken up — some computations are genuinely sequential and benefit from being in one place. But length is a signal worth examining.
A function that contains deeply nested conditionals or loops within loops is harder to test and harder to read. Extracting the inner loop or the innermost condition into its own named function often makes both the outer function and the extracted function clearer:
nex> -- before: inner loop mixed with outer logic
nex> function count_divisors(n: Integer): Integer
do
let i := 1
repeat n do
if n % i = 0 then
result := result + 1
end
i := i + 1
end
end
nex> -- after: inner check extracted
nex> function is_divisor(n, i: Integer): Boolean
do
result := n % i = 0
end
nex> function count_divisors(n: Integer): Integer
do
let i := 1
repeat n do
if is_divisor(n, i) then
result := result + 1
end
i := i + 1
end
end
Both versions produce the same results. The second names the concept of divisibility explicitly, making the loop body read as: for each i from 1 to n, if i is a divisor of n, count it. The extracted function is_divisor is also independently testable, which is a benefit in itself.
In Chapter 6 we saw that functions can be used as values and passed as arguments to other functions. A function can also be returned from another function, and a returned function can remember values from the scope it was created in. These techniques will help us build powerful programming abstractions.
Let's start with a function taking another function as a parameter. Consider a function that transforms every element of an array using whatever transformation the caller supplies:
nex> function transform_all(items: Array[Integer], f: Function(n: Integer): Integer): Array[Integer]
do
result := []
across items as x do
result.add(f(x))
end
end
nex> transform_all([1, 2, 3, 4], fn (n: Integer): Integer do result := n * n end)
[1, 4, 9, 16]
transform_all does not know or care whether it is squaring numbers or doing something else entirely — that decision belongs to whatever function is passed as f. A function that accepts another function as a parameter, or that returns one, is called a higher-order function.
Functions can flow in the other direction too: a function can build and return another function as its result.
nex> function make_counter(): Function(): Integer
do
let count := 0
result := fn (): Integer do
count := count + 1
result := count
end
end
nex> let counter := make_counter()
nex> counter()
1
nex> counter()
2
nex> counter()
3
Each call to counter() returns a different result, even though counter() takes no arguments and make_counter has long since finished running. Somewhere, the value of count is being kept alive between calls. That somewhere is the function value itself: when make_counter returns the fn (): Integer do ... end literal, that literal takes count with it. A function value that remembers a variable from the scope in which it was created, even after that scope's own function has returned, is called a closure. counter is a closure over count — it captures count, and every call to counter reads and writes that one captured variable, not a fresh copy.
Notice what this means for purity, in the sense Section 7.2 defined it. counter() takes no inputs, yet it does not always return the same output — calling it twice in a row gives 1 then 2. A closure that mutates something it captured is an effectful function by that definition, exactly as much as one that prints or writes a file; the effect is simply invisible at the call site, because the state it touches is hidden inside the closure rather than passed around openly. So keep this in mind: effects are sometimes exactly what you want, but a mutating closure should be recognised and used as the effectful thing it is, not treated as though it were as easy to reason about as a pure function.
Here are two closures that act as two windows into a shared value:
nex> function adder(init: Integer)
do
let total := init
let add := fn (x: Integer) do total := total + x end
let peek := fn (): Integer do result := total end
add(5)
print(peek())
add(10)
print(peek())
print(total)
end
nex> adder(2)
7
17
17
add and peek are two separate function values, but they share one captured total. A write through add is visible through peek, and even the enclosing scope's own later read of total sees it. adder is an object with one private state (total) and two methods — one that changes the field, one that reads it. This is Object Oriented Programming in its miniature form. (We will learn more about this style of programming in Chapter 12).
1. The function percentage(part, total: Real): Real from Section 7.1 has an assumption: total must not be zero. Write a version called safe_percentage that returns 0.0 when total is zero and the normal percentage otherwise. Test it with part = 50.0, total = 200.0 (expected: 25.0), part = 0.0, total = 100.0 (expected: 0.0), and part = 10.0, total = 0.0 (expected: 0.0).
2. Separate the following function into a pure computation and a thin effectful wrapper. The pure function should return a String; the effectful wrapper should print it.
function greet_by_time(hour: Integer) do
if hour < 12 then
print("Good morning!")
elseif hour < 18 then
print("Good afternoon!")
else
print("Good evening!")
end
end
Test the pure function with hour = 9, hour = 14, and hour = 20.
3. Define a function is_prime(n: Integer): Boolean that returns true if n is a prime number (divisible only by 1 and itself). Test it with n = 2 (true), n = 9 (false), n = 17 (true), and n = 1 (false, by convention). Write the function using is_divisor from Section 7.6 as a helper.
4. A function count_vowels(s: String): Integer should return the number of vowels (a, e, i, o, u, both upper and lowercase) in a string. Write it using across to iterate over the characters and a helper function is_vowel(ch: Char): Boolean. Test it with "Hello" (expected: 2), "rhythm" (expected: 0), and "aeiou" (expected: 5).
5.* The collatz sequence starting from a positive integer n is defined as follows: if n is even, the next term is n / 2; if n is odd, the next term is 3 * n + 1. The sequence continues until it reaches 1. For example, starting from 6: 6, 3, 10, 5, 16, 8, 4, 2, 1. Define a pure function collatz_length(n: Integer): Integer that returns the number of steps to reach 1 from n. Verify that collatz_length(6) is 8, collatz_length(27) is 111, and collatz_length(1) is 0.
6.* Write a function make_accumulator(): Function(n: Integer): Integer that returns a closure. Each call to the closure adds its argument to a running total the closure remembers, and returns the new total. Verify that calling the same accumulator with 10, then 5, then 20 returns 10, then 15, then 35. Then create a second, independent accumulator from a second call to make_accumulator(), and verify that using it does not disturb the running total of the first.