05 — Immutability: let vs var
MiniLang distinguishes let (immutable binding) from var (mutable
binding). This distinction is enforced by the interpreter after the
resolver has already annotated depths.
Tracking mutability in the environment
The environment stores a mutability flag alongside each value:
struct Slot {
Value value;
bool mutable_;
};
std::unordered_map<std::string, Slot> vars_;
define stores the slot:
void Environment::define(const std::string& name, Value v, bool mutable_) {
vars_[name] = {std::move(v), mutable_};
}
set / setAt checks the flag:
void Environment::setAt(int depth, const std::string& name, Value v) {
Environment* e = this;
for (int i = 0; i < depth; ++i) e = e->parent_.get();
auto& slot = e->vars_.at(name);
if (!slot.mutable_)
throw RuntimeError("Cannot reassign 'let' binding '" + name + "'.");
slot.value = std::move(v);
}
The assignment check
When the interpreter visits an assignment expression:
Value Interpreter::visitAssign(AssignExpr& e) {
Value v = evaluate(*e.value);
assignVariable(e, v);
return v;
}
assignVariable → setAt → immutability check. If the target was bound
with let, a RuntimeError is thrown with a clear message. This is a
runtime check, not a static one.
Why not static?
Making immutability a static error (checked by the resolver) would require
tracking whether each name was declared as let or var in the scope
stack. That's doable — the scope map could store {bool defined, bool mutable}.
The choice here is pragmatic: static checking is strictly better for user experience (error before running), but it requires threading the mutability flag through two more data structures. For the curriculum, a runtime check demonstrates the concept clearly. cp-05 introduces the type-checker pass which is a static pass and shows how static checks are structured.
Shadowing across scopes
let x = 1;
{
var x = 2; // OK — new binding in inner scope, different slot
x = 3; // OK — this x is mutable
}
print x; // 1 — outer let x unchanged
Each let/var creates a new slot in its scope. Shadowing is allowed:
a var x in an inner scope doesn't make the outer let x mutable.
The resolver assigns separate depths to each, so the inner assignment
never reaches the outer slot.
The let design in practice
In real languages:
- Rust:
letis immutable,let mutis mutable. - JavaScript:
constis immutable,letis mutable (confusingly opposite to MiniLang). - Swift:
letis immutable,varis mutable (same as MiniLang). - Haskell: Everything is
let-bound and immutable by default.
MiniLang follows Swift/Rust semantics. The pedagogical point is that
immutability is a property of the binding, not the value. A let
binding to a mutable array still allows mutating the array's contents;
it prevents rebinding the name to a different array.