Functions are values in SCUA. You can declare them with a name, write one inline, return one from another function, and pass one as an argument. A function written inside another captures the variables around it, which is what makes closures work.
fn greet(name)
return `hello, {name}`
end
print(greet("Ed"))
Run it:
$ scua greet.scua
hello, Ed
#Declaring functions
A named function is fn name(params) ... end. Use
return to hand back a value. A function with no
return, or one that falls off the end, gives back
nil.
The body is a block, so any let you make inside is local
to the call.
#Default values for parameters
A parameter can carry a default, written = value after
its name. A call may then leave that argument out:
fn greet(name, greeting = "Hello")
return `{greeting}, {name}!`
end
print(greet("Ada"))
print(greet("Ada", "Hey"))
Run it:
$ scua greet.scua
Hello, Ada!
Hey, Ada!
The rule is one sentence: a default fills the argument when
it is missing or nil. So a parameter with a
default is never nil inside the body, and a value that
might be missing selects the default without an if at the
call site:
let cfg = { }
fn connect(host, port = 8080)
return `{host}:{port}`
end
print(connect("db", cfg @ "port")) -- db:8080 — the config has no port
Only nil triggers a default. false and
0 are values, so they are passed through:
fn show(label, enabled = true)
return `{label}: {enabled}`
end
print(show("sound", false)) -- sound: false
If you need a parameter that can genuinely be handed nil
— where "no value" and "the value nil" mean different things — don't
give it a default.
#Defaults come last
Every parameter after a defaulted one must have a default too. SCUA has no named arguments, so there's no way to skip an argument and supply a later one; a default in front of a required parameter could never be used, and the compiler says so:
parameter 'b' has no default, but a parameter before it does — with no named
arguments a default is only reachable if every parameter after it also has one
#Defaults are evaluated on each call
A default is an ordinary expression, and it runs on the calls that leave the argument out — not once when the function is declared. That means a fresh value every time:
fn collect(item, into = [])
into.push(item)
return into
end
print(len(collect("a")))
print(len(collect("b")))
Run it:
$ scua collect.scua
1
1
If the default were made once and shared, the second call would print
2. It doesn't. (This is the same trap Python's
def f(xs=[]) is famous for.)
Defaults are evaluated left to right, in the function's own frame, so a later default can use an earlier parameter:
fn window(center, radius = 1, lo = center - radius, hi = center + radius)
return `{lo}..{hi}`
end
print(window(10)) -- 9..11
print(window(10, 5)) -- 5..15
A default can only name parameters before it — the later
ones haven't been filled in yet, and naming one is an error rather than
a silent nil.
#Defaults or an options record?
Both express "this argument is optional". The rough line:
- Up to about three optional scalars — use defaults.
spawn(kind, count = 1, hp = 10)reads like tuning data, which is what it is. - An open-ended bag of knobs — take a trailing
record, the way
fs.find(path, { recursive = true })does. Defaults are positional, so setting the fourth one means typing the second and third; past a few options that stops being pleasant.
#One difference from record fields
A record field default (see Records
and types) fills a field that is missing, and an
explicit nil is kept:
record Cfg { host: any = "localhost" }
let c: Cfg = { host = nil }
print(c.host) -- nil
A parameter default fills a missing or nil argument, so the same input gives you the default instead. The difference is real and worth remembering: you can ask a record whether a field is there, but a parameter always holds a value once the call starts, so there is nothing to distinguish.
#Functions are values
You can write a function without a name and bind it to a variable.
fn(params) ... end is an expression that evaluates to a
function value:
let square = fn(x) return x * x end
print(square(9))
Run it:
$ scua square.scua
81
A named declaration and a binding to a function value are close to interchangeable. Reach for the inline form when you're passing a function somewhere or building one on the fly.
#Closures
A function written inside another captures the surrounding variables by reference, not by copy. The inner function keeps working even after the outer call that created it has returned, and it sees the latest value of what it captured.
A counter makes this concrete. Each call to make_counter
creates a fresh n, and the returned function closes over
it:
fn make_counter()
let n = 0
return fn()
n = n + 1
return n
end
end
let next = make_counter()
print(next())
print(next())
print(next())
Run it:
$ scua counter.scua
1
2
3
The n outlives the call to make_counter.
Each call to next updates the same captured
n.
Two closures can capture the same variable, and then they share it. Here one closure writes and the other reads, and the reader sees the writer's updates:
let total = 0
let add = fn(amount) total = total + amount return total end
let peek = fn() return total end
add(10)
add(5)
print(peek())
Run it:
$ scua shared.scua
15
Both add and peek close over the same
total, so peek reports what add
accumulated. A top-level let is shared with named functions
in the same file the same way; see Variables.
#Recursion
A named function can call itself:
fn fact(n)
if n <= 1 then return 1 end
return n * fact(n - 1)
end
print(fact(5))
Run it:
$ scua fact.scua
120
#Passing functions as arguments
Because functions are values, a function can take another function as a parameter and call it. This is how higher-order helpers like map and filter work.
fn apply_twice(f, x)
return f(f(x))
end
print(apply_twice(fn(n) return n + 3 end, 10))
Run it:
$ scua higher-order.scua
16
The built-in list module leans on this for
map, filter, reduce, and friends.
See Collections.
#Returning several values
A function can return more than one value. List them after
return, separated by commas, and bind them on the other
side with a matching let list:
fn div_mod(a, b)
return a // b, a % b
end
let q, r = div_mod(17, 5)
print(`17 = {q} * 5 + {r}`)
Run it:
$ scua div-mod.scua
17 = 3 * 5 + 2
This is the natural shape for "a result and a flag" — a lookup that returns both what it found and whether it found anything:
fn find(items, want)
for i, x in items do
if x == want then return i, true end
end
return -1, false
end
let idx, ok = find([10, 20, 30], 20)
print(`found at {idx}? {ok}`)
$ scua find.scua
found at 1? true
#How the two sides line up
The left side has a fixed number of names; the right side produces some number of values. SCUA matches them up the same way Lua does:
- Too few values — the leftover names get
nil.let a, b = f()wherefreturns one value leavesbasnil. - Too many values — the extras are dropped.
let only = div_mod(9, 4)keeps just the first value.
#Parallel assignment
The same comma form assigns to variables that already exist. The whole right side is evaluated before anything is written, so you can swap without a temporary:
let a = "left"
let b = "right"
a, b = b, a
print(`{a} {b}`)
$ scua swap.scua
right left
The targets aren't limited to plain variables — a field or an array
element works too: pos.col, pos.row = 3, 4.
#Giving the returns types
Like every type in SCUA, the return is optional — but you can declare
it. A multi-value return is written -> (T, U):
fn div_mod(a: int, b: int) -> (int, int)
return a // b, a % b
end
let q, r = div_mod(17, 5)
let label: string = q
The checker then verifies the function actually returns two
ints, and — because the signature flows to the call site —
types q and r as int, so the
misuse on the last line is caught:
$ scua div-mod-typed.scua
scua: div-mod-typed.scua:5: type error: value is int, but the binding is declared string
A wrong slot type (return 1, "two" against
-> (int, int)) or the wrong number of values is reported
the same way. As always the annotation is erased before the program runs
— it changes what the checker accepts, not the behaviour. Leave it off
and the values are simply untyped (any), exactly like an
unannotated single return.
#The one rule to remember
A call expands into multiple values at the tail of a binding
or assignment — let x, y = f() or
x, y = f() — and in a return:
return f() hands back everything f returned,
so wrapping a multi-value function needs nothing special.
fn div_mod(a, b) return a // b, a % b end
fn wrapped(a, b) return div_mod(a, b) end
let q, r = wrapped(20, 6)
print(`{q} remainder {r}`)
$ scua wrap.scua
3 remainder 2
Anywhere else a call gives just its first value, so
print(div_mod(20, 6)) prints only the quotient. When you
need every value in one of those positions, bind them first and use the
names.
(If you'd rather hand back a single structured thing, returning an
array or a table still works — return [lo, hi] — and reads
back with indexing or field access.)
#Typed functions
Everything above works with no annotations at all. When you want a
compile-time check, you can give a function's parameters and return
value types, and you can type a binding or field as a function with a
signature like fn(int) -> int. The checker then verifies
that calls pass the right argument types and that a function value
matches the signature it's assigned to. Annotations are erased before
the program runs, so they change what the checker accepts, not how the
code behaves. See Records and gradual
types for the details.