SCUA

Manual

Variables: let and const

You bind a name to a value with let or const. A let can be reassigned; a const cannot, and the value it holds is frozen, so it can't be changed either. Both are block-scoped, and both can be declared at the top level of a file where functions in the same file can see them.

let score = 10
score = score + 5
print(score)

const MAX_HP = 100
print(MAX_HP)

Run it:

$ scua basics.scua
15
100

#let is reassignable

A let binding can be assigned a new value as many times as you like. Assignment uses the name on its own, without let:

let score = 10
score = score + 5

Use const for a value that should never change after it's set. By convention SCUA programs name constants in upper case, but that's a habit, not a rule.

#const is immutable

Reassigning a const is a compile error, caught before the program runs:

const MAX_HP = 100
MAX_HP = 120

Run it:

$ scua const-error.scua
scua: const-error.scua:2: cannot assign to const 'MAX_HP'

The error names the line and the binding.

#const freezes the value

A const fixes the value as well as the name. A table, array or record held in a const is frozen: its fields and elements can't be changed, added or removed, and the same goes for every table and array inside it. A write the compiler can see is a compile error:

const CONFIG = { speed = 4, tags = ["fast"] }
CONFIG.speed = 5

Run it:

$ scua const-frozen.scua
scua: const-frozen.scua:2: `CONFIG` is a frozen constant, so it can't be changed: `const` in SCUA freezes the value, not just the name. Use `let` for a value you change

A write the compiler can't see, such as one inside a function that was handed the constant, stops the program when it runs. The message still names the constant:

const CONFIG = { speed = 4 }

fn tweak(c)
  c.speed = 5
end

tweak(CONFIG)

Run it:

$ scua const-fault.scua
scua: const-fault.scua:4: `CONFIG` is a frozen constant, so it can't be changed: `const` in SCUA freezes the value, not just the name. Use `let` for a value you change
  at tweak (const-fault.scua:4)
  at main (const-fault.scua:7)

If you come from JavaScript, this is the difference to remember: const xs = [] followed by xs.push(1) is an error in SCUA. Use let for a list you add to.

A const holds a frozen copy. The value you gave it is left alone, so another name that refers to the same table can still change it, and the constant doesn't see the change:

let settings = { volume = 3 }
const DEFAULTS = settings
settings.volume = 9
print(DEFAULTS.volume, settings.volume)

Run it:

$ scua const-copy.scua
3 9

This is true wherever the const is written, at the top of a file or inside a function.

#Build a table, then freeze it

To make a constant you fill step by step, build it in a let, then freeze it with freeze. freeze(value) returns a frozen copy:

let by_name = {}
for item in [{ name = "sword", damage = 5 }, { name = "bow", damage = 3 }] do
  by_name[item.name] = item
end
const BY_NAME = freeze(by_name)
print(BY_NAME.sword.damage)

Run it:

$ scua build-then-freeze.scua
5

Filling the const directly is refused, and the error shows that fix:

const BY_NAME = {}
for item in [{ name = "sword", damage = 5 }] do
  BY_NAME[item.name] = item
end

Run it:

$ scua fill-const.scua
scua: fill-const.scua:3: `BY_NAME` is frozen, so it can't be filled after it's declared. Build it as a `let`, then freeze it: `let by_name = {}` … `const BY_NAME = freeze(by_name)`

A module's top level can't hold a let, so a module builds the table in a function and keeps what the function returns. This works in the main script too. A const holds a frozen copy of whatever it's given, so the function needs no freeze:

fn build_by_name()
  let by_name = {}
  for item in [{ name = "sword", damage = 5 }, { name = "bow", damage = 3 }] do
    by_name[item.name] = item
  end
  return by_name
end
const BY_NAME = build_by_name()
print(BY_NAME.bow.damage)

Run it:

$ scua build-in-a-function.scua
3

Inside a module, the error for a filled const names this fix instead of the let. See State in a module.

#A constant holds data

A constant holds data: numbers, strings, bytes, booleans, nil, enums and flags, arrays, tables and records of these, frames and typed arrays, and functions that don't capture variables. Anything else is refused, and the message says what it is and where it sits. A reference to a partition is the one you're most likely to meet. Pass it to whoever needs it instead:

