Quirrel4.41.0

Bindings and constants

Quirrel has four ways to bind a name: local, let, const and enum. All four are scoped to the block where they are written, from the declaration to the end of that block.

let and local

local declares a variable that can be reassigned. let declares a binding that cannot be reassigned. After its one initializing assignment, a later write to it is a compile error. Use let by default. Use local only for a variable that you intend to reassign, such as a counter or an accumulator.

A fixed let binding says nothing about the object it names. let ammoBelt = [] still allows ammoBelt.append(...) afterwards. Only ammoBelt = otherArray is rejected. freeze locks the object itself; see below.

let name with no initializer forward-declares the binding. Exactly one later statement in the same block must define it. This lets two closures capture each other before either is assigned.

examples/pages/language/bindings-basic.nut
local ammoLeft = 30     // local can be reassigned
let magazineSize = 30   // let cannot: it names one value for good

ammoLeft -= 12
println("ammoLeft =", ammoLeft, "magazineSize =", magazineSize)

// magazineSize = 40  // compile error: can't assign to binding 'magazineSize'
Output:
ammoLeft = 18 magazineSize = 30

local is a variable in the usual sense. let names a value once and rejects a second assignment. Most bindings never change, and let states this for both the reader and the compiler. This is why let is the default choice.

examples/pages/language/bindings-let-local.nut
local reloadSeconds = 4.0             // reassignable: ticks down every frame
let maxReloadSeconds = reloadSeconds  // fixed once, read back below unchanged

function tick(dt) {
  reloadSeconds -= dt
  if (reloadSeconds < 0) reloadSeconds = 0
}

tick(1.5)
tick(1.0)
println("reloadSeconds =", reloadSeconds)
println("maxReloadSeconds =", maxReloadSeconds)

// the binding is fixed, not the object it names: a let still allows this
let ammoBelt = []
ammoBelt.append("ap_round")
ammoBelt.append("he_round")
println("ammoBelt.len() =", ammoBelt.len())
Output:
reloadSeconds = 1.5
maxReloadSeconds = 4
ammoBelt.len() = 2

A binding can also carry a declared type - see Type annotations.

Reading a forward-declared let before its definition runs is a compile error, and so is a second assignment:

let target
println(target)  // error: binding 'target' cannot be used before its definition
target = "tank"
target = "plane" // error: a 'let' binding accepts a single assignment

Forward declaration

A let can be declared with no value and defined later, on its own line. This is the only way to write two functions that call each other: the function written first would otherwise name something that does not exist yet.

examples/pages/language/bindings-forward.nut
// two functions that call each other: neither can be written second
let advanceSquad
let holdPosition

advanceSquad = function(steps) {
  return steps <= 0 ? "arrived" : holdPosition(steps - 1)
}
holdPosition = function(steps) {
  return steps <= 0 ? "dug in" : advanceSquad(steps - 1)
}

println("advanceSquad(4) =", advanceSquad(4))
println("advanceSquad(3) =", advanceSquad(3))

// reading one before its definition is a compile error, not a null
let pickTarget
pickTarget = @(squad) squad + " engaged"
println("pickTarget(alpha) =", pickTarget("alpha"))
Output:
advanceSquad(4) = arrived
advanceSquad(3) = dug in
pickTarget(alpha) = alpha engaged

The compiler tracks the definition, and each mistake has its own message:

MistakeWhat the compiler says
declared but never definedforward declaration 'name' is never defined
read before its definitionbinding 'name' cannot be used before its definition
defined twicebinding 'name' is already defined; a 'let' binding accepts a single assignment (declare it with 'local' to allow more)

The last message shows the difference from local: a forward declared let still accepts exactly one assignment, so it stays a binding that never changes after it is set. Use local when the value must be reassigned.

const and global const

const binds a compile-time value: a number, string, null, or a table or array nested from those. Every place the name is used, the compiler substitutes the value directly. There is no variable, no stack slot and no lookup left at runtime. A function can be const too, if it captures no outer variable.

Plain const is lexical only and leaves no trace at runtime; not even getroottable sees it. global const also writes the name into the shared consttable, reachable through getconsttable. This is how a constant is made visible beyond the file that declares it.

examples/pages/language/bindings-const.nut
const maxSquadSize = 8            // folded into every use site below, no lookup
global const gameVersion = "1.12" // also written into the shared consttable

println("maxSquadSize =", maxSquadSize)
println("gameVersion =", gameVersion)

// the fold is otherwise invisible, but its absence from consttable is not
println("getconsttable() has maxSquadSize =", "maxSquadSize" in getconsttable())
println("getconsttable() has gameVersion =", "gameVersion" in getconsttable())
Output:
maxSquadSize = 8
gameVersion = 1.12
getconsttable() has maxSquadSize = false
getconsttable() has gameVersion = true

What the compiler will evaluate

The value is not limited to a literal. Anything the compiler can evaluate on its own is allowed, and the result is substituted:

