Variables and Mutability¶
A variable binds a name to a value of a known type. Termina provides two kinds
of binding, distinguished by whether the bound value may later change:
immutable bindings, introduced with let, and mutable bindings, introduced
with var. Every binding, of either kind, is declared with an explicit type,
since Termina does not infer types. A let also receives its value at the
point of declaration, whereas a var may receive it later, and in that case the
transpiler checks that no path of the program reads it before it has one.
Immutable bindings¶
A let declaration introduces an immutable binding. Once a value has been
assigned, it cannot be changed for the remainder of the binding's scope. The
declaration states the name, its type, and the initial value:
The transpiler rejects any assignment to an immutable binding. The following
fragment is rejected by the transpiler because base was declared with let:
A binding declared with let records in the code that its value does not
change, and the transpiler enforces it, so let is preferred whenever a value
does not need to be modified after its initialization.
Mutable bindings¶
When a value must change over the course of a computation, the binding is
declared with var. A mutable binding can be reassigned as many times as
needed, provided every assigned value has the binding's declared type:
The two kinds of binding can be seen together in a small function that
combines an immutable value with a mutable accumulator. Both let and var
map to C local variables. The transpiler rejects any write to a let binding
when it checks the program, and the generated C declares the binding const
as well:
Types are mandatory¶
Both forms of declaration require a type annotation. A declaration without a
type, such as let base = 10, is a syntax error, because Termina does not infer
the types of bindings, and the representation of the value is taken only from
the type the programmer writes. A let also requires its
initializing expression, since an immutable binding that is not given a value
where it is declared could never be given one, and let base : u32; is
likewise a syntax error.
Declaring a mutable binding without a value¶
A var may be declared without an initializer when its value depends on a
decision taken after the declaration. In the following function, each branch of
the if gives r its value, and the generated C declares the variable without
initializing it:
In C, reading a local variable before a value has been written yields an
indeterminate result. The transpiler prevents it by following every path from
the declaration and rejecting a read that at least one of them reaches before
the variable has been assigned. Removing the else branch of the function
above leaves a path on which r is never assigned, and the return is
reported:
error [VUE-007]: object read before it is assigned.
→ src/lib/util.fin:6:12
│
6 │ return r;
│ ^
Variable r is declared without an initializer and there is a path that reaches this point without assigning it.
Assign the whole object on every path before reading it.
The check considers the object as a whole. Until a structure or an array has been assigned completely, writing one of its fields or elements is rejected as well, because the fields that remain unwritten would still hold indeterminate values. A loop body does not count as an assignment, since the loop may run no iterations, and taking a reference to the variable counts as a read.
Every value must be used¶
Termina rejects a binding that is declared but never read. A value that is computed and stored, only to be ignored, is almost always either a mistake or the residue of code that has since changed, and in a language aimed at analyzable, verifiable software it is treated as an error rather than a warning.
The same holds for every value a variable receives. An assignment whose value is overwritten on every path before anything reads it is rejected, and so is an initializer in the same situation:
The fragment is accepted once x is declared where its value is computed, as
var x : u32 = a + 1;, or is declared without an initializer, as in the
previous section.
Scope and the absence of shadowing¶
A binding is visible from the point of its declaration to the end of the block
that contains it, where a block is the region delimited by a pair of braces,
such as the body of a function, an action, or a branch of an if. Outside
that block, the binding does not exist.
Within the region where a name is visible, that name cannot be redeclared.
Unlike languages that permit shadowing, Termina does not allow a new binding to
reuse the name of one that is already in scope, not even inside a nested block.
The following fragment is rejected, because the inner declaration of x clashes
with the outer one:
With shadowing, a single name can refer to different values in different parts of a function depending on the nesting; in Termina, a name in scope always denotes the same binding.
A binding is also expected to be declared in the innermost block that contains
all its uses. In the following function, factor is only used inside the
if, so the transpiler asks for the declaration to be moved there:
function scaled(a : u32, big : bool) -> u32 {
var r : u32 = a;
var factor : u32 = 4; // error: variable scope can be reduced
if big {
factor = factor * a;
r = factor;
}
return r;
}
The check applies when the initializer is built from literals, constants and values that cannot change in between, such as parameters passed by value, so that moving the declaration does not change what the program computes.