7. Expressions
7.1 Evaluation order#
Left to right, always: receiver then arguments, operands, array elements, interpolation fields, constructor arguments. A backend may reorder only when traps and side effects are unobservable.
7.2 Arithmetic#
| Operators | Meaning |
|---|---|
+, -, * | checked integer or IEEE float arithmetic; integer overflow traps |
+%, -%, *% | two's-complement wrapping integer arithmetic |
/ | float division; both operands must be floats |
// | integer floor division: -7 // 2 == -4 |
% | modulo paired with floor division: -7 % 2 == 1 |
unary -, + | sign; unary minus is rejected on unsigned types |
Integer division by zero traps. minimum_signed // -1 traps as overflow. C's truncating division and remainder are truncating_div and truncating_rem. wrapping_*, saturating_*, and checked_* named operations exist for every operator. Constant folding uses the same rules as runtime. Float arithmetic is IEEE 754 with no contraction or reassociation.
Why trapping is the default. C wraps unsigned arithmetic silently and leaves signed overflow undefined, and both are where the exploitable bugs live. Base traps, because a trap tells you where a port went wrong and a wrap does not. Hashing, PRNGs, and checksums wrap on purpose and get the three % operators, which keep those lines short. The floor semantics of // are Luce's and differ from C's truncation; it is the one silent-behaviour difference a C programmer must learn, and it is on the first page of the translation table.
7.3 Bits, comparison, logic#
Fixed-width integers support &, |, ^, ~, <<, >>. A shift count of the operand's width or more traps. Signed right shift is arithmetic. Conditions are bool; there is no truthiness. and and or short-circuit; not takes a bool. Comparisons do not chain: write low <= x and x < high. not a == b is rejected; write not (a == b).
7.4 Equality and hashing#
== and != exist for scalars, char, str (by bytes), tuples, arrays, structs, enums, optionals, and pointers (by address), when every component supports equality. A struct or enum whose components are hashable is hashable, and hash(value) -> u64 is process-seeded and not stable across runs. Pointers are not hashable. Unions and interface views have neither. No user type overloads an operator; a domain with unusual equality exposes a named method.
7.5 Conversions and casts#
Base has two conversion spellings with two meanings.
T(x) is the checked conversion. u32(length) traps if the value does not fit. f64(count) rounds to nearest. i32(f) truncates toward zero and traps on NaN or out of range. c.int(n) is checked. A conversion the compiler can prove impossible is an error.
(T)x is C's cast, with every case defined:
| Cast | Meaning |
|---|---|
| integer to narrower integer | truncation to the low bits |
| integer to wider integer | sign extension from a signed source, zero extension from an unsigned source |
| float to integer | truncation toward zero, saturating to the destination's range; NaN becomes 0 |
| integer to float, float to float | value conversion as in C |
(u32)flag | an integer-backed enum to its representation |
(T*)p from U*, void*, const T*, const void* | pointer conversion; removing const is permitted and explicit, and modifying an object that was declared let or const through the result is undefined as in C |
(T*?)n from usize | integer to pointer; nullable because zero is a valid integer |
(usize)p | pointer to integer |
(func(...) -> R)p from void*, (void*)f | function-pointer conversion, target-dependent, provided for dlsym |
(T*)f, (func)p for an object pointer | rejected |
There is no reinterpreting cast between scalars. f32.from_bits(u32), f64.from_bits(u64), and value.to_bits() reinterpret floats; a union (§10.4) reinterprets anything else.
The parser reads ( type ) as a cast when the parenthesised text is a type ending in *, [], [N], or ?, or is a scalar or c. type name, or a parenthesised function type. (Name)(x) with a bare struct name is a call, not a cast; struct-to-struct casts do not exist, so nothing is lost.
Why two spellings. The checked form is Luce's and is the ordinary one. The C cast exists because ported code is full of (uint8_t)x that means "the low byte", and making it trap would make porting impossible. Giving the C spelling C's meaning, and defining the one case C leaves undefined, keeps both honest: the parenthesised one is C, the constructed one is Luce.
7.6 Members, calls, indexing, slicing#
image.width
image.resize(scale = 0.5)
pixels[10]
pixels[10..<20]
decode[Header](data)
. accesses a field, method, or module member, and auto-dereferences a pointer: p.field is (*p).field. Calls use (). Indexing and slicing are checked. Slicing is half-open, [start..<end], with [..<end] from zero and [start..] to the end. There is no negative indexing and no step. Brackets after a PascalCase name whose contents are types are generic arguments; otherwise they are an index.
7.7 Pointer operations#
| Form | Meaning |
|---|---|
*p | load; *p = v stores |
p.field, p.method() | auto-dereference; -> is not needed and remains the type arrow |
&x | address-of (§6.5) |
p + n, p - n, p[i] | element-scaled arithmetic and unchecked indexing |
p - q | element difference as isize; same pointee type required |
p == q, p < q | address comparison (§5.3) |
Pointer arithmetic is unchecked: producing a pointer outside the object p addresses, or one past its end, and using it, is undefined as in C. A span should be used whenever a length is known. Raw arithmetic is the escape hatch and should be rare.
Why no ->. Luce already uses -> to declare a result type, and a symbol with two meanings is a cost. Auto-dereference on . is what Go, Odin, and Zig do, and C++ references do the same.
7.8 Conditional and match expressions#
let label = "ready" if ready else "waiting"
let kind = match c:
'0'..='9' => "digit"
'a'..='z', 'A'..='Z' => "letter"
_ => "other"
The conditional expression requires both branches and one common type. A match expression yields the chosen arm's value with =>; §8.7 states the pattern rules, which are shared with the statement form.
7.9 Discarded values#
A call may appear as a statement. A non-unit result is discarded and the linter warns; discard(call()) states the intent. A fallible result must be handled before it can be discarded.
7.10 Precedence#
From tightest to loosest: member, call, index; unary try, not, -, +, ~, *, &, cast; *, /, //, %, *%; +, -, +%, -%; <<, >>; &; ^; |; ..<, ..=; comparison and is; and; or; conditional expression; the optional else fallback (§11.1); catch.