Variables & mutability
name = "Ecko" # bare assignment: an immutable binding
# name = "Echo" # error: 'name' is immutable
mut count = 0 # mutable, because you said so
count = count + 1
let ratio = 3.14 # immutable, the same as a bare assignment
One rule: immutable unless mut
| form | reassignable |
|---|---|
x = v | no |
let x = v | no |
mut x = v | yes |
A value changes only where you wrote mut, so a reader can see every binding that moves. That covers writing through a path too: m.key = v and xs[0] = v change m and xs, so those need mut as well. Parameters and loop variables are immutable; to change a parameter, start the body with mut n = n.
Redeclaring still works: let x = ... or mut x = ... makes a new binding that shadows the old one. A function can write a top-level binding only when that binding is mut. _ is the discard, and _ = f() can be written as often as you like.
ecko check refuses a reassignment of an immutable binding before the program starts (immutable-reassign), and ecko fix --migrate --only=mut adds the mut for you where the binding is declared.
Compound assignment
mut total = 0
for n in [3, 4, 5] {
total += n
}
print(total) # 12
x += e is shorthand for x = x + e, and -=, *=, /=, //= and %= work the same way. Because it is the long form, the same rules apply: the binding must be mut, and += on a string needs a string. It also works through paths - m.hits += 1, xs[i] *= 2 - as long as the index or object is a plain name, literal or field. xs[next()] += 1 is refused, since the long form would call next() twice; bind it first.
const is a deprecated alias for let - identical behaviour. ecko check warns and ecko fmt rewrites it.
Function bindings are immutable
fn f() = 1
f = 2 # error
let f = fn() 2 # legal: an explicit redeclaration
Same for built-ins - except that assigning to a built-in name shadows it with a new (immutable) binding in the current scope rather than erroring:
sum = 0 # fine anywhere; the builtin `sum` is untouched here
ecko check warns when you shadow a built-in that the file also calls, because that is usually accidental. When it is deliberate - a module that exports get or join - reach the original through core.get, core.join and so on.
A name bound by import is shadowed the same way. Assigning to it makes a new binding in the current scope and leaves the module alone everywhere else:
import std.json
fn build() {
json = "text" # a local of build(); ecko check warns
json
}
print(build()) # text
print(json.encode([1])) # [1] - the import is untouched
ecko check reports this as shadows-import, because a later json.encode in the same function fails with "Can't access field 'encode' of string". Pick another name, or write let json = ... if the shadow is what you want.
Shadowing and scope
A block introduces a scope. An inner binding shadows an outer one for the rest of the block and does not affect it afterwards.
Closures capture their defining scope, so they observe later updates and can mutate an outer mut:
fn make_counter() {
mut n = 0
fn() {
n = n + 1
n
}
}
The captured scope stays alive after the defining call returns, which is what makes the counter work.
The one exception: parallel workers
A closure running in pmap, an async task or an HTTP handler gets a snapshot of captured variables. Mutating a captured mut changes only that call's copy:
mut count = 0
pmap(items, fn(i) { count = count + 1 })
print(count) # still 0
That is share-nothing by construction - it is why data races are not a category of bug here. When workers must share, use a cell.
No silent nulls
Reading a name that was never defined is an error found by ecko check before the program starts, not a null that propagates until something else breaks. The same strictness applies to collection access.