ITT8060 Advanced Programming · Autumn 2026
Tallinn University of Technology
Full prose version: notes.html
ITT8060Two questions about any evaluation:
Computation time — the number of individual computation steps.
Space — the maximal memory needed during the evaluation to represent expressions and bindings.
Plan for today:
recursion has a cost → accumulating parameters → the stack/heap model → iteration in general → continuations.
ITT8060let rec fact x =
match x with
| 0 -> 1
| n -> n * fact (n - 1)
What resources are needed to compute \(\mathtt{fact}(N)\)?
Every recursive call sits inside a multiplication —
each pending n * … must be remembered.
ITT8060fact N
~> N * fact (N-1)
~> N * ((N-1) * fact (N-2))
~> ...
~> N * ((N-1) * ((N-2) *
(... (3 * (2 * 1)) ...)))
~> ...
~> N!
The chain of pending multiplications grows as deep as the recursion.
Time: proportional to \(N\) — fine.
Space: proportional to \(N\) — is this satisfactory?
ITT8060naiveRev [x1; x2; ...; xn]
~> naiveRev [x2; ...; xn] @ [x1]
~> (naiveRev [x3; ...; xn] @ [x2]) @ [x1]
~> ...
~> ((...(([] @ [xn]) @ [xn-1]) @ ...)
@ [x2]) @ [x1]
let rec naiveRev lst =
match lst with
| [] -> []
| x :: xs -> naiveRev xs @ [x]
@ is linear in its first argument, so the nested appends cost
\(1 + 2 + \cdots + (n-1)\) steps.
Space \(\sim n\): satisfactory. Time \(\sim n^2\): not satisfactory.
ITT8060
ITT8060Efficient solutions come from more general functions with an extra argument:
\[\mathtt{factA}(n, m) = n! \cdot m \qquad (n \ge 0)\]
\[\mathtt{revA}([x_1, \ldots, x_n],\, ys) = [x_n, \ldots, x_1]\ @\ ys\]
The originals are the special cases:
\[n! = \mathtt{factA}(n, 1) \qquad \mathtt{rev}\,[x_1,\ldots,x_n] = \mathtt{revA}([x_1,\ldots,x_n], [\,])\]
\(m\) and \(ys\) are accumulating parameters: they hold the temporary result during the evaluation.
ITT8060let rec factA (x, m) =
match x with
| 0 -> m
| n -> factA (n - 1, n * m)
factAfactA(5, 1)
~> factA(4, 5)
~> factA(3, 20)
~> ...
~> factA(0, 120)
~> 120
Space demand: constant.
Time demand: proportional to \(n\).
ITT8060let rec revA (lst, ys) =
match lst with
| [] -> ys
| x :: xs -> revA (xs, x :: ys)
revArevA([1;2;3], [])
~> revA([2;3], 1 :: [])
~> revA([3], [2; 1])
~> revA([], [3; 2; 1])
~> [3; 2; 1]
Space and time: proportional to \(n\), the length of the first list.
@ has been replaced by ::.
ITT8060factA and revA are tail-recursive:
the recursive call is the last function application evaluated in the
body — e.g. factA(3, 20), revA([3], [2;1]).
Consequence: only one set of bindings for the argument identifiers is needed during the whole evaluation.
Compare with fact: nothing is waiting to the left of the call.
ITT8060> let xs16 = List.replicate 1000000 16;;
> #time;; // toggle in F# Interactive
> for i in xs16 do fact i |> ignore;;
Real: 00:00:00.051, CPU: 00:00:00.046
> for i in xs16 do
factA (i, 1) |> ignore;;
Real: 00:00:00.024, CPU: 00:00:00.031
> for i in xs16 do () ;;
Real: 00:00:00.012, CPU: 00:00:00.015
#time reports elapsed time, CPU time and garbage collections.
The for loop alone costs ~12 ms —
the real gain of factA is much better than the visible factor 2.
ITT8060> let xs20000 = [1 .. 20000];;
> naiveRev xs20000;;
Real: 00:00:07.624, CPU: 00:00:07.597,
GC gen0: 825, gen1: 253, gen2: 0
> revA (xs20000, []);;
Real: 00:00:00.001, CPU: 00:00:00.000,
GC gen0: 0, gen1: 0, gen2: 0
7.6 seconds versus 1 millisecond.
Look at the GC columns: the naive version had 825 + 253 collections;
revA reclaimed nothing.
Reducing @ to :: did that. To see why — memory management.
ITT8060
ITT8060let xs = [5; 6; 7]
let ys = 3 :: 4 :: xs
let zs = xs @ ys
let n = 27
Primitive values (int, …) live on the stack, in the current
stack frame.
Composite values (lists) are built from cons cells on the heap; the stack holds references into it.
What does memory look like after these four bindings?
ITT8060ys = 3 :: 4 :: xs — not copied: its chain continues into xs.
zs = xs @ ys — fresh cells for the elements of xs only; the last
one links to the ys chain.
Safe because lists are immutable — and exactly why @ is linear in
its first argument.
ITT8060let zs = let xs = [1; 2]
let ys = [3; 4]
xs @ ys
Evaluating the local declarations pushes frame sf1: bindings for
xs, ys and the auxiliary entry result for the value of the whole
let-expression.
ITT8060The top frame is popped when the let-expression completes;
zs receives the result.
The cells marked † are now unreachable — obsolete.
ITT8060let ws = [6; 7]
let addFive zs = 5 :: zs
let vs = addFive ws
zs in sf1 points at the same cells as the caller's ws;
the result is one fresh cell linking into the existing chain.
Imperative runtimes need copy-on-write to share safely.
Immutable lists share for free — no one can write.
ITT8060The garbage collector reclaims obsolete cells behind the scenes, managing
the heap in three generations by age: gen0 (youngest), gen1, gen2.
Design bet: objects die young.
naiveRev xs20000;;
GC gen0: 825, gen1: 253, gen2: 0
The churn stayed in the young generations — the bet pays off.
ITT8060> let rec bigList n =
if n = 0 then []
else 1 :: bigList (n-1);;
> bigList 120000;;
val it: int list = [1; 1; 1; ...]
> bigList 130000;;
Process is terminated due to
StackOverflowException.
> let rec bigListA n xs =
if n = 0 then xs
else bigListA (n-1) (1 :: xs);;
> bigListA 12_000_000 [];;
val it: int list = [1; 1; 1; ...]
> bigListA 13_000_000 [];;
System.OutOfMemoryException: ...
Stack: ~\(1.2 \cdot 10^5\) frames.
Heap: ~\(1.2 \cdot 10^7\) cells — two orders of magnitude more.
bigListA does not exhaust the stack. Why?
Its call is a tail call — the same frame is reused.
That is the entire secret.
ITT8060
ITT8060let rec iterate p f h z =
if p z then iterate p f h (f z)
else h z
A function is iterative if it is an instance of this shape, for a
predicate p, step function f and finishing function h.
factA iterates \(f(n, m) = (n-1,\ n \cdot m)\).
revA iterates \(g(x :: xs,\ ys) = (xs,\ x :: ys)\).
ITT8060iterate p f h v
~> iterate p f h (f v) [z ↦ v]
~> iterate p f h (f (f v)) [z ↦ f v]
~> ...
~> h (fⁿ v) when p (fⁿ v) = false
Observe the desirable properties:
\(n\) recursive calls, but at most one binding of z active at any
stage —
— and therefore one stack frame suffices.
The compiler turns this into a loop.
ITT8060let factW n =
let mutable ni = n
let mutable r = 1
while ni > 0 do
r <- r * ni
ni <- ni - 1
r
> for i in 1..1000000 do
factA (16, 1) |> ignore;;
Real: 0.024 s, GC gen0: 0
> for i in 1..1000000 do
factW 16 |> ignore;;
Real: 0.048 s, GC gen0: 9
Every while loop corresponds to a tail-recursive function, and vice versa — and measured, the tail-recursive version is not slower than the loop.
(Modern style: let mutable — ref/!/:= are deprecated.)
ITT8060let rec fib x =
match x with
| 0 -> 0
| 1 -> 1
| n -> fib (n - 1) + fib (n - 2)
Straight from the definition — and hopeless:
fib 4
~> fib 3 + fib 2
~> (fib 2 + fib 1) + fib 2
~> ((fib 1 + fib 0) + fib 1)
+ fib 2
~> ...
The two calls recompute each other's work.
fib 44 needs around \(10^9\) base-case evaluations.
ITT8060let rec itfib (n, a, b) =
if n <> 0 then itfib (n - 1, a + b, a)
else a
Two accumulators — the last two Fibonacci numbers:
itfib(n, 0, 1)
~> itfib(n-1, F₁, F₀)
~> itfib(n-2, F₂, F₁)
~> ...
~> itfib(0, Fₙ, Fₙ₋₁) ~> Fₙ
Invariant after \(k\) steps: arguments are \((n-k,\ F_k,\ F_{k-1})\).
The invariant is the correctness proof.
ITT8060
ITT8060type BinTree<'a> =
| Leaf
| Node of BinTree<'a> * 'a * BinTree<'a>
let rec count t =
match t with
| Leaf -> 0
| Node (tl, _, tr) ->
count tl + count tr + 1
Try it: countA : int -> BinTree<'a> -> int with
countA n t = n + count t — the inner call
still grows the stack. (Hansen & Rischel, Ex. 9.8)
Whatever accumulator you add, the body still makes two recursive calls —
— and one of them cannot be in tail position.
ITT8060let rec bigListC n c =
if n = 0 then c []
else bigListC (n - 1)
(fun res -> c (1 :: res))
A continuation c is a function for the rest of the computation.
Base case: feed the result to c.
Recursive case: the continuation of bigListC (n-1) is
fun res -> c (1 :: res).
Both the recursive call and the calls of c are tail calls.
ITT8060> bigListC 16_000_000 id;;
Real: 00:00:08.586, CPU: 00:00:08.314,
GC gen0: 80, gen1: 60, gen2: 3
val it: int list = [1; 1; 1; ...]
The pending work that used to sit in stack frames now sits in closures on the heap.
Slower than bigList — but it can build lists that bigList cannot
(recall: bigList overflowed at 130 000).
ITT8060let rec countC t c =
match t with
| Leaf -> c 0
| Node (tl, _, tr) ->
countC tl (fun vl ->
countC tr (fun vr ->
c (vl + vr + 1)))
Both calls of countC are tail calls; the call of c is a tail call.
The stack does not grow.
> let t =
Node(Node(Leaf,1,Leaf), 2,
Node(Leaf,3,Leaf));;
> countC t id;;
val it: int = 3
countC handles bigger trees than count — count is faster on trees
that fit.
ITT8060Loops in imperative languages = a special case of recursion: tail-recursive (iterative) functions.
Have iterative functions in mind when efficiency matters: avoid huge
chains of pending operations, and inadequate @ in recursive
declarations.
Memory management: stack (frames, primitives), heap (cons cells, closures), generational garbage collection.
Continuations turn arbitrary recursion tail-recursive — trading stack for heap.
None of this replaces the algorithmic idea: iterative functions do not substitute for good algorithms and data structures.
ITT8060