partition Logger
  on Line(text) print(text) end
end

const LOG = Logger()

Run it:

$ scua const-partition.scua
scua: const-partition.scua:5: a constant holds data only, but `LOG` is a partition reference — pass it in instead, as a constructor argument or in a message

freeze takes the same values a const does. A function in a constant is still the same function: after const F = f, F == f is true, and so is a test against a function kept inside a constant table. See Comparing functions.

#Block scope

A binding is visible from where it's declared to the end of the block that contains it. A do ... end block is the simplest way to introduce a scope:

let x = 1
do
  let x = 2
  print(`inside: {x}`)
end
print(`outside: {x}`)

Run it:

$ scua scope.scua
inside: 2
outside: 1

The inner let x is a separate binding that lives only inside the block. When the block ends, the outer x is what's in scope again. The bodies of if, while, for, and functions are blocks too, so a binding made inside one of them is not visible outside it.

#Top-level bindings are visible inside functions

A let or const written at the top level of your script is in scope for every function defined in that same file. A top-level let is the main script's mutable state: a function can read it and update it, and the change persists across calls. A top-level const is read-only everywhere. (A module's top level holds no let: it uses const, or state for a value each partition keeps its own copy of. See Modules.)

const STEP = 5
let score = 0

fn add_points()
  score = score + STEP
end

print(`start: {score}`)
add_points()
add_points()
print(`after two: {score}`)

Run it:

$ scua shared-score.scua
start: 0
after two: 10

add_points reads the constant STEP and updates the shared score. Each call adds to the same binding, so after two calls score is 10. This is handy for a counter or a small piece of configuration that several functions share.

A partition can't reach it. A top-level let belongs to the main script. Code that runs in a partition (its handlers, its state lines, and every function they call) may not read or change one, and the compiler says so, naming the variable and the path to it:

let score = 0

fn add_points()
  score = score + 5
end

partition Player
  on Score()
    add_points()
  end
end

tell Player().Score()

Run it:

$ scua actor-copy.scua
scua: actor-copy.scua:9: the `Score` handler of `Player` calls `add_points`, which changes `score`, a variable of the main script (`let score`, line 1). A partition can't reach the main script's variables: keep the value in the partition's `state` block, or `tell` whoever owns it

Keep data that belongs to a partition in its state, and ask it when you need it:

partition Player
  state score = 0
  on Score() score = score + 5 end
  ask Total() return score end
end

partition Game
  on Play(p)
    tell p.Score()
    tell p.Score()
    print(`score: {ask p.Total() timeout 1s}`)
  end
end

tell Game().Play(Player())

Run it:

$ scua actor-state.scua
score: 10

A top-level const is different: it is frozen, so no code can change it, and every partition reads the same value. A partition reads a value that never changes from a const, or gets it as a constructor argument. See Partitions and actors.

#Using a name before its declaration

A top-level fn can be called from anywhere in its file, above its declaration included: every function at the top level exists before the first line runs.

print(greet("Ada"))

fn greet(name) return `hello, {name}` end
$ scua hoist.scua
hello, Ada

A value is different. A top-level let or const exists from the line that declares it, so reading one above that line is a compile error that names the line to move:

print(LIMIT)
const LIMIT = 3
$ scua early.scua
scua: early.scua:1: `LIMIT` is used before its declaration (line 2). Move the declaration above this line

When the read happens inside a function, the compiler can't tell when the function will run, so the check happens then: reading the value before its declaration has run is a fault naming the line, never a silent nil.

fn limit() return LIMIT end
print(limit())
const LIMIT = 3
$ scua late.scua
scua: late.scua:1: `LIMIT` is used before its declaration ran (line 3)
  at limit (late.scua:1)
  at main (late.scua:2)

A partition sees exactly the top-level declarations that had run when it was created. One created above const LIMIT gets this fault every time it reads LIMIT, on one core or many, however long it runs: declare what partitions need before you create them.

#Shadowing

You can declare a new binding with the same name as one already in scope. The new binding shadows the old one from that point on:

