6 Locals — let and var
What to know first
Looking back
running_total in chapter 5 accumulated with var and was still a fn. Why was that allowed?
A. Because purity is observational. total is a local that lives only inside the op, so the caller cannot see it change. Only writes the caller can see (through a mut parameter) count as effects. This chapter covers the two words that make locals and the span over which a local lives.
The need for this chapter, and its context
By the end of this chapter
let is immutable and var is mutable and changed only with set. You will pick up when to write the type and when to leave it out, and why a binding without a value is rejected (especially the .5 trap). You will also see that a local lives only inside its block, that a live outer name cannot be reused inside, and that if is a statement that yields no value.The questions this chapter answers
- Can the elements of a slice bound with
letbe changed? - If
ifcould be an expression, couldn’t we have fewervars?
6.1 The word says whether it changes#
A name that holds a value in an op body is a local. There are only two kinds.
let <name> <type> be <expr> .— immutable. Once set, it does not change.var <name> <type> be <expr> .— mutable. It is changed withset <name> <expr> ..
examples/ch06/sumto.low
module sumto .
rem run: sum_to 10
rem run: sum_to 100
fn sum_to input n u64 . output u64 .
do
var total u64 be 0 .
var i u64 be 1 .
while le i n . do
set total (add total i) .
set i (add i 1) .
end
return total .
end
Output
$ lowentc --run sum_to sumto.low 10
sum_to(10) = 55
$ lowentc --run sum_to sumto.low 100
sum_to(100) = 5050
total and i change as the loop runs, so they are vars. Only values that change are made with var; the rest are let by default. Using set on a let is rejected.
examples/ch06/immutable.low
module immutable .
rem expect: E-IMMUTABLE
fn f output u64 .
do
let a u64 be 1 .
set a 2 .
return a .
end
Output
$ lowentc --check immutable.low
immutable.low:7:0 E-IMMUTABLE: this binding was made with `let`, which is IMMUTABLE — `set` cannot reassign it. Until now `let` and `var` meant exactly the same thing (two spellings, one meaning — and SPEC-002 §2.5 forbids synonyms). Use `var` if you mean to reassign it
According to the long explanation in the diagnostic, let and var once meant the same thing. Two spellings for one meaning means one of them says nothing. Today let promises immutability and the compiler checks the promise.
Q. Can the elements of a slice bound with let be changed?
A. What let forbids is putting a different value into the name. Whether the elements of a slice can be written is decided by the slice’s type — mut slice allows writing elements and slice only reading (chapter 9). Immutability of the name and immutability of the bytes it points to are different questions.
6.2 Write the type, or leave it to the value#
A local’s type may be written or left out. When left out, the value decides the type.
examples/ch06/infer.low
module infer .
rem run: next_byte 41
rem trap: next_byte 255
fn next_byte input a u8 . output u8 .
do
let b be add a 1 .
return b .
end
Output
$ lowentc --run next_byte infer.low 41
next_byte(41) = 42
$ lowentc --run next_byte infer.low 255
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)
In let b be add a 1 ., a is a u8, so b is a u8 too. That is why adding 1 to 255 overflows u8 and stops. If the type cannot be determined from the value, it is rejected and must be written.
This book usually writes the type. The width is the boundary of overflow (chapter 4), so when the type is visible in the source, the places that can stop are visible with it. Types are left out only for short intermediate values or where the type is obvious.
6.3 No name without a value#
There must be a value after be. There is no way to make a name first and give it a value later.
examples/ch06/novalue.low
module novalue .
rem expect: E-LET-NOVALUE
fn half output f64 .
do
let x f64 be .5 .
return x .
end
Output
$ lowentc --check novalue.low
novalue.low:6:0 E-LET-NOVALUE: this binding has NO VALUE — `be` is followed by nothing, and the tool used to quietly bind 0 there: a value that appears NOWHERE in your source. If you wrote a float like `.5`, that is the cause: a LEADING DOT is a form CLOSER here, not part of a number, so the value vanished before it was ever read. Write `0.5`. Otherwise give the binding a value — this language has no way to declare a name and fill it in later
This is not a blank left by mistake. The author believes they wrote the value .5. But .5 is not a floating-point literal — the free-standing full stop closes the form, and nothing is left after be. The old tool quietly put 0 there, and a value that appears nowhere in the source got into the program. Now it is rejected, and the diagnostic names the trap. A floating-point half is written 0.5.
A common misconception. An uninitialised variable is zero
6.4 How long a local lives#
A local lives until the end of the block it was made in. When the block ends, the name can be used again.
examples/ch06/scope.low
module scope .
rem run: pick 5
rem run: pick 0
fn pick input a u64 . output u64 .
do
var result u64 be 0 .
if gt a 1 . do
let doubled u64 be mul a 2 .
set result doubled .
end
if le a 1 . do
let doubled u64 be 1 .
set result doubled .
end
return result .
end
Output
$ lowentc --run pick scope.low 5
pick(5) = 10
$ lowentc --run pick scope.low 0
pick(0) = 1
The two if blocks each make a doubled. When the first block ends, the first doubled is gone, so the second block may make one with the same name. There is no moment at which both names are alive at once.
On the other hand, an inner block making a name that is still alive outside is rejected.
examples/ch06/outer.low
module outer .
rem expect: E-NAME-SHADOW
fn f input a u64 . output u64 .
do
let t u64 be 1 .
if gt a 1 . do
let t u64 be 2 .
return t .
end
return t .
end
Output
$ lowentc --check outer.low
outer.low:8:0 E-NAME-SHADOW: an inner block binds a name that is already bound OUTSIDE it. The namespace is FLAT (SPEC-002 2.10: no shadowing) — the outer value is still alive and the same letters now point at a different one. Rename the inner binding
outer.low:11:0 E-NAME-SCOPE: this name was declared INSIDE a block and is read outside it. A block is where a name lives (§6.5.1): on the path that did not enter the block the name never existed, and the tool used to answer 0 there — a value that appears nowhere in the source. Declare it before the block (`var … be 0 .`) and set it inside
Inside the inner block t would be 2, and outside it 1 again. When the same letters mean different values on different lines, the reader thinks of the wrong value. It is the same rule as the parameter shadowing in chapter 3 — the name space is flat.
6.5 if yields no value#
if is a statement. It cannot stand where an expression is expected.
examples/ch06/ifvalue.low
module ifvalue .
rem expect: E-IF-VALUE
fn pick input a u64 . output u64 .
do
let x u64 be if gt a 1 . 5 else 6 .
return x .
end
Output
$ lowentc --check ifvalue.low
ifvalue.low:6:0 E-IF-VALUE: `if` is a STATEMENT — it gives no value (§6.5.2 (4)). Choose the value in each branch instead: `var x u64 be 6 . if c do set x 5 . end`, or `return` from each branch
To decide a value per branch, do what the diagnostic suggests: make a var with a default and set it inside the branches, or return from each branch. When the value is one of several cases over and over, match (chapter 7) is the better tool.
Q. If if could be an expression, couldn’t we have fewer vars?
A. We could. In exchange, blocks would enter expressions, and on meeting a return or break inside such a block you would have to work out where that expression leaves to. Lowent chose to separate expressions from statements. Expressions make values; statements change flow.
6.6 Common mistakes#
Counter-example. Misspelling the name in a set
examples/ch06/mistake_typo.low
module mistake_typo .
rem expect: E-IR-UNDEF
fn bump output u64 .
do
var total u64 be 0 .
rem ✘ a misspelt name --- `totl` was never declared
set totl (add total 1) .
return total .
end
Output
$ lowentc --check mistake_typo.low
mistake_typo.low:8:0 E-IR-UNDEF: set of an undeclared name
set only puts a new value into a var that already exists. Some languages quietly create a new variable when you assign to an unknown name; here it is rejected with E-IR-UNDEF. If a typo became a new variable, the real one would never change and the program would silently compute the wrong answer. That is why declaring (var) and changing (set) are different words.
Counter-example. Assigning with =
examples/ch06/mistake_equals.low
module mistake_equals .
rem expect: E-CHAR
fn bump output u64 .
do
var total u64 be 0 .
rem ✘ `=` is not a symbol of this language --- putting a new value in is `set`
total = add total 1 .
return total .
end
Output
$ lowentc --check mistake_equals.low
8:9 E-CHAR: unexpected character
8:9 E-FORM-UNEXPECTED: unexpected token in form
In most languages = means assignment, while in mathematics it means equality. One symbol switching between two meanings has produced bugs like if (x = 0). Lowent uses no symbol for either: declaring is let/var … be, changing is set, and asking whether two values are equal is eq. = is not a character the language knows at all, hence E-CHAR.
Counter-example. Naming a local after a common English word
examples/ch06/mistake_localname.low
module mistake_localname .
rem expect: E-NAME-BUILTIN
fn size input xs slice u8 . output u64 .
do
rem ✘ `count` is the name of a builtin op --- it cannot be a local name
let count u64 be len xs .
return count .
end
Output
$ lowentc --check mistake_localname.low
mistake_localname.low:7:0 E-NAME-BUILTIN: a local takes the name of a BUILTIN op. The namespace is FLAT (no shadowing), so this name now means two things — and which one it means decides how the SENTENCE IS BRACKETED: `f len data` reads as `f(len(data), …)` if `len` is the builtin (arity 1) and as `f(len, data)` if it is your local (arity 0). Same letters, different tree, no error. That is RFC-0046 P1. Rename the local
Names such as count, len, min, max, ok and avg are already builtin ops. The name space is flat, so if a local took one of them, f count data would group differently depending on whether count is the builtin call or the local. The same letters would build a different tree, so E-NAME-BUILTIN rules it out. Use names like n, total or size.
Counter-example. Reading a local outside the block that declared it
examples/ch06/mistake_outside.low
module mistake_outside .
rem expect: E-NAME-SCOPE
fn big_or_zero input a u64 . output u64 .
do
if gt a 3 . do
let big u64 be mul a 10 .
end
rem ✘ `big` only lives inside the block above --- it is read outside
return big .
end
Output
$ lowentc --check mistake_outside.low
mistake_outside.low:10:0 E-NAME-SCOPE: this name was declared INSIDE a block and is read outside it. A block is where a name lives (§6.5.1): on the path that did not enter the block the name never existed, and the tool used to answer 0 there — a value that appears nowhere in the source. Declare it before the block (`var … be 0 .`) and set it inside
big was declared inside the if block, so it disappears when the block ends. Reading that name outside the block is rejected with E-NAME-SCOPE; it used to quietly give 0 on the path that never entered the block — a value that appears nowhere in the source. Declare a value you need after the block before the block.
examples/ch06/outside_fixed.low
module outside_fixed .
rem run: big_or_zero 5
rem run: big_or_zero 1
fn big_or_zero input a u64 . output u64 .
do
rem a value used after the block is declared before it; the block only sets it
var big u64 be 0 .
if gt a 3 . do
set big (mul a 10) .
end
return big .
end
Output
$ lowentc --run big_or_zero outside_fixed.low 5
big_or_zero(5) = 50
$ lowentc --run big_or_zero outside_fixed.low 1
big_or_zero(1) = 0
Now the 0 is a default written in the source. The answer is the same, but where the 0 comes from is visible.
6.7 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
let x T be e . | a name that never changes | immutable by default — only changing values stand out |
var x T be e . | a name that may change | say that it will change at the moment you declare it |
set x e . | put a new value into a var | declaring and changing are different words — a typo never becomes a new variable |
let x be e . | let the value decide the type | only for short intermediate values — the width is the overflow boundary |
the value after be | always required | there is no such thing as an uninitialised variable |
| the end of a block | locals declared inside it disappear | names live close to where they are used |
| re-declaring an outer name inside | rejected (E-NAME-SHADOW) | the same letters point to one value only |
eq a b | ask whether two values are equal | there is no = — assignment and equality never mix |
Table 6.1 — Local syntax — shape · meaning · why it looks this way
Recap
let (immutable) and var (mutable), and set works only on a var. The type may be written or left to the value, but a value after be is required. A local lives until the end of its block, and a live name cannot be made again even in an inner block. if is a statement that yields no value.