13. Expressions
An expression computes a value. Its grammar, precedence, and associativity are in §4.4–§4.5; this chapter defines the meaning of each operator, the evaluation order, and the expression-valued control forms.
13.1 Place expressions
A place (lvalue) designates storage that can be read and assigned. The places are: a variable; an array or map index a[i] / m[k]; a struct field p.f; a tuple element t.0; and a user-defined subscript call (§18). A place is required as the target of an assignment, a compound assignment, and the operand of & (inout argument). Reading a place yields a value (a deep copy where the value is heap-bearing and must be independent); writing a place stores into that storage.
13.2 Operators
Arithmetic (+ - * / %, unary -). Both operands MUST have the same type, which is int, float, a sized numeric (u32/u64/f32), char (for +/- with the byte-domain rule below), or a newtype over an int or float base; the result keeps that type. int/u32/u64 division or modulo by a zero *value* aborts (§30). Division or modulo by an integer literal 0 is a compile error regardless of operand type (so x / 0 is rejected even when x is float); float/f32 division by a zero *value* or by the float literal 0.0 never traps and follows IEEE-754 (x/0.0 → ±inf/NaN). Integer overflow wraps (two's-complement for int, modulo 2^32/2^64 for u32/u64). char ± int has type char and wraps to a byte (0..255, like u8), so the value never escapes the type's range.
String concatenation. + on two strings concatenates them. As the one documented exception to the same-type rule, string + char appends the char's single byte to the string and yields a string ("ab" + 'c' → "abc"). It is one-directional: char + string is a type error. For symmetric composition, convert explicitly with str(c). An implementation MUST implement exactly this.
bytes concatenation. + on two bytes concatenates them, and bytes + char appends the char's single byte — the same two forms string has, with the same one-directional rule, because bytes is the same length-counted buffer (§5.2.6). There is no mixing with string: b + "s" and s + b are type errors, and the boundary is crossed only by explicit to_bytes/to_str. Any other arithmetic on a bytes operand is a compile error that names the three operators bytes does have (+, b[i], b[i:j]) rather than suggesting a numeric conversion.
Element-wise arithmetic on arrays. The five arithmetic operators also apply to two arrays, and to an array against a scalar. a OP b on two arrays yields a fresh array whose i-th element is a[i] OP b[i]; a OP s and s OP a broadcast the scalar over every element and keep the operand order, so 2 - [5, 1] is [-3, 1] and never [3, -1]. Legality is exactly the scalar rule applied per element — a OP b is legal iff a[i] OP b[i] is legal — so int and every sized integer get + - * / %, float, f32 and a numeric newtype get + - * /, and char gets + -. & | ^ << >> are not included: they are not arithmetic, so an array operand still reaches the bitwise and shift rules below and is refused there, unchanged. Both array kinds participate and their two length-mismatch rules differ; the whole rule — the kinds, the mismatches, and literal adaptation of a broadcast scalar — is §16.8.
Comparison (== != < > <= >=) and in. Both operands MUST share a type. ==/!= apply to any type except void and are structural except for function values; ordering applies only to the ordered set (§5.5). x in m tests map membership; x in xs is not provided (membership on arrays is a corelib function).
Variant test (is). v is V yields bool: true iff the enum value v currently holds variant V. The left operand MUST be an enum, an Option or a Result; anything else is a compile error. The right operand is a variant name, never an expression, and is spelled exactly as a match arm spells it — bare V for an enum of the current package, qualified pkg.V for an imported one — with the same rejection if the name is not a variant of that enum. It applies uniformly to nullary and payload-carrying variants, and binds no payload; to read a payload, use match (§19.4). is does not chain: a is X is Y is a compile error, because its result is a bool and no bool holds a variant. It binds tighter than a comparison and looser than every arithmetic operator, so a is X and b is Y groups as (a is X) and (b is Y).
For a nullary variant, v == V remains legal and equivalent — == is structural over the whole value and a nullary variant carries nothing to differ in (§5.5). The two spellings genuinely overlap there; is is the one that also works for a payload-carrying variant, where V is not a value and v == V is refused.
For an Option the two names are Some and None; for a Result, Ok and Err (§19.8). They are never package-qualified, and a name from the other family is the same compile error as an unknown enum variant.
Logical (and, or, not) operate on bool and yield bool. and and or short-circuit: the right operand is evaluated only if the result is not determined by the left (a and b evaluates b only when a is true; a or b evaluates b only when a is false). Precedence, tightest first among the logical group: not, then and, then or, all looser than any comparison.
Bitwise (& | ^ ~). Operands MUST be the same integer type.
Shift (<< >>). Both operands MUST be integers, but they need not match in width: the shifted value and the shift count are typed independently, and the result takes the left operand's type. So x << n is well-typed for a u32 x and an int n, and yields u32. >> is an arithmetic (sign-preserving) shift on signed int and a logical shift on u32/u64.
Per the Go-style precedence (§4.5) & << >> bind at the multiplicative level and | ^ at the additive level, so every bitwise operator binds tighter than a comparison: a & b == c is (a & b) == c. A shift count outside 0.. width−1 is defined: a count ≥ the operand's bit width yields 0 (every bit is shifted out), and a negative count is a program error that aborts at runtime (tycho: negative shift count), like division by zero. A negative *constant* count is rejected at compile time.
13.3 Unary operators
-e negates; ~e is bitwise NOT; &p forms an inout argument from a place (§11). These prefix operators bind tighter than every binary operator (below only the postfix group). not e is boolean negation but is not part of this tight group: it sits at a much looser precedence level — looser than every comparison and arithmetic/bitwise operator (§13.2, §4.5).
13.4 Evaluation order
The following are normative (those marked *probed* were confirmed identical on the compiler):
- Short-circuit
and/oras in §13.2. matchsubject — evaluated exactly once (*probed*), before any arm is tested.for x in xs— the collection is evaluated exactly once, before the loop.- Compound assignment — a side-effecting call inside the place is evaluated once (*probed*); a pure index sub-expression may be evaluated twice.
- A side-effecting index in an assignment place is sequenced left-to-right (*probed*): in
a[f()] = g()the indexf()is evaluated before the RHSg(), against the implementation. - The whole place is evaluated before the RHS, left-to-right (*probed*) — the general rule of which the single-index case above is an instance. A place is rooted at a variable (§13.1); a call is never a place receiver, so
p().x = eandp()[i] = eMUST be rejected and the receiver leg carries no side effect to order. The side-effecting legs of a place are therefore its index and subscript-argument sub-expressions, and they are evaluated in source order (left to right, outermost index first), all of them before the RHS: ing[f()][h()] = e()and inbs[f()].v[h()] = e()the order isf(),h(), thene(). Compound assignment orders the same legs the same way. - An f-string's interpolations are sequenced left-to-right: in
f"{a()}{b()}{c()}"the holes are evaluated in the order they are written and printed. An f-string (§3.9.5) desugars to a+chain, but its holes are sequenced where the bare+operands below are not — a hole is never short-circuited against another hole, so the sequencing is cheap and sound, and the printed order is left-to-right. - Argument and operand evaluation order is *unspecified*. Tycho does not sequence the arguments of a call or the operands of a binary operator relative to one another; their order of side effects is inherited from the target and MUST NOT be relied on. A program that needs a particular order MUST introduce explicit intermediate bindings.
What that last rule looks like when it bites — a side effect in an operand of +, and the same side effect in a call argument:
fn side(s: string) -> string:
println("call " + s)
return s
fn join2(x: string, y: string) -> string:
return x + y
fn main():
println(side("A") + side("B"))
println(join2(side("A"), side("B")))
Both lines print AB, and each is free to print call B before call A or after it. Both orders are conforming; an implementation may run either first. No output fence is paired with the example above, deliberately: recording either order would assert what this section refuses to guarantee, and a golden over it would be the same mistake a program makes when it relies on the order. The value is stable only because neither operand's result depends on the other's side effect. A program that needs A to run first says so, and then the order is a property of the statements rather than of the backend:
p := side("A")
q := side("B")
println(p + q)
The two sequenced exceptions above do not extend to this case. A place's index legs and an f-string's holes are sequences no short-circuit can cut across, so sequencing them costs one temporary each and cannot skip an evaluation the program expected to skip — whereas an argument may sit inside f(x, cond and g()), where lifting it would evaluate what the short-circuit exists to avoid. And each had an order that was already visibly contradicted: the place index was implementation-defined, and an f-string prints its holes left to right while firing them right to left, so its own output disagreed with its execution. A bare + chain shows the reader no order to contradict.
13.5 Expression-valued if and match
An if or match may produce a value when it appears in tail position — as the whole right-hand side of a :=, a typed x : T =, an x = or place-assignment, or a return. In that position:
- each branch or arm is an indented block whose final statement is a value expression, and that final expression's value is the branch's result. The common case is a single value expression (a one-statement block); leading ordinary statements (binds, side effects) may precede it and execute before the value is produced. A block whose final statement is not a value expression is rejected;
- a value
ifMUST have anelse(every path yields a value);elifchains are allowed; - a value
matchMUST be exhaustive; - all branches MUST have the same type, which becomes the type of the whole expression (§6.5). For the
:=and typedx : T =forms this is strict type equality: the branches must already synthesize the identical concrete type, and a bareNone/Ok/Errbranch is rejected — its type is fixed only by a destination type, and neither the:=nor the typed form flows one into the branches. Numeric-literal adaptation and bare-sum-constructor fixing apply only in thex =/ place-assignment /returntail positions, where a destination type flows in.
The value lands in the destination's storage exactly as a returned value does; it introduces no new aliasing. A branch that diverges (e.g. returns) instead of yielding, and use as a nested sub-expression (1 + if …), are not part of the value form; the statement forms (§14) cover those.
13.6 Closures and function values
A fn(params) -> R: expr lambda and a named function both produce first-class function values. A closure captures by deep copy at creation: the values of the free variables it references are copied into the closure's environment when the closure is formed, so later mutation of the originals is not observed by the closure (value semantics). A returned closure's captured environment is deep-copied into the caller's storage on return, like any escaping value. A function that has an inout parameter cannot be used as a first-class value (§15). Function values are not comparable (§5.5).
"A named function" includes a package-qualified one: pkg.fn, written without parentheses, is the function value for an imported package's exported fn, exactly as a bare name is for a local one. Package privacy applies unchanged — a _name is not reachable this way either.
A generic function is the one named form that has no function value. $T is fixed by a call's arguments, and a value form has no arguments, so there is no instantiation to take; pkg.gen and a bare gen are both refused. Fix the types in a lambda instead — fn(a: int, b: int) -> int: math.min(a, b) is an ordinary call site, so $T binds there as usual.
13.7 Other expression forms
- f-strings desugar to
str-concatenation (§3.9.5); a hole MUST have a typestraccepts (§8.2). or_returnis a postfix operator that unwraps anOption/Resultor short-circuits out of the enclosing function (§19); it binds tighter than any binary operator.or_else x: his the same postfix unwrap, but on failure it returnshfrom the enclosing function, with the error bound tox(§14.6.1).spawn,channel(…), and the concurrency operators are specified in §20; each has grammar restrictions noted in §4.4.