let value = "first"
let value = "second"
print(value)

Run it:

$ scua shadow.scua
second

This is a new binding, not an assignment. It's most useful when you want to refine a value through a few steps and don't need the earlier versions, for example reassigning a name to a trimmed or parsed form of itself.

One exception: at the top level of a file, a name declared with const is declared once. const LIMIT = 10 followed by let LIMIT = 20 is an error, because functions and actors would read one and the rest of the file the other. Inside a block or a function you can still shadow a const with a new binding.

For how functions capture the variables around them, see Functions and closures.

#Your names and the built-ins

Every file can use the built-ins (print, len, round, tostring and the rest) without importing anything. They come from the standard library, which sits around your file like an outer scope.

You can still declare your own function or variable with a built-in's name. Your declaration hides the built-in in the whole file, above the line you wrote it on as well as below, and SCUA says so once, with a warning. To reach the built-in anyway, write std. in front of its name:

fn round(x)
  return x // 1
end
print(round(2.7))
print(std.round(2.7))

Run it:

$ scua rounding.scua
2.0
3
scua: rounding.scua:1: warning: `round` hides the built-in `round` in this file; the built-in is still `std.round`

std.<name> always means the built-in, whatever your file declares. std is a reserved word, so nothing in your program can be called std.

This warning never stops a program. --deny-warnings doesn't turn it into an error, and neither does a --!strict file. That's on purpose: a later SCUA release may add a built-in whose name your program already uses, and your program must keep running exactly as it did. For the same reason it's printed when the run ends, after everything the program wrote, so the first line of the error output is always about your program: its error, if it stopped with one. If you'd rather not see the warning, rename your declaration.

A top-level fn exists before the first line runs (see Using a name before its declaration), so a call above it is already your function. A let or const exists only from its line, and because the declaration covers the whole file, using its name in the file's top-level code before that line has run is an error. It would otherwise quietly use the built-in above the line and your value below it:

print(round(2.7))
let round = 3
$ scua early.scua
scua: early.scua:1: `round` is used here before its declaration on line 2 has run. That declaration hides the built-in `round` in this whole file, so `round` here means it too; to use the built-in, write `std.round`
scua: early.scua:2: warning: `round` hides the built-in `round` in this file; the built-in is still `std.round`

When a program is refused, the error that stopped it comes first and any warnings follow it.

A parameter or a local variable may have a built-in's name too. It hides the built-in only where it's in scope, the way any local hides an outer name, and there's no warning. A method call like xs.len() always means the built-in unless your file declares its own len, so a local called len never changes it:

fn largest(xs)
  let max = xs[0]
  for x in xs do
    if x > max then max = x end
  end
  return max
end
print(largest([3, 9, 4]), max(3, 9))
$ scua local.scua
9 9

Built-ins can't be assigned to. tostring = fn(x) return "?" end is an error in your program and in any module, because it would change tostring for code that never asked for it. Declare your own instead, which affects only your file:

tostring = fn(x) return "?" end
$ scua assign.scua
scua: assign.scua:1: `tostring` is a built-in, and built-ins can't be assigned to. To have your own `tostring` in this file, declare it — `fn tostring(…) … end` or `let tostring = …` — which hides the built-in here and nowhere else; the built-in stays reachable as `std.tostring`

Modules follow the same rules: a module that declares its own round hides the built-in in that module only. One difference: a name the module exports gives no warning where it's declared. A geometry module that declares fn distance and returns { distance = distance } has picked that name for the files that call geom.distance, so the warning would only be noise; inside the module, distance still means the module's own. A name the module keeps to itself warns as usual. See Modules.

If the module also calls that name without a prefix, the warning moves to the call, because that's where the mix-up happens. A database module that exports its own fn error(message) and then writes error(fault) to re-raise a fault calls its own function, not the built-in that raises. The warning says where the call goes:

scua: db.scua:12: warning: `error(…)` here calls this file's own `error` (line 3), not the built-in `error`; write `std.error(…)` if you meant the built-in

If you meant your own function, nothing needs to change. A module that calls the name in several places gets one warning that lists the lines.