7. Expressions
7.1 Evaluation order#
Left to right, always: receiver then arguments, operands, array elements, interpolation fields, constructor arguments, an indexed or sliced owner before its index (a span's data and length are read then, so an index that replaces the span still selects from the one it had), and an assignment's place before its value, a compound assignment reading the place before the value too. 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 | |||
+%, -%, *%, unary -% | two's-complement wrapping; unary -% is defined on unsigned types too, so x & -%x isolates the lowest set bit | |||
| `+ | , - | , * | ` | saturating |
+?, -?, *? | checked, yielding T?: none on overflow | |||
/ | float division; both operands must be floats | |||
// | integer division, truncating toward zero: -7 // 2 == -3, as in C | |||
% | remainder with the sign of the dividend: -7 % 2 == -1, as in C | |||
unary - | sign; rejected on unsigned types (use -%) |
Two integers of one width and signedness compute together, key.hash() % table.length with a u64 and a usize, in the pointer-sized type when one operand is; storing the result is still the strict rule of §5.1. Integer division by zero traps. minimum_signed // -1 traps as overflow. Floor division and modulo are luce-std's math.div_floor(a, b) and math.mod_floor(a, b). Constant folding uses the same rules as runtime. Float arithmetic is IEEE 754 with no contraction or reassociation. The one fused operation is written out: a.mul_add(b, c) on an f32 or f64 is a * b + c rounded once, one instruction where the target has it (every arm64, x86-64 from v3) and the C library's fma elsewhere, with the same bits on both. Likewise an extern func binding the C library's sqrt, floor, ceil, trunc, round, fabs or fma, or its f form, with their C types (as luce-std's math and math32 do) is computed by the target's instruction where that gives the library's result bit for bit, NaNs included, and called elsewhere: all of them on arm64; sqrt and fabs on x86-64, with fma from v3, and on Linux floor, ceil and trunc from v2.
Why trapping is the default. C wraps unsigned arithmetic silently and leaves signed overflow undefined, and both are the source of most exploitable integer bugs. Base traps, because a trap reports the location of the overflow and a wrap does not, and the check is one predicted branch. Hashing, PRNGs, and checksums wrap on purpose and use the % operators; overflow-aware code uses +? and handles none.
Why C's division. Full Luce's // and % floor. Floor division costs an adjustment after the divide instruction on signed operands, and it differs from C silently for negative operands, which a port would not notice until a result was wrong. Base follows the instruction and C. This is a stated difference from full Luce for negative signed operands only (§23).
7.3 Bits, comparison, logic#
Integers, including usize and isize, support &, |, ^, ~, <<, >>. A shift count of the operand's width or more traps; << discards bits shifted past the width, as in C, and is never an overflow. 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 and spans (the same length and equal elements, each by its own ==, as Python compares lists, so a float array holding NaN is unequal to itself and [-0.0] equals [0.0]; an array beside a span compares as one), structs, enums, optionals, and pointers (by address), when every component supports equality; comparing any optional with none asks whether it holds a value, whatever its payload. A struct or enum whose components are hashable is hashable; a span is not, since what it views may change under it, and neither is an aggregate holding one; and every hashable value has the method value.hash() -> u64, process-seeded and not stable across runs. A pointer hashes its address; its pointee's hash is (*p).hash(). Unions and interface views have neither. A hashable type declares no hash method of its own: the one its fields give it is the one Hashable (§14.4) and every hashed container use. No user type overloads an operator; a domain with unusual equality exposes a named method.
7.5 Conversions and casts#
A cast converts the value of its operand and gives the operand no context: an untyped operand computes as i64, or f64, and is converted after, so (u8)(200 + 100) is 44 and (u32)(1 << 40) is 0, where let x: u8 = 200 + 100 computes in u8 and traps (§4.2). A bare literal is read in the cast's type: (u64)18446744073709551615.
Base has two conversion spellings with two meanings.
Widening is implicit. An integer converts implicitly to a wider integer of the same signedness: u8 to u16, u32, u64, or usize; i32 to i64 or isize. No value can change, so no spelling is required. Nothing else converts implicitly: narrowing, a change of signedness, and conversion to or from a float are always written.
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.long(n) is checked. Kind(n) from an integer traps when n is not a declared case of the integer-backed enum Kind. str(bytes) and str(value) validate text (§5.5) and yield str! rather than trapping, because invalid input is ordinary data, not a program error. A conversion the compiler can prove impossible, such as u32(-1) from a negative literal, 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 a different signedness | reinterpretation of the bits |
| 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 |
(i32)flag from bool, (bool)n | 0 or 1; nonzero, as in C. The cast is written, since bool has no implicit numeric conversion (§5.1) |
(u32)kind | an integer-backed enum to its representation |
(Kind)n | an integer to an integer-backed enum with no check |
(str)bytes | text reinterpretation with no check (§5.5) |
(c.str)text | the borrowed data pointer, without a memory read or termination check; C string use requires caller-provided termination and lifetime (§5.5) |
(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 |
(Handle)p from void*, (void*)h | an opaque handle (§17.1, §17.7) is pointer-shaped: it converts to and from void* by cast, so a Base module can hand out a handle over its own memory |
(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 of different kinds. f32.bits(u32) and f64.bits(u64) build a float from its bits and value.bits() reads them; 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, str, a c. type name, an alias of one of those ((bits)1, (lib.bits)1), a parenthesised function type, or a type built on a parenthesised one, as a pointer to an atomic is (((@u64)*)raw, §15.1), while ((n)) and ((n) * 2) stay grouped expressions; a bare name that resolves to a value is not a cast. (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 implicit widening. Full Luce requires every integer conversion to be written, and its arithmetic code is correspondingly full of u64(index). A widening of the same signedness cannot change a value or lose one, so requiring it to be written costs the reader without protecting them. Zig admits exactly this conversion and nothing more, and the rule has held there.
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 the two distinct: 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 whose contents are types are generic arguments; brackets whose contents are an expression are an index; a bracket immediately after a name that is a type is never 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.6) |
p + n, p - n, p[i] | element-scaled arithmetic and unchecked indexing; n and i are usize or isize; no p[i] when the pointee is an array or a span |
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[N] on an array or span of length N is the one-past-the-end address, permitted for & alone; every other index is checked against the length. Spans cover the cases where a length is known.
p[i] on a pointer to an array or a span, T[N]* or (T[])* with any qualifiers, is an error: it would step over whole arrays or spans, and the code that writes it almost always means an element of the one p addresses. That element is (*p)[i], checked against the length. A walk over consecutive arrays writes its step, *(p + i), or makes a span of them with a type alias, Row[](p, count) for type Row = T[N]. Inside a generic, p[i] on a T* steps over whole Ts whatever T is, an array included, since the generic's author wrote a walk over T. A pointer is not sliced either: (*p)[start..<end] slices what it points to, and T[](p, count) views count elements from it.
Why not dereference for [] as . does. A field has one meaning wherever the pointer is, so p.field can only mean (*p).field. p[i] already means a step, C's meaning, so reading it as an element of the pointee when the pointee is an array would give one spelling two meanings decided by a type the reader may not see. A silent read past the array is the failure, so the form is refused and the reader writes which one they meant.
Reading typed values from untyped memory. Prefer declaring a struct for what is stored and casting once where the untyped memory enters; the cast-and-dereference form *(Link*)block is legal and is the last choice. When no struct fits, a header decoded from a buffer or a C structure walked by offsets, memory.read[T](address: void*) -> T and memory.write[T](address: void*, value: T) copy memory.size_of(T) bytes at the address with no alignment assumption. memory.copy(to, from, count), memory.move(to, from, count) for overlapping ranges, and memory.set(span, byte) are memcpy, memmove, and memset.
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.4 states the pattern rules, which are shared with the statement form. A match expression ends with its arms: the line after them is the next statement, never an operator, a conditional, an else or a catch continuing it.
7.9 Discarded values#
A call may appear as a statement. A non-unit result is dropped and the linter warns; _ = value drops a value on purpose. It evaluates the value, of any expression, and keeps nothing; a fallible one is handled first, as a binding's value would be, _ = try write(data). A catch whose value _ = drops may end its handler without recover, since no value is wanted: _ = save(doc) catch failure: print(failure.message).
7.10 Precedence#
A leading try covers the following expression through its binary operators, conditional branches, and optional fallback, stopping before an attached catch. Inside an operator operand it has unary precedence: use parentheses to extend that operand, as in left + try (read() + parse()).
From tightest to loosest: member, call, index; operand try, not, -, -%, +, ~, *, &, cast; *, /, //, %, *%, *|, *?; +, -, +%, -%, +|, -|, +?, -?; <<, >>; &; ^; |; ..<, ..=; comparison; and; or; conditional expression; the else fallback (§11.1); catch.