A const may beExample
a simple valueconst MAGAZINE = 30
a table or array of simple values, nestedconst LOADOUT = { belts = [1, 2, 3] }
another constant, or a field reached from oneconst SECOND = LOADOUT.belts[1]
an arithmetic, logical or conditional expressionconst RESERVE = MAGAZINE * 4
a call to a pure functionconst CAP = max(MAGAZINE, 25)
a function declaration that captures nothingconst function armorAt(angle) { ... }
examples/pages/language/bindings-const-folding.nut
from "math" import max

const MAGAZINE = 30
const RESERVE = MAGAZINE * 4            // arithmetic on another const
const LOADOUT = { weapon = "ak74", belts = [1, 2, 3] }
const SECOND_BELT = LOADOUT.belts[1]    // reaching into a const container
const CAP = max(MAGAZINE, 25)           // a pure function, run by the compiler
const GRADE = MAGAZINE > 10 ? "rifle" : "pistol"

println("RESERVE =", RESERVE, "SECOND_BELT =", SECOND_BELT)
println("CAP =", CAP, "GRADE =", GRADE)

// const RANDOM = rand()
// error: Only calls to pure functions are allowed in constant expressions
Output:
RESERVE = 120 SECOND_BELT = 2
CAP = 30 GRADE = rifle

max(MAGAZINE, 25) is not called at runtime. The compiler runs it once while compiling and stores the result. This is what the pure attribute is for, and the compiler enforces it. A call to a function not marked pure is rejected with Only calls to pure functions are allowed in constant expressions, so const RANDOM = rand() does not compile.

Because the substitution happens at compile time, a const costs nothing to read, cannot be reassigned by any code, and is available in places a runtime value is not: inside another const, or as an enum member's value. The cost is that its value must be known without running the program, and that a change to it requires recompiling every file that used it.

enum and global enum

enum groups related constants under one name, accessed as Enum.member. A member with no = gets an integer automatically. A member with = takes an integer, a float or a string literal. Like const, a plain enum is lexical only; global enum also goes into the consttable.

The counter for automatic values starts at 0 and advances only when it is used. It counts only the automatic members, in their own order, and ignores any explicit value written between them.

examples/pages/language/bindings-enums.nut
enum AmmoType {
  ap = 10,
  he,               // NOT 11: an auto value counts only its own kind, from 0
  sabot,            // the second auto value, so this is 1, not 12
  designation = "smoke"
}

println("AmmoType.ap =", AmmoType.ap)
println("AmmoType.he =", AmmoType.he)
println("AmmoType.sabot =", AmmoType.sabot)
println("AmmoType.designation =", AmmoType.designation)
println("type(AmmoType.he) =", type(AmmoType.he))
println("type(AmmoType.designation) =", type(AmmoType.designation))
Output:
AmmoType.ap = 10
AmmoType.he = 0
AmmoType.sabot = 1
AmmoType.designation = smoke
type(AmmoType.he) = integer
type(AmmoType.designation) = string

Scope and shadowing

A local, let, const or enum lives from its declaration to the end of its own block, as in most languages. The difference is this: within one function, a nested block cannot declare a new binding under a name already used earlier in that function, by any of the four kinds, even a name from a block that has already closed. The compiler rejects it as a conflict; it does not shadow the name.

Two independent (non-nested) blocks can each use the same name, since neither is inside the other. A function body is its own scope regardless of nesting, so a parameter can reuse a name from every enclosing scope without conflict. This is the only place ordinary shadowing happens.

examples/pages/language/bindings-scope.nut
function announce(range) {
  println($"contact at {range}m")
}

{
  let range = 400
  announce(range)
}
{
  let range = 250   // a disjoint sibling block: reusing the name is fine here
  announce(range)
}

let range = 999
function closest(range) {   // a function body is its own scope, not a nested block
  return range
}
println("closest(10) =", closest(10))
println("range =", range)
Output:
contact at 400m
contact at 250m
closest(10) = 10
range = 999

Nesting the same name one block deeper is rejected, for any kind of declaration:

let squadSize = 4
{
  let squadSize = 8  // error: conflicts with existing local variable
}

freeze

freeze marks a table, array, instance, class or userdata as immutable and returns a reference to it. The immutable flag lives on that reference, not on the object. Any other reference to the same object made before the freeze - a variable it was copied to, a value already stored elsewhere - still writes through normally, because it was never marked. Content is shared, so a write through such a reference is visible from the frozen one too. Only the reference returned by freeze refuses to write.

examples/pages/language/bindings-freeze.nut
let turretConfig = { turnSpeed = 45, elevation = 20 }
let sharedRef = turretConfig       // alias made before freeze
let locked = freeze(turretConfig)  // a new, immutable reference to the same table

try { locked.turnSpeed = 90 } catch (e) { println("locked.turnSpeed = 90 throws:", e) }
sharedRef.turnSpeed = 90             // still allowed: sharedRef was never frozen
println("locked.turnSpeed =", locked.turnSpeed) // visible here too: they name the same table
Output:
locked.turnSpeed = 90 throws: trying to modify immutable 'table'
locked.turnSpeed = 90

Assign the result back over the original name (t = freeze(t)) when every access to an object must go through a frozen reference. A separate mutable alias defeats the freeze.