Skip to content

Bindings and control flow

Two Kinds of Name

Safe expressions gave you a closed vocabulary of functions and said nothing about where a value could come from other than a literal. Here is the missing half. An expression can see names, and those names arrive from exactly two places.

Some come from the host, which supplies an environment when it asks for a value to be evaluated. A normalized animation time called t is the canonical case, and it is why this validates and evaluates even though nothing in the document defines t:

(group :name "fade"
(shape :sdf
:radius 0.5
:alpha (lerp 1 0 (clamp t 0 1))))

Open in playground →

The rest you create yourself with let, and this is the one place a SJON document introduces a name at all.

let Binds in Order

(let [a 1
b (+ a 2)
c (* a b)]
(+ a b c))

Open in playground →

The bindings are a flat vector of name/expression pairs, read left to right, and each one can see everything bound before it:

(let [ a 1 a is now 1
b (+ a 2) can see a -> b is 3
c (* a b) ] can see a and b -> c is 3
(+ a b c)) the body sees a, b, and c -> 7

Read that diagram backwards and you have the rule that catches people: a cannot see b. There is no mutual recursion, no forward reference, and no way to define something in terms of a name that comes later. That falls out of “no recursion” from Safe expressions, and it is a feature: a let you can read top to bottom is a let you can evaluate in your head.

if and cond

if picks one branch and evaluates only that one:

(if (> t 0.5) 1 0)

Open in playground →

cond takes test/value pairs, walks them left to right, and stops at the first test that is truthy, returning the value paired with it. Everything after that pair goes unevaluated:

(cond
(< x 0) -1
(> x 0) 1
true 0)

Open in playground →

x = -3 (< x 0) truthy -> -1 ; (> x 0) never runs
x = 4 (< x 0) falsy
(> x 0) truthy -> 1 ; the true branch never runs
x = 0 (< x 0) falsy
(> x 0) falsy
true truthy -> 0

That last row is why the literal true is the idiom for a default branch. Take it away and the third case falls off the end, and cond returns nil when no test is truthy. nil is a real value that will travel happily into whatever slot you put it in, so leaving the default off is a decision, not an oversight to be caught later.

Worked Example

Our fade again, with the clamp appearing once instead of being inlined:

(group :name "fade"
(shape :sdf
:radius 0.5
:alpha (let [phase (clamp t 0 1)]
(lerp 1 0 phase))))

Open in playground →

Both versions compute the same number. The second one names the intermediate, which is worth doing the moment the expression stops fitting on one line.

There is a limit, and I want to name it rather than let you discover it at 200 lines of let. SJON expressions are for a little arithmetic that belongs in the document: a fade, a margin derived from a width, a colour derived from an index. When the logic stops feeling like “a little safe math”, the right move is to lift it into your host language and pass the result in as data. A document that has grown a program inside it has lost the property that made it worth reading.

Exercises

Write an alpha fade that goes from transparent to opaque:

(shape :sdf
:alpha (lerp 0 1 (clamp t 0 1)))

Open in playground →

Now invert it, changing exactly one thing:

(shape :sdf
:alpha (lerp 1 0 (clamp t 0 1)))

Open in playground →

Use let so the clamp is written once and the pulse is named:

(shape :sdf
:radius (let [phase (clamp t 0 1)
pulse (lerp 0.8 1.2 phase)]
(* 20 pulse)))

Open in playground →

Repair the binding vector:

(let [a 1 b] (+ a b))

Open in playground →

The vector holds name/expression pairs, and this one has three elements, so b has no value. Give it one:

(let [a 1
b (+ a 2)]
(+ a b))

Open in playground →

Repair the missing default:

(cond
(< x 0) -1
(> x 0) 1)

Open in playground →

This is valid and returns nil when x is zero. If a zero case was intended, say so:

(cond
(< x 0) -1
(> x 0) 1
true 0)

Open in playground →

Mastery Check

  1. Can a later let binding refer to an earlier one?

  2. Can an earlier let binding refer to a later one?

  3. What supplies t in an expression like (clamp t 0 1)?

  4. When should expression logic move out of SJON?