Bindings and Scope
J2 makes immutability the default and mutation a visible choice. The two binding operators are the quiet backbone of the language; the same pair later distinguishes pure methods from mutating ones.
Constants and mutables
A name bound with = is a constant. A name bound with := is
mutable and may be reassigned.
rate = 0.07 # constant
total := 0.0 # mutable
total = total + 100.0 * (1.0 + rate)
print(total) # 107
Reassigning a constant raises MutabilityError:
limit = 10
limit = 11 # MutabilityError: "limit" is a constant and cannot be reassigned
Once a name is mutable, later plain assignments with = update it and it
stays mutable. Reach for = first when writing new code; a program tends to
read better the fewer := bindings it has, and the compiler can reason more
freely about code that mutates less.
Compound assignment
Mutable numeric bindings support +=, -=, *=,
/=, and %=, along with increment ++ and decrement
--.
n := 10
n += 5 # 15
n *= 2 # 30
n /= 4 # 7.5 (division follows the usual rule)
count := 0
count++
count++
count--
print(fmt("n={} count={}", n, count)) # n=7.5 count=1
Compound forms apply only to bare names. Updating an element goes through a full
assignment instead: write s[i] = s[i] + 1, not s[i] += 1.
Assignment targets
Three kinds of targets can appear on the left of an assignment:
| Target | Example | Meaning |
|---|---|---|
| a name | x = 5 | bind or reassign a binding |
| an element | s[2] = 99 | replace one element of a seq |
| a field or key | m.role = "admin" | set a map key or an instance field |
Element and field assignment mutate the underlying seq, map, or instance, which is visible through every reference to it; see Sequences, Maps, and Flows.
Shadowing
A binding introduced inside a function may reuse a name from the surrounding program. The inner binding shadows the outer one for the rest of the function; the outer binding is untouched. Note that this means assigning to a global name inside a function creates a fresh local rather than mutating the global:
g = 100
func shadows() = {
g = 5 # a new local constant, unrelated to the global g
give g
}
func reads_global() = g + 1
print(shadows()) # 5
print(reads_global()) # 101
print(g) # 100, unchanged
What a function can see
Scope in J2 is simple enough to state completely. A function body sees, in order of precedence:
- its own parameters and local bindings,
- bindings made at the top level of the program before the call,
- the built-in functions and modules.
A function never sees the local variables of its caller. There is no
nonlocal chain to reason about: it is locals, then globals, then
builtins.
threshold = 10
hits := 0
func over(x) = x > threshold # reads the global constant
print(over(12)) # true
print(hits) # 0
Lambdas are the one refinement: a lambda snapshots the bindings it references at the point of definition, so it can carry local state outward. That behavior belongs to Functions.
Block scope
Names introduced inside a block (a loop body, an if arm) end with the
block. A loop body gets a fresh scope on every iteration, so a binding made inside the
body does not leak from one iteration to the next; accumulate across iterations by
declaring the mutable binding before the loop:
total := 0
for x in [1, 2, 3] {
doubled = x * 2 # fresh each iteration
total += doubled
}
print(total) # 12