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:

TargetExampleMeaning
a namex = 5bind or reassign a binding
an elements[2] = 99replace one element of a seq
a field or keym.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