Control Flow & Error Handling
Conditionals, variable binding, lambdas, error handling, null semantics, and higher-order functions in Rayfall.
Conditionals: if
The if special form evaluates a condition and returns the corresponding branch:
Without an else branch, if returns 0:
More condition/result pairs chain: conditions are tested in order, the result paired with the first truthy one is evaluated, and an odd trailing argument is the fallback. Conditions after the first truthy one, and every other result, are never evaluated:
‣ (set classify (fn [x] (if (> x 100) "high" (> x 50) "mid" "low")))
‣ (classify 60)
"mid"
‣ (if false 1 false 2)
0
Inside a query (a projection, where:, a by: key), a chain with a fallback is evaluated row by row like the three-argument form, and a text literal in it becomes a symbol cell; a chain without a fallback is left to the evaluator.
Sequential Evaluation: do
do evaluates expressions in order and returns the last result. At the top
level, interpreted do gives let bindings a block scope. In a compiled
function body, do runs inline and a let can remain visible afterward:
For the full name lookup rules, see Names and scope.
Iteration: while
while evaluates cond, and while it is truthy evaluates each body expression
in order, then tests again. It always returns null — it is a statement form,
run for effect.
It is the only iteration form that can stop early. map, fold, scan and
prior all consume their whole input, so a "repeat until done" loop written as
a fold over a fixed range pays that range's full length on every call, however
early the work finishes. while stops when the condition says stop, allocates
no range, and does not recurse — so it is not bounded by the stack depth a
recursive loop would hit.
The body may be omitted, in which case a condition with side effects is the whole loop. That is the natural shape when there is no sequence to iterate over in the first place:
Unlike do, while pushes no scope of its own. A let in the body binds in
the enclosing frame and therefore survives the iteration, which is what makes a
let usable as a loop variable inside a lambda:
When a fresh binding per pass is wanted instead, wrap the body in do, which
does push a scope:
A loop whose condition never goes false runs until interrupted; Ctrl-C breaks out of one at the REPL.
Bounded Iteration: times
times runs the body a fixed number of times and returns null.
The count is evaluated once, on entry, so the bound is fixed however the body mutates whatever produced it:
A count of zero or less runs the body zero times rather than raising — a bound
that computes to empty is a no-op, not an error. A non-integer count is a type
error. Like while, times pushes no scope of its own, so a let in the body
binds in the enclosing frame; wrap the body in do when a fresh binding per
pass is wanted.
To loop over positions, give times a [name count] binding instead of a bare
count. The name takes 0, 1, …, count - 1, one value per pass:
The index is bound like a let, in the enclosing frame, and keeps the last
index once the loop ends; a loop that runs no pass leaves it as it was. It is
set from the loop's own counter at the start of every pass, so a body that
rebinds the name changes it for the rest of that pass only, never the passes
that follow. The count follows the rules above: evaluated once, an integer,
nothing run when it is zero or less.
Reach for times when the number of passes is known up front and for while
when it is not. Neither allocates a sequence, so neither pays for a range that
exists only to be counted.
Variable Binding: set and let
set creates a global binding. let creates a local binding scoped to the enclosing do:
The variable y is not visible outside the do block.
Lambda Functions: fn
Create anonymous functions with fn. Parameters are listed in square brackets:
Recursive lambdas use self to refer to the enclosing function:
self is also a value: (map self node) recurses over a nested list. Inside a closure it is the closure itself, not the function that made it.
Error Handling: try / raise
raise throws an error with an arbitrary value. try catches it and passes the value to a handler function:
‣ (try (raise 42) (fn [e] e))
42
‣ (try (raise 42) (fn [e] (+ e 1)))
43
‣ (try (raise "boom") (fn [e] "caught"))
"caught"
An error the engine raises itself reaches the handler as a dict of its code and message:
If no error is raised, try returns the result of the body expression normally. Works inside lambdas compiled to bytecode.
Fallback value
If the second argument is not a function, it is returned as-is as the fallback value on error (evaluated only when the body fails):
A handler must accept the single error argument, so only a lambda or a unary builtin is called with the error; any other value (including a multi-argument builtin) is treated as a fallback value.
Nested lambdas capture visible lexical bindings when they are created:
Inside the closure its captures are locals: a let of x rebinds it for that call only, and a capture named like a builtin is the callee. See Lambdas & the VM for how closures compile.
Early Return: return
return exits the innermost enclosing lambda early with the given value, whether it runs as bytecode or on the tree-walking interpreter:
The zero-arg form returns null:
return works inside (try ...): the trap frame is unwound cleanly before the lambda exits.
return only exits the lambda it is lexically inside — not any outer lambdas. There is no non-local return.
Null Semantics
Nulls are sentinel-encoded in the payload (INT64_MIN for i64, NaN for
f64, and so on), not a separate bitmap. Typed null literals produce the
sentinel for their type:
| Literal | Type |
|---|---|
0Nl |
i64 null |
0Nf |
f64 null |
0Ni |
i32 null |
0Nh |
i16 null |
0Nd |
date null |
0Nt |
time null |
0Np |
timestamp null |
Null rules:
nil?checks for null:(nil? 0Nl)→true- All null forms are falsy in
if - All null forms are equal via
==:(== 0Nl 0Nl)→true - Typed nulls propagate through arithmetic:
(+ 0Nl 1)→0Nl printlnandshowreturn null (not printed in the REPL)
Higher-Order Functions
Lambdas are auto-mapped over vectors when called directly. Use map for explicit element-wise application, fold for reductions, and scan for running accumulations:
;; map applies a function to each element; same-type atom results
;; form a typed vector, anything else a list
‣ (map (fn [x] (* x x)) [1 2 3 4 5])
; => [1 4 9 16 25]
;; lambdas auto-map over vectors
‣ ((fn [x] (* x x)) (til 5))
; => [0 1 4 9 16]
;; fold reduces a vector with a binary function
‣ (fold + 0 (til 10))
; => 45
;; scan produces running accumulations
‣ (scan + [1 2 3 4 5])
; => [1 3 6 10 15]
;; fold-while stops as soon as the running result fails the predicate,
;; leaving the rest of the collection untouched
‣ (fold-while (fn [acc] (< acc 100)) + 0 (til 1000))
; => 105
;; where returns indices matching a condition
‣ (where (> (til 10) 3))
; => [4 5 6 7 8 9]
map, fold, scan and prior all consume their whole input. fold-while
is the one member of the family that can stop: the accumulator is offered to
the predicate before each step, and a falsy answer ends the fold and yields the
accumulator as it stands. The test happens before the first element too, so a
predicate that is false at the start returns the initial value untouched.
Elements are pulled one at a time, so a fold that stops after three steps costs
three elements however long the collection is.