I couldn't find a beginner friendly answer to what the difference between the "local" and "let" keywords in SML is. Could someone provide a simple example please and explain when one is used over the other?
2 Answers
(TL;DR)
- Use
case ... of ...
when you only have one temporary binding. - Use
let ... in ... end
for very specific helper functions. - Never use
local ... in ... end
. Use opaque modules instead.
Adding some thoughts on use-cases to sepp2k's fine answer:
(Summary)
local ... in ... end
is a declaration andlet ... in ... end
is an expression, so that effectively limits where they can be used: Where declarations are allowed (e.g. at the top level or inside a module), and inside value declarations (val
andfun
), respectively.But so what? It often seems that either can be used. The Rosetta Stone QuickSort code, for example, could be structured using either, since the helper functions are only used once:
(* First using local ... in ... end *) local fun par_helper([], x, l, r) = (l, r) | par_helper(h::t, x, l, r) = if h <= x then par_helper(t, x, l @ [h], r) else par_helper(t, x, l, r @ [h]) fun par(l, x) = par_helper(l, x, [], []) in fun quicksort [] = [] | quicksort (h::t) = let val (left, right) = par(t, h) in quicksort left @ [h] @ quicksort right end end (* Second using let ... in ... end *) fun quicksort [] = [] | quicksort (h::t) = let fun par_helper([], x, l, r) = (l, r) | par_helper(h::t, x, l, r) = if h <= x then par_helper(t, x, l @ [h], r) else par_helper(t, x, l, r @ [h]) fun par(l, x) = par_helper(l, x, [], []) val (left, right) = par(t, h) in quicksort left @ [h] @ quicksort right end
So let's focus on when it is particularly useful to use one or the other.
local ... in ... end
is mainly used when you have one or more temporary declarations (e.g. helper functions) that you want to hide after they're used, but they should be shared between multiple non-local declarations. E.g.(* Helper function shared across multiple functions *) local fun par_helper ... = ... fun par(l, x) = par_helper(l, x, [], []) in fun quicksort [] = [] | quicksort (h::t) = ... par(t, h) ... fun median ... = ... par(t, h) ... end
If there weren't multiple, you could have used a
let ... in ... end
instead.You can always avoid using
local ... in ... end
in favor of opaque modules (see below).let ... in ... end
is mainly used when you want to compute temporary results, or deconstruct values of product types (tuples, records), one or more times inside a function. E.g.fun quicksort [] = [] | quicksort (x::xs) = let val (left, right) = List.partition (fn y => y < x) xs in quicksort left @ [x] @ quicksort right end
Here are some of the benefits of
let ... in ... end
:- A binding is computed once per function call (even when used multiple times).
- A binding can simultaneously be deconstructed (into
left
andright
here). - The declaration's scope is limited. (Same argument as for
local ... in ... end
.) - Inner functions may use the arguments of the outer function, or the outer function itself.
- Multiple bindings that depend on each other may neatly be lined up.
And so on... Really, let-expressions are quite nice.When a helper function is used once, you might as well nest it inside a
let ... in ... end
.Especially if other reasons for using one applies, too.
Some additional opinions
(
case ... of ...
is awesome, too.)When you have only one
let ... in ... end
you can instead write e.g.fun quicksort [] = [] | quicksort (x::xs) = case List.partition (fn y => y < x) xs of (left, right) => quicksort left @ [x] @ quicksort right
These are equivalent. You might like the style of one or the other. The
case ... of ...
has one advantage, though, being that it also work for sum types ('a option
,'a list
, etc.), e.g.(* Using case ... of ... *) fun maxList [] = NONE | maxList (x::xs) = case maxList xs of NONE => SOME x | SOME y => SOME (Int.max (x, y)) (* Using let ... in ... end and a helper function *) fun maxList [] = NONE | maxList (x::xs) = let val y_opt = maxList xs in Option.map (fn y => Int.max (x, y)) y_opt end
The one disadvantage of
case ... of ...
: The pattern block does not stop, so nesting them often requires parentheses. You can also combine the two in different ways, e.g.fun move p1 (GameState old_p) gameMap = let val p' = addp p1 old_p in case getMapPos p' gameMap of Grass => GameState p' | _ => GameState old_p end
This isn't so much about not using
local ... in ... end
, though.Hiding declarations that won't be used elsewhere is sensible. E.g.
(* if they're overly specific *) fun handvalue hand = let fun handvalue' [] = 0 | handvalue' (c::cs) = cardvalue c + handvalue' cs val hv = handvalue' hand in if hv > 21 andalso hasAce hand then handvalue (removeAce hand) + 1 else hv end (* to cover over multiple arguments, e.g. to achieve tail-recursion, *) (* or because the inner function has dependencies anyways (here: x). *) fun par(ys, x) = let fun par_helper([], l, r) = (l, r) | par_helper(h::t, l, r) = if h <= x then par_helper(t, l @ [h], r) else par_helper(t, l, r @ [h]) in par_helper(ys, [], []) end
And so on. Basically,
- If a declaration (e.g. function) will be re-used, don't hide it.
- If not, the point of
local ... in ... end
overlet ... in ... end
is void.
(
local ... in ... end
is useless.)You never want to use
local ... in ... end
. Since its job is to isolate one set of helper declarations to a subset of your main declarations, this forces you to group those main declarations according to what they depend on, rather than perhaps a more desired order.A better alternative is simply to write a structure, give it a signature and make that signature opaque. That way, all internal declarations can be used freely throughout the module without being exported.
One example of this in j4cbo's SML on Stilts web-framework is the module StaticServer: It exports only
val server : ...
, even though the structure also holds the two declarationsstructure U = WebUtil
andval content_type = ...
.structure StaticServer :> sig val server: { basepath: string, expires: LargeInt.int option, headers: Web.header list } -> Web.app end = struct structure U = WebUtil val content_type = fn "png" => "image/png" | "gif" => "image/gif" | "jpg" => "image/jpeg" | "css" => "text/css" | "js" => "text/javascript" | "html" => "text/html" | _ => "text/plain" fun server { basepath, expires, headers } (req: Web.request) = ... end
-
2Just to add a little bit to the confusion, `let` is also available at the module-language level in Standard ML: https://gist.github.com/igstan/a5eee5b03db06e274a0331ab1e019be7. In SML/NJ, `local` is also available in the module language: https://gist.github.com/igstan/d014f0f2737571da69feda323d326052 – Ionuț G. Stan Sep 21 '16 at 20:34
The short answer is: local
is a declaration, let
is an expression. Consequently, they are used in different syntactic contexts, and local
requires declarations between in
and end
, while let
requires an expression there. It's not much deeper than that.
As @SimonShine mentioned, local
is often discouraged in favour of using modules.

- 34,518
- 3
- 61
- 72