Intermediate

Functions in Standard ML

Learn how functions work in Standard ML - definitions, recursion, currying, partial application, higher-order functions, and closures with Docker-ready examples

Functions are the heart of Standard ML. As a functional language, SML treats functions as first-class values: they can be bound to names, passed as arguments, returned from other functions, and stored in data structures. There are no “methods” or “subroutines” in the object-oriented sense — every piece of behavior is a function, and even operators like + are functions underneath.

Two ideas set SML functions apart from imperative languages. First, every function takes exactly one argument and returns exactly one value; what looks like a multi-argument function is really a chain of single-argument functions, a technique called currying. Second, SML’s Hindley-Milner type inference figures out the type of each function for you, so you rarely write type annotations even though the language is statically typed.

This tutorial covers defining and calling functions, recursion (which replaces loops in functional code), higher-order functions like map and foldl, currying with partial application, and closures that capture their surrounding environment. Each example is a complete, runnable program.

Defining and Calling Functions

Functions are introduced with the fun keyword. Parameters are separated by spaces — both in the definition and at the call site — not wrapped in parentheses and commas.

Create a file named functions.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
(* One parameter; the compiler infers int -> int *)
fun square x = x * x

(* Two parameters; the compiler infers int -> int -> int *)
fun add x y = x + y

(* Explicit type annotations are allowed but rarely needed *)
fun greet (name : string) : string = "Hello, " ^ name ^ "!"

(* A function whose only purpose is a side effect returns unit *)
fun announce name = print (greet name ^ "\n")

val () = print ("square 5 = " ^ Int.toString (square 5) ^ "\n")
val () = print ("add 3 4 = " ^ Int.toString (add 3 4) ^ "\n")
val () = announce "Standard ML"

Notice that add 3 4 applies add to two arguments with nothing but spaces. The ^ operator concatenates strings, and Int.toString converts an integer to its textual form so it can be printed.

Recursion Instead of Loops

Pure functional code has no mutable loop counters, so iteration is expressed with recursion. SML makes recursion natural by combining it with pattern matching: you list one clause per case, separated by |.

Create a file named recursion.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
(* One clause per case, separated by | *)
fun factorial 0 = 1
  | factorial n = n * factorial (n - 1)

(* Pattern match on list structure: empty vs head::tail *)
fun sumList [] = 0
  | sumList (x::xs) = x + sumList xs

(* Tail recursion with an accumulator avoids growing the stack *)
fun factTail (0, acc) = acc
  | factTail (n, acc) = factTail (n - 1, n * acc)

val () = print ("factorial 5 = " ^ Int.toString (factorial 5) ^ "\n")
val () = print ("sumList [1,2,3,4] = " ^ Int.toString (sumList [1,2,3,4]) ^ "\n")
val () = print ("factTail (6, 1) = " ^ Int.toString (factTail (6, 1)) ^ "\n")

The first version of factorial builds up a chain of pending multiplications. The factTail version carries a running result in an accumulator, so the recursive call is the last thing it does — a tail call that SML compilers turn into an efficient loop.

Higher-Order Functions

Because functions are values, they can be passed to and returned from other functions. The Basis Library provides map, List.filter, and foldl for processing lists without writing explicit recursion. You can pass either a named function or an anonymous one written with fn ... => ....

Create a file named higher_order.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
(* Takes a function f and applies it twice *)
fun applyTwice f x = f (f x)

(* An anonymous function (lambda) bound to a name *)
val increment = fn x => x + 1

(* Pass a named function to map *)
fun double x = x * 2
val doubled = map double [1, 2, 3, 4]

(* Pass an inline anonymous function to filter *)
val evens = List.filter (fn x => x mod 2 = 0) [1, 2, 3, 4, 5, 6]

(* foldl collapses a list into a single value *)
val total = foldl (fn (x, acc) => x + acc) 0 [1, 2, 3, 4, 5]

fun showList xs = "[" ^ String.concatWith ", " (map Int.toString xs) ^ "]"

val () = print ("applyTwice increment 5 = " ^ Int.toString (applyTwice increment 5) ^ "\n")
val () = print ("doubled = " ^ showList doubled ^ "\n")
val () = print ("evens = " ^ showList evens ^ "\n")
val () = print ("total = " ^ Int.toString total ^ "\n")

applyTwice increment 5 computes increment (increment 5), which is 7. The combination of small reusable functions and higher-order operators is the functional alternative to writing loops by hand.

