Practice: mutability, modules, and streams
This is a Practice chapter, not a Tutorial. There are no slides
and there is no video; it is a worksheet. The
Tutorial walked through a queue functor
on screen. Here you solve the problems yourself, directly in the
browser. Each problem has an editable cell seeded with
failwith "not implemented" (or a stub module) and a test cell
that prints all tests passed when your solution is correct. A
reference solution sits below each problem behind a collapsed
Reference solution panel: try the problem first, then reveal the
solution to compare.
The worksheet has three parts, one per thread of the module:
- Part 1: mutability (Problems 1 to 7). References, mutable record fields, and in-place array update, from the references lecture and arrays lecture.
- Part 2: modules and functors (Problems 8 to 13). Packaging code behind a signature, and writing functors that build modules from modules. Several problems tie Parts 1 and 2 together: functors whose result type is mutable.
- Part 3: streams (Problems 14 to 19). Infinite data built from thunks, from the streams lecture.
Difficulty rises roughly as you go. The functor problems in Part 2 are the meatiest; if you get stuck, skip ahead to the streams and come back.
Part 1: mutability
Problem 1: sum_ref
Write a function
sum_ref : int list -> int
that sums a list using a single mutable accumulator and
List.iter, not
recursion and not List.fold_left. The point is to practise the
ref idiom: allocate a cell, mutate it in a loop, read it out at the
end.
Implement sum_ref with a ref and List.iter.
Show reference solution
Reference solution:
Allocate acc once, mutate it with := for each element, and
dereference with ! at the end. This is the imperative shape of a
fold: the accumulator lives in a cell rather than being threaded
through recursive calls.
Problem 2: rotate_left
Write a function
rotate_left : 'a array -> unit
that rotates an array one position to the left, in place: the
element at index 0 moves to the end, everything else shifts down
one. For example, [|1; 2; 3; 4|] becomes [|2; 3; 4; 1|]. Arrays
of length 0 or 1 are unchanged. The function returns unit; its
effect is the mutation.
Implement rotate_left.
Show reference solution
Reference solution:
Save the first element, shift every later element down one with a
for loop and a.(i) <- ..., then drop the saved element into the
last slot. The n > 1 guard skips the work for empty and
singleton arrays (where rotation is a no-op). This is the kind of
index-juggling in-place mutation that arrays are for.
Problem 3: a mutable bank account
Using the record type
type account = { mutable balance : int }
write two functions:
deposit : account -> int -> unit
withdraw : account -> int -> bool
deposit acc n adds n to the balance. withdraw acc n subtracts
n only if the account has at least n; it returns true if the
withdrawal happened and false (leaving the balance untouched) if
there were insufficient funds.
Implement deposit and withdraw.
Show reference solution
Reference solution:
deposit is a one-line field update with <-. withdraw guards
the update on sufficient funds: when the guard holds it mutates and
returns true; otherwise it leaves the field alone and returns
false. Returning a bool lets the caller tell whether the
withdrawal succeeded, which a unit-returning version could not.
Problem 4: gensym
Write a fresh-name generator
gensym : unit -> string
so that successive calls return "x0", "x1", "x2", and so on:
each call produces a name that has never been produced before. This
is the trick a compiler uses to invent temporary variable names.
The counter must be private: a caller can call gensym but cannot
see or reset the count.
Implement gensym. Capture a ref counter in a closure (the
private-state-in-a-closure pattern from the
references lecture).
Show reference solution
Reference solution:
gensym is a value, not a function-with-arguments: evaluating
let gensym = let counter = ref 0 in fun () -> ... allocates the
counter cell once and returns a closure over it. Every call
reads the current count, bumps the cell, and formats the name. The
counter is unreachable from outside the closure, so it cannot be
tampered with. (Note the test binds the three results with let
before comparing: OCaml does not promise left-to-right evaluation
inside a single list or tuple, so forcing the order matters when
the calls have a side effect.)
Problem 5: prefix_sums_in_place
Write a function
prefix_sums_in_place : int array -> unit
that replaces each element with the sum of all elements up to and
including it, in place, in a single left-to-right pass. For
example, [|1; 2; 3; 4|] becomes [|1; 3; 6; 10|]. The empty array
and singleton arrays are unchanged.
Implement prefix_sums_in_place.
Show reference solution
Reference solution:
Starting at index 1, add the (already-updated) previous cell into
the current one. Because we sweep left-to-right, by the time we
reach index i the cell at i - 1 already holds the prefix sum up
to i - 1, so one addition extends it. Index 0 is already its own
prefix sum, so the loop starts at 1; empty and singleton arrays do
zero iterations.
Problem 6: a growable array
A fixed-size array cannot grow. A growable array (OCaml's
Buffer for ints, or Java's ArrayList, or Rust's Vec) wraps a
backing array plus a length, and doubles the backing array when
it fills up. Using the record type
type t = { mutable data : int array; mutable len : int }
implement:
create : unit -> t
length : t -> int
get : t -> int -> int
push : t -> int -> unit
create () starts empty (with some small backing capacity).
push t x appends x, growing the backing array if it is full.
get t i returns the ith pushed element; length t the number
of elements pushed.
Implement the growable array. Grow by allocating a new array of double the capacity and copying the elements over.
Show reference solution
Reference solution:
create allocates a small backing array (0 is a filler for the
unused slots) and a length of 0. push first checks whether the
backing array is full; if so it allocates one twice as large,
copies the live elements over, and swaps it in by mutating
t.data. Then it writes the new element at index t.len and bumps
t.len. Amortised over many pushes, the doubling makes each push
O(1) on average even though an individual grow is O(n). length
and get read the record and the backing array. The capacity
(Array.length t.data) and the logical length (t.len) are
deliberately different numbers: that gap is the whole point of a
growable array.
Problem 7: a sliding-window maximum
Implement a fixed-size window that remembers the last k values
pushed into it and can report their maximum. Using
type window = { buf : int array; mutable count : int; size : int }
implement:
make_window : int -> window
add : window -> int -> unit
current_max : window -> int
make_window k makes a window of capacity k. add w x records
x (overwriting the oldest value once more than k have been
added). current_max w returns the largest of the values currently
in the window (the last min count k added).
Implement the window with a ring buffer: an array of size k
indexed by count mod k, so the oldest value is overwritten
automatically.
Show reference solution
Reference solution:
The ring buffer stores values at index count mod size, so once
the window is full each new value overwrites the oldest. After the
window fills, all size slots hold exactly the last size values
(in some rotated order, which does not matter for a maximum).
current_max scans the min count size live slots with a mutable
running maximum. min_int is the identity for max, used both as
the filler and the seed. This is the imperative dual of folding
max over a list, with the list living in a fixed-size array.
Part 2: modules and functors
These problems are the heart of the worksheet. The signatures and
the functor problems (Showable, the doubly-linked-list node and
list, the functional heap) are drawn from the CS3100
mutability-and-modules and monads assignments; the set and
dictionary functors are the classic Set.Make / Map.Make shape.
Problem 8: Showable modules
Given the signature
module type Showable = sig
type t
val string_of_t : t -> string
end
implement two modules, IntShowable and FloatShowable, that
satisfy it. Use the standard library's string_of_int and
string_of_float for the string_of_t functions.
Implement IntShowable and FloatShowable.
Show reference solution
Reference solution:
Each module fixes t to a concrete type and supplies the printer.
The with type t = int in the ascription is the key detail: a bare
: Showable would make t abstract, so the test
IntShowable.string_of_t 10 would not type-check (10 is an int,
not the opaque IntShowable.t). The with type constraint exposes
the equation t = int to the outside while still checking the
module against the signature.
Problem 9: a doubly-linked-list node functor
This problem combines Part 1 (mutable fields) with Part 2 (functors). A doubly-linked-list node holds a value and mutable links to its neighbours. Implement the functor
module MakeNode : functor (C : Showable) -> NODE with type content = C.t
for the signature
module type NODE = sig
type t
type content
val create : content -> t
val get_content : t -> content
val get_next : t -> t option
val get_prev : t -> t option
val set_next : t -> t option -> unit
val set_prev : t -> t option -> unit
end
create c makes a fresh node holding c with no neighbours. The
get_* accessors read the fields; the set_* operations mutate the
next / prev links in place. The neighbours are t option so a
node at either end can record "no neighbour."
Implement the functor MakeNode.
Show reference solution
Reference solution:
The node is a record with one immutable field (value) and two
mutable fields (next, prev), exactly the doubly-linked-list
node from the arrays lecture, now packaged
inside a functor. The functor is parameterised by C : Showable,
so content is C.t; the with type content = C.t ascription
exposes that equation so the test can call create 1 with a plain
int. The set_* operations are field assignments (<-) returning
unit. Note the type t is recursive: a node's neighbours are
themselves nodes.
Problem 10: a doubly-linked-list functor
Build the list itself on top of the node from Problem 9: a functor
module MakeList : functor (N : NODE) -> DLL with type content = N.content
for the signature
module type DLL = sig
type t
type content
val create : unit -> t
val is_empty : t -> bool
val insert_first : t -> content -> unit
val to_list : t -> content list
end
A list holds a mutable reference to its head node. create () makes
an empty list. insert_first l c makes a new node for c, links it
in front of the current head (updating the old head's prev), and
makes it the new head. to_list l walks the chain via get_next
and returns the contents head-to-tail. (The starter cell already
provides a working MakeNode for you to build on.)
Implement the functor MakeList.
Show reference solution
Reference solution:
The list is a record with a single mutable head field. The
functor only ever touches nodes through N's operations, so it
works for any node module: it is a functor over a functor's
output. insert_first allocates a node, and if the list was
non-empty wires the two-way link between the new node and the old
head before repointing head. to_list walks get_next from the
head. Note is_empty compares l.head = None: that is safe even
though nodes are cyclic (a node's prev/next can point back at
it), because Some _ = None is decided by the constructors without
ever descending into the node.
Problem 11: set algebra over an ordering
The functors lecture built a
Set.Make-style functor whose output could only answer
membership questions: empty, add, mem. A real set module
also provides the set algebra: intersection, difference,
subset. Extend the same sorted-list design with those. Given
module type ORDERED = sig
type t
val compare : t -> t -> int
end
implement
module MakeSet : functor (O : ORDERED) -> SET with type elt = O.t
for
module type SET = sig
type elt
type t
val empty : t
val add : elt -> t -> t
val mem : elt -> t -> bool
val inter : t -> t -> t (* elements in both *)
val diff : t -> t -> t (* in the first, not the second *)
val subset : t -> t -> bool (* first contained in second? *)
val to_list : t -> elt list (* ascending, no duplicates *)
end
The representation is a sorted list with no duplicates, and the
starter already implements the membership part (empty, add,
mem, to_list). Your job is inter, diff, and subset.
Both arguments satisfy the sorted invariant, so each operation
can be a single synchronized walk down the two lists, comparing
heads and advancing the smaller side (the merge pattern), rather
than a nested loop that calls mem once per element.
Implement inter, diff, and subset in the functor MakeSet.
Show reference solution
Reference solution:
All three walk the two lists in lockstep. At each step, compare
the heads: equal heads mean a shared element (keep it for
inter, drop it for diff, keep checking for subset); a
smaller head on one side cannot appear on the other side at all,
because both lists are sorted. Each operation is therefore
O(|s1| + |s2|), the same shape as the merge step of merge sort.
The naive alternative, calling mem on s2 once per element of
s1, is O(|s1| * |s2|): the sorted invariant is what buys the
speedup. subset can answer false the moment an element of
s1 proves smaller than every remaining element of s2.
Problem 12: a dictionary functor over a key
A companion to the set: a small Map.Make-style dictionary,
parameterised by a key module that knows how to test keys for
equality. Given
module type KEY = sig
type t
val equal : t -> t -> bool
end
implement
module MakeDict : functor (K : KEY) -> DICT with type key = K.t
for
module type DICT = sig
type key
type 'v t
val empty : 'v t
val add : key -> 'v -> 'v t -> 'v t
val find : key -> 'v t -> 'v option
val remove : key -> 'v t -> 'v t
end
add k v d returns a dictionary mapping k to v and agreeing
with d elsewhere (a later add on the same key wins). find
returns Some v or None; remove drops a key. Note the value
type 'v stays polymorphic: only the key type is fixed by the
functor.
Implement the functor MakeDict.
Show reference solution
Reference solution:
The dictionary is an association list with no duplicate keys.
remove filters out any pair with a matching key; add removes
the old binding (if any) and conses the new one on the front, so
the invariant "at most one pair per key" holds and the latest add
wins. find walks until it hits the key. The value type 'v is a
parameter on t, so one MakeDict(StrKey) serves string-keyed
dictionaries of any value type. (Map.Make does the same with a
balanced tree instead of a list, for O(log n) operations.)
Problem 13: a functional heap
Not every "store" needs mutation. A functional heap is an
immutable key-value map that returns a new map on every update,
representing the store as a plain value. Implement the module
FHeap satisfying
module type FHEAP = sig
type ('k, 'v) t
val empty : ('k, 'v) t
val set : ('k, 'v) t -> 'k -> 'v -> ('k, 'v) t
val get : ('k, 'v) t -> 'k -> 'v option
end
empty is the heap with no bindings. set h k v returns a heap
that maps k to v and agrees with h everywhere else. get h k
returns Some v if k is bound, None otherwise. A later set
on a key shadows an earlier one.
Implement FHeap. (An association list is the simplest backing
store; you may use List.assoc_opt.)
Show reference solution
Reference solution:
The heap is an association list. set conses a new pair onto the
front (it does not remove the old binding); get uses
List.assoc_opt, which returns the first match, so the most
recent set wins. Because set builds a new list and never
mutates, the old heap keeps its bindings: the test's
"original heap unchanged" check passes. The representation type
('k, 'v) t is abstract behind the signature, so callers cannot
depend on it being a list. (CS3100's version uses a function
'k -> 'v option as the store instead; either works.)
Part 3: streams
These problems use the thunk-based stream type from the
streams lecture. Each problem's cell
seeds the type and the helpers it needs (hd, tl, take, and
sometimes from / map_s); you write the new function.
Problem 14: interleave
Write a function
interleave : 'a stream -> 'a stream -> 'a stream
that alternates between two streams: the first element of s1,
then the first of s2, then the second of s1, and so on. For
example, interleaving 0, 1, 2, ... with 100, 101, 102, ...
gives 0, 100, 1, 101, 2, 102, ....
Implement interleave.
Show reference solution
Reference solution:
Emit the head of s1 now, and defer the rest: the tail thunk
interleaves s2 with the tail of s1, with the two streams
swapped. Swapping the arguments on each step is what makes the
output alternate. Because the tail is a thunk, only as much of each
stream as take demands is ever forced.
Problem 15: cycle
Write a function
cycle : 'a list -> 'a stream
that turns a non-empty list into the infinite stream that repeats
it forever. For example, cycle [1; 2; 3] is the stream
1, 2, 3, 1, 2, 3, 1, .... On the empty list, raise
Invalid_argument (there is nothing to cycle).
Implement cycle.
Show reference solution
Reference solution:
The inner go walks the list, emitting one element per stream
node; when it runs off the end ([]) it starts over from the
original lst. The restart sits inside the tail thunk, so the
stream is genuinely infinite but uses finite memory: there is one
go lst closure, re-entered each time round. The empty-list guard
runs before building any stream, so cycle [] fails immediately
rather than diverging.
Problem 16: merge, and the Hamming numbers
Write a function
merge : int stream -> int stream -> int stream
that merges two sorted, strictly increasing streams into one
sorted stream, dropping duplicates (a value in both streams
appears once). With merge in hand, a famous stream falls out: the
Hamming numbers (or 5-smooth numbers), the increasing sequence
of positive integers whose only prime factors are 2, 3, and 5:
1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, .... The test below builds
that stream as
let rec hamming =
Cons (1, fun () ->
merge (map_s (fun x -> x * 2) hamming)
(merge (map_s (fun x -> x * 3) hamming)
(map_s (fun x -> x * 5) hamming)))
a stream defined in terms of itself: every Hamming number times 2, 3, or 5 is another Hamming number, merged back together.
Implement merge (deduplicating). The test wires it into the
self-referential hamming stream.
Show reference solution
Reference solution:
Compare the two heads. Emit the smaller and advance only that
stream; when the heads are equal, emit one copy and advance
both (this is the deduplication). The result stays sorted because
each step emits the global minimum of the two fronts. In hamming,
the three scaled copies (2x, 3x, 5x) are each sorted, and
merging them deduplicates values reachable two ways (for example
6 = 2 * 3 = 3 * 2). The self-reference is safe because every use
of hamming sits inside a tail thunk, so the stream is only forced
one node at a time, by which point earlier nodes already exist.
Problem 17: iterate
Write the unfold combinator
iterate : ('a -> 'a) -> 'a -> 'a stream
so that iterate f x is the stream x, f x, f (f x), f (f (f x)), .... For example, iterate (fun n -> n * 2) 1 is the powers of
two 1, 2, 4, 8, 16, ..., and iterate (fun n -> n + 1) 0 is the
naturals.
Implement iterate.
Show reference solution
Reference solution:
The current value x is the head; the tail is iterate f applied
to f x, deferred behind a thunk. Each forced node advances the
seed by one application of f. iterate is the most general
one-step stream generator: from n is iterate (fun k -> k + 1) n, and many of the streams in this lecture are special cases of
it.
Problem 18: partial_sums
Write a function
partial_sums : int stream -> int stream
that turns a stream into its stream of running totals: the nth
output is the sum of the first n + 1 inputs. For example, the
partial sums of 1, 2, 3, 4, ... are 1, 3, 6, 10, .... This is a
scan: like a fold, but it emits every intermediate accumulator
instead of only the final one (and on an infinite stream there is
no final one).
Implement partial_sums.
Show reference solution
Reference solution:
The helper go carries the running total acc. At each node it
adds the current head to acc, emits the new total, and defers the
rest with the updated acc captured in the thunk. Unlike a fold,
which would loop forever on an infinite stream and never return,
the scan produces one total per node on demand. The seed 0 makes
the first output equal to the first input.
Problem 19: enumerating all pairs
A stream visits its elements one after another, so it can only
enumerate a sequence. Can it enumerate a grid: every pair
(i, j) of non-negative integers, each appearing at some finite
position? Yes, by walking the diagonals. Write
nat_pairs : (int * int) stream
that enumerates the pairs in order of increasing i + j, and within
each diagonal by increasing i: (0,0), then (0,1), (1,0), then
(0,2), (1,1), (2,0), and so on. Every pair appears at a finite
index, which is what makes int * int (a "two-dimensional"
infinity) streamable at all.
Implement nat_pairs. A helper that walks one diagonal (fixed sum
s, increasing i) and rolls over to the next diagonal is the
clean way.
Show reference solution
Reference solution:
diag s i produces the points on the diagonal i + j = s, walking
i from 0 up to s (so j = s - i runs from s down to 0). When
i exceeds s the diagonal is done and we roll over to `diag (s +
- 0
. Starting atdiag 0 0enumerates diagonals0, 1, 2, ...in turn. Because each diagonal is finite, every pair(i, j)` is reached after the finitely many points on the earlier diagonals: this is Cantor's diagonal enumeration of pairs, the argument that the rationals are countable, expressed as a stream.
What you should be able to do now
By the end of these nineteen problems you should be comfortable with:
- The reference idiom (
sum_ref,gensym's private counter), in-place array work with aforloop (rotate_left,prefix_sums_in_place), and mutable records (deposit/withdraw, the growable array, the ring-buffer window). - The amortised-doubling trick behind every growable array, and the ring-buffer trick behind a fixed-size sliding window.
- Writing a module to satisfy a signature, and why
with type t = ...matters when a caller needs the concrete type (Showable). - Writing functors that build modules from modules: one whose
result is a mutable node (
MakeNode), one built on top of another functor (MakeList), and theSet.Make/Map.Makeshape parameterised by a comparison or equality (MakeSet,MakeDict). - Hiding a representation behind an abstract type, and the
difference between a functional store that returns new values
(
FHeap) and a mutable one that updates in place. - Building and consuming infinite streams: alternating
(
interleave), repeating (cycle), merging and self-reference (merge/ Hamming), unfolding (iterate), scanning (partial_sums), and diagonalising a grid (nat_pairs).
The next module goes further with two more advanced ideas: monads (a uniform way to sequence computations that carry context, including the kind of functional-heap state you built in Problem 13) and generalised algebraic data types (GADTs).
Sources
Part 2's signature-and-functor problems (Showable, the
MakeNode / MakeList doubly-linked list, and the functional heap
FHeap) are drawn from the mutability-and-modules and monads
assignments of the instructor's CS3100: Paradigms of Programming
course at IIT Madras, with prose, signatures, test harnesses, and
reference solutions rewritten for this NPTEL course. The set and
dictionary functors (MakeSet, MakeDict) follow the standard
Set.Make / Map.Make shape. Part 1 (references, arrays, mutable
records) and Part 3 (streams) are new here, exercising this
module's lectures directly.