Lowent Manual←↑→

6 Locals — let and var

What to know first

chapter 3, The surface · shadowing is forbidden
chapter 5, Ops · mutation confined inside an op counts as pure

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

A value that changes is expensive for the reader, who has to ask again at every use “what is this value here?”. Lowent separates names that do not change from names that do by word, and closes the door on naming something without a value. Before using loops and branches in chapter 7, this chapter fixes the containers that hold values inside them.

By the end of this chapter

You will learn that 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

  1. Can the elements of a slice bound with let be changed?
  2. If if could be an expression, couldn’t we have fewer vars?

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.

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 0 .
  var i u64 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. The other way round, a var that never changes gets W-VAR-NEVER-SET — write it as a let (RFC-0113 O4).

examples/ch06/immutable.low

module immutable .
rem expect: E-IMMUTABLE

fn f output u64 .
do
  let a u64 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 The type goes in front of the value#

A binding is let <name> <type> <value> .. The type stands right after the name and before the value, and it is always written — the processor never guesses a 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 u8 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 u8 add a. 1 . ., b is a u8, so adding 1 to 255 overflows u8 and stops. 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.

Leaving the type out — let x 300 . — is rejected with E-LET-NOTYPE. When guessing was allowed, that bare literal slipped past the width check and an op returning u8 answered 300. The old form with be between the name and the type, let x be u8 4 ., is E-LET-BE — be is no longer a word of the language. A long type gets a name with type.

examples/ch06/mistake_be.low

module mistake_be .
rem expect: E-LET-BE

fn f output u8 .
do
  rem ✘ the old form --- `be` is no longer a word of the language. The type comes right after the name
  let x be u8 4 .
  return x. .
end .

Output

$ lowentc --check mistake_be.low
7:9 E-LET-BE: `be` is gone — the type follows the name: `let x u8 4 .` — found `be` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

6.3 No name without a value#

There must be a value after the type. 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 .5 .
  return x. .
end .

Output

$ lowentc --check novalue.low
6:13 E-LET-NOVALUE: a binding names its value, and this one has none — a LEADING DOT is a stop, not part of a number: `.5` reads as `.` then `5`. Write `0.5` — found `.` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

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 only the type is left after the name. 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

In C, an uninitialised local has an unspecified value. Some languages fill in zero. Lowent has neither — there is no such thing as an uninitialised variable. There is no moment at which a value is undecided, so there is no defect that reads at that moment.

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 0 .
  if gt a. 1 . do
    let doubled u64 mul a. 2 . .
    set result. doubled. .
  end .
  if le a. 1 . do
    let doubled u64 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 1 .
  if gt a. 1 . do
    let t u64 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 … 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 (if gt a. 1 . do 5 . end else do 6 . end .) .
  return x. .
end .

Output

$ lowentc --check ifvalue.low
6:14 E-IF-VALUE: `if` is a statement, not a value — bind the result in each branch (`var x T … . if c. do set x. … . end .`) — found `if` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

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 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:6:0 W-VAR-NEVER-SET: this `var` is never changed — write `let` (a `let` cannot be reassigned, which is what this name already does, and the interval analysis keeps its facts) — RFC-0113 O4
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 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

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 … , 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 usize .
do
  rem ✘ `max` is the name of a core op --- it cannot be a local name
  let max usize len xs. . .
  return max. .
end .

Output

$ lowentc --check mistake_localname.low
mistake_localname.low:7:0 E-NAME-BUILTIN: a local takes the name of a CORE 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 len, min, max, ok and abs are already core ops. The name space is flat, so if a local took one of them, f max data would group differently depending on whether max is the core op 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. (count, map, filter, fold, seg and segs are no longer core ops, so they can be local names.)

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 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 … 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 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#

ShapeMeaningWhy
let x T e .a name that never changesimmutable by default — only changing values stand out
var x T e .a name that may changesay that it will change at the moment you declare it
set x e .put a new value into a vardeclaring and changing are different words — a typo never becomes a new variable
let x e .let the value decide the typeonly for short intermediate values — the width is the overflow boundary
the value after the typealways requiredthere is no such thing as an uninitialised variable
the end of a blocklocals declared inside it disappearnames live close to where they are used
re-declaring an outer name insiderejected (E-NAME-SHADOW)the same letters point to one value only
eq a. b. .ask whether two values are equalthere is no = — assignment and equality never mix

Table 6.1 — Local syntax — shape · meaning · why it looks this way

Recap

There are two kinds of local, 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 the type 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.