Control Flow in Standard ML
Learn control flow in Standard ML - if expressions, case pattern matching, recursion, and while loops with practical Docker-ready examples
Control flow determines the order in which a program’s logic executes. In most imperative languages this means statements that branch and loops that iterate. Standard ML approaches the problem differently: as a functional language, its control-flow constructs are expressions that produce values, and the workhorse for repetition is recursion, not the for loop.
This shift matters. An if in SML is not a statement that conditionally runs code — it is an expression that evaluates to one of two values, so both branches must have the same type. Likewise, case is far more than a switch: it is built on pattern matching, the same mechanism that destructures data throughout the language. Where Java or Python reach for a counter and a loop body, idiomatic SML defines a recursive function whose patterns describe each case of the data.
SML is multi-paradigm, so it does provide imperative while loops and mutable references for the rare cases that genuinely need them. But you will write far less of that than you might expect. This tutorial walks through both the functional core — if, case, recursion, guards — and the imperative escape hatches, so you can recognize idiomatic SML and know when each tool fits.
Conditional Expressions: if/then/else
In SML, if ... then ... else is an expression, not a statement. It always returns a value, and the else branch is mandatory because every expression must produce a result. Both branches must have the same type.
Create a file named conditionals.sml:
| |
There is no ternary operator in SML — and none is needed, since if already is the conditional expression. Note that SML writes negative numbers with a tilde (~3), reserving - for subtraction. The andalso and orelse keywords are the short-circuit logical AND and OR.
Pattern Matching with case
The case expression compares a value against a series of patterns and evaluates the branch for the first one that matches. It is the functional replacement for a switch statement, but considerably more powerful — patterns can destructure tuples, lists, and custom datatypes. The wildcard _ matches anything.
Create a file named case_match.sml:
| |
A major advantage: the compiler checks that your patterns are exhaustive. If you forget a case, SML warns you at compile time — a safety net that prevents an entire class of bugs. Note that => (the match arrow) is distinct from -> (the function-type arrow).
Recursion Replaces Loops
This is where SML diverges most from imperative languages. To repeat work, you define a recursive function whose patterns describe each case. The two clauses below — one for the base case, one for the recursive step — are the loop. Clauses are separated by |, mirroring the case syntax.
Create a file named recursion.sml:
| |
The sumList function shows the classic list pattern: [] matches the empty list, and x::xs matches a non-empty list, binding x to the head and xs to the tail. The accumulator-based factorial is tail recursive — the recursive call is the last thing the function does — which lets SML compile it into an efficient loop with constant stack usage.
Imperative Loops with while
SML is multi-paradigm, so when an algorithm is genuinely iterative and stateful, you can use a while loop together with mutable references (ref). Use ! to read a reference and := to update it. Reach for this only when recursion would be awkward — most SML code never needs it.
Create a file named loops.sml:
| |
There is no for loop in Standard ML. For walking a collection, the functional List.app (apply a function to each element for its side effect) is clearer than manual indexing, and higher-order functions like map and foldl cover most of what loops are used for elsewhere.
Putting It Together: FizzBuzz
This classic exercise combines conditionals and recursion. The inner line function uses chained if expressions to decide each number’s label, and loop recurses from 1 to n — the functional equivalent of a counting loop.
Create a file named control_flow.sml:
| |
The base case returns () (the unit value) to stop the recursion, and the recursive case prints a line then advances the counter. Sequencing two expressions with ; inside parentheses runs them in order — print first, then the recursive call.
Running with Docker
| |
SML/NJ prints a version banner and val it = () : unit binding echoes around your program’s output; the outputs below show just the text your programs produce.
Expected Output
conditionals.sml:
positive
negative
zero
larger = 10
50 in range? true
200 in range? false
case_match.sml:
zero
two
many
Sat weekend? true
Mon weekend? false
recursion.sml:
5... 4... 3... 2... 1... Liftoff!
Sum = 15
5! = 120
loops.sml:
Sum 1..10 = 55
10 20 30
control_flow.sml:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
Key Concepts
ifis an expression, not a statement — it always returns a value, theelsebranch is mandatory, and both branches must share a type. There is no ternary operator becauseifalready fills that role.caseis built on pattern matching — it destructures values and the compiler warns when your patterns aren’t exhaustive, catching missing cases before runtime.- Recursion replaces loops — repetition is expressed with a recursive function’s base and recursive clauses, separated by
|. List recursion uses the[]andx::xspatterns. - Tail recursion is efficient — when the recursive call is the last action, SML runs it in constant stack space, just like an imperative loop.
andalsoandorelseare the short-circuit boolean operators;=is equality (there is no==).- Imperative loops exist but are rare —
while ... dowithref,!, and:=is available, and there is noforloop; preferList.app,map, andfoldlfor collections. - Mind the arrows and the tilde —
=>separates match patterns from results,->denotes function types, and negative numbers are written with~(e.g.,~3).
Running Today
All examples can be run using Docker:
docker pull eldesh/smlnj:latest
Comments
Loading comments...
Leave a Comment