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

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

ShapeMeaningWhy
let x T be e .a name that never changesimmutable by default — only changing values stand out
var x T be 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 be e .let the value decide the typeonly for short intermediate values — the width is the overflow boundary
the value after bealways 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 bask 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 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.