Currying and Partial Application

Every SML function of “several arguments” is actually a curried chain of single-argument functions. The type int -> int -> int reads as “a function that takes an int and returns a function that takes an int and returns an int.” This means you can apply a function to some of its arguments and get back a specialized function — partial application.

Create a file named currying.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(* add has type int -> int -> int (curried) *)
fun add x y = x + y

(* Applying add to one argument yields a new function *)
val addFive = add 5

(* A function that returns a function (a closure over n) *)
fun makeAdder n = fn x => x + n
val addTen = makeAdder 10

(* A closure capturing a mutable reference keeps private state *)
fun makeCounter () =
  let
    val count = ref 0
  in
    fn () => (count := !count + 1; !count)
  end

val tick = makeCounter ()

val () = print ("addFive 3 = " ^ Int.toString (addFive 3) ^ "\n")
val () = print ("addTen 7 = " ^ Int.toString (addTen 7) ^ "\n")
val () = print ("tick () = " ^ Int.toString (tick ()) ^ "\n")
val () = print ("tick () = " ^ Int.toString (tick ()) ^ "\n")
val () = print ("tick () = " ^ Int.toString (tick ()) ^ "\n")

addFive and addTen are closures: they remember the value (5 and 10) that was in scope when they were created. makeCounter shows closures capturing a mutable ref, giving each counter its own private, persistent state — calling tick three times yields 1, 2, 3.

Local Functions and Scope

The let ... in ... end construct introduces bindings — including helper functions — that are visible only inside the expression. Names bound at the top level with val or fun are visible to every definition that follows them.

Create a file named scope.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(* Local value bindings, visible only between in and end *)
fun sumOfSquares a b =
  let
    val aSquared = a * a
    val bSquared = b * b
  in
    aSquared + bSquared
  end

(* A helper function scoped inside describe *)
fun describe n =
  let
    fun parity x = if x mod 2 = 0 then "even" else "odd"
  in
    Int.toString n ^ " is " ^ parity n
  end

(* A top-level binding visible to every function below it *)
val taxRate = 8

fun addTax price = price + (price * taxRate) div 100

val () = print ("sumOfSquares 3 4 = " ^ Int.toString (sumOfSquares 3 4) ^ "\n")
val () = print (describe 7 ^ "\n")
val () = print ("addTax 100 = " ^ Int.toString (addTax 100) ^ "\n")

The helper parity exists only within describe; referencing it elsewhere would be a compile error. Meanwhile taxRate is a global binding that addTax closes over. Using let to hide helpers keeps the top-level namespace clean and signals intent.

Running with Docker

1
2
3
4
5
6
7
8
9
# Pull the SML/NJ image
docker pull eldesh/smlnj:latest

# Run each example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml functions.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml recursion.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml higher_order.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml currying.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml scope.sml

SML/NJ also prints version banners and inferred type information for each binding. The lines below are the program output produced by the print calls.

Expected Output

functions.sml:

square 5 = 25
add 3 4 = 7
Hello, Standard ML!

recursion.sml:

factorial 5 = 120
sumList [1,2,3,4] = 10
factTail (6, 1) = 720

higher_order.sml:

applyTwice increment 5 = 7
doubled = [2, 4, 6, 8]
evens = [2, 4, 6]
total = 15

currying.sml:

addFive 3 = 8
addTen 7 = 17
tick () = 1
tick () = 2
tick () = 3

scope.sml:

sumOfSquares 3 4 = 25
7 is odd
addTax 100 = 108

Key Concepts

  • fun defines functions, and arguments are applied with spaces (add 3 4), not parenthesized comma lists.
  • Type inference deduces each function’s type, so annotations like (name : string) are optional even in this statically-typed language.
  • Recursion replaces loops; pair it with pattern matching (fun f [] = ... | f (x::xs) = ...) to handle each case cleanly.
  • Tail recursion with an accumulator lets the compiler reuse stack space, the functional equivalent of an iterative loop.
  • Higher-order functions like map, List.filter, and foldl take other functions — named or anonymous (fn x => ...) — as arguments.
  • Currying means every multi-argument function is a chain of one-argument functions, enabling partial application (add 5 yields a new function).
  • Closures capture variables from their defining scope, including mutable ref cells, which gives functions private persistent state.
  • let ... in ... end scopes local values and helper functions so they stay invisible to the rest of the program.

Running Today

All examples can be run using Docker:

docker pull eldesh/smlnj:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining