Skip to content

Operators

Every operator candela has, with its precedence, the operand types it accepts, and the value it produces.

Precedence

Binary operators bind in this order, loosest first. All are left-associative except ^.

Level Operators Associativity
1 \|\| left
2 && left
3 == != left
4 < <= > >= is as left
5 \| left
6 ^^ left
7 & left
8 << >> left
9 + - left
10 * / % left
11 ^ right

2 ^ 3 ^ 2 is 2 ^ (3 ^ 2).

is and as take a type on their right rather than an expression, at the level of the comparisons: a + b as int is (a + b) as int, v as int + 1 is (v as int) + 1, and v is int && v > 0 tests before it compares. See type tests and downcasts.

The order is Rust's. The bitwise operators bind tighter than a comparison, so flags & mask == mask asks whether flags & mask equals mask, and the shifts bind looser than +, so 1 << n + 1 shifts by n + 1.

Five forms bind tighter than every binary operator: a function call f(x), an index or slice a[i], a field access p.x, a method call p.len(), and x?. They attach to the term they follow, so a.b[0] ^ 2 raises a.b[0] to the power of two, and they chain: adder(1)(2) calls what adder(1) returned, fs[0](x) calls the element it indexed, and m.get(k)?.len() reads the length of the value a lookup found.

The prefix operators -, ! and ~ bind tighter than ^ and looser than the postfix forms. -a ^ 2 is (-a) ^ 2 and !ok == done is (!ok) == done. Use parentheses when you want the other reading.

Types are never mixed

Both operands of an arithmetic, bitwise or comparison operator must already have the same type. candela does not promote int to float, so 1 + 2.0 is a compile error. Convert first with float() or int():

let total = float(count) + 2.0;

int is a signed 64-bit integer and float is a 64-bit binary floating-point number. Arithmetic on int wraps at the ends of that range. See types.

That rule is about the built-in types. A struct or an enum of your own settles what an operator on it accepts, including a second operand of another type; see user types below.

Arithmetic

Operator Operand types Result
+ int, int int
+ float, float float
+ string, string string, the two joined
+ T[], T[] T[], a new array holding both
- * / % ^ int, int int
- * / % ^ float, float float

+ on arrays builds a new array and leaves both operands untouched:

let a = [1, 2];
let b = a + [3];
print(a);   // [1, 2]
print(b);   // [1, 2, 3]

Integer division truncates towards zero, and % takes the remainder with the sign of the left operand. Integer division or remainder by zero raises the runtime errors division_by_zero and modulo_by_zero, both catchable; see error handling. Float division and remainder follow IEEE 754 and produce infinities or NaN instead of raising.

^ raises the left operand to the power of the right. With int operands the exponent must not be negative: a negative literal is rejected before the program runs, and a negative value only known at run time raises the catchable negative_exponent. Use floats when you want a fractional result.

Integer arithmetic wraps on overflow.

An any value cannot be used as an arithmetic operand. Narrow it first with v as int or v as float, or test it with is.

Bitwise

&, |, ^^, ~, << and >> work on the bits of an int and produce an int. ^ is the power operator, so xor takes the doubled spelling ^^, the way && and || take theirs.

Operator Operand types Result
& int, int int, the bits set in both
\| int, int int, the bits set in either
^^ int, int int, the bits set in one of the two
~ int int, every bit flipped
<< int, int int, the bits moved up
>> int, int int, the bits moved down

No other operand type is accepted, and the types are never mixed: 1.5 & 1 is the same compile error 1.5 * 1 is, and an any value has to be narrowed with v as int first.

let read = 4;
let write = 2;
let mode = read | write;
print(mode & read == read);   // true
print(~0);                    // -1

int is signed, so >> is arithmetic: the vacated places take the sign bit, and -16 >> 2 is -4. << fills with zeroes and wraps like the rest of integer arithmetic, so 1 << 63 is the smallest int.

An int holds 64 bits, so both shifts take a count between 0 and 63. A count written as a literal outside that range is refused before the program runs; a count only known at run time raises the catchable shift_count_out_of_range.

Comparison

<, <=, > and >= produce a bool. Both operands have to be the same type, and that type has to be one of three:

Operator Operand types Result
< <= > >= int, int bool
< <= > >= float, float bool
< <= > >= string, string bool

Strings order by their bytes, which is the order sort puts a list of strings in. A string that prefixes another comes first, so "ab" < "abc". Bytes are not letters: every uppercase ASCII letter comes before every lowercase one, so "Z" < "a", and every ASCII character comes before a character that takes more than one byte. Lowercase both sides when you want a case-insensitive order.

fn main() {
    let names = ["pear", "Apple", "fig"];
    names.sort();
    print(names);            // ["Apple","fig","pear"]
    print("fig" < "pear");   // true
}

There is no ordering on arrays, maps, structs or enums.

Equality

== and != produce a bool and accept operands of any type. Two values of different types are never equal, so "5" == 5 is false and "5" != 5 is true. That holds whether the mismatch is visible at compile time or only shows up at run time through a parameter whose type comes from the call. Comparing values of the same type is the case worth writing.

Operand type How it compares
int, float, bool, null by value
string by contents
T[] by length, then element by element
struct by type, then field by field
enum by variant, then payload
map by length, then key by key

Comparison recurses, so nested collections compare all the way down.

Floats compare by their exact representation: 0.0 == -0.0 is false, and any comparison involving NaN is false.

Logical

&&, || and ! take bool operands and produce a bool. No other type is accepted, so there is no truthiness; write the comparison out:

if count > 0 && !done {
    print("working");
}

&& and || short-circuit wherever they appear: the right operand is not evaluated when the left one already settles the answer. That holds in a condition, in a let, in an argument, and in a returned expression alike, so a right operand with a side effect runs only when it is reached.

Type tests and downcasts

v is T produces a bool: whether v holds a T. v is Some(x) tests an enum value's variant with the pattern a match arm writes. v as T produces v typed T, and raises the catchable bad_downcast when it holds another type. T is any type an annotation can name, read up to the end of the type, so v is int | string tests for the union.

if v is int && v > 0 {
    print(v + 1);
}
let cfg = json::parse(text) as {string: any};

Where the test held, a tested variable has the type T and a variant's names hold its payload: in the body of the if or while it is the condition of, and to the right of an && after it. See types.

User types

A struct or an enum defines an operator by declaring a method named with the symbol, and the operator on a value of that type calls it. See methods.

impl Vec2 {
    fn +(self, other: Vec2) -> Vec2 {
        return Vec2 { x: self.x + other.x, y: self.y + other.y };
    }
}
Operators Form
+ - * / % ^ & \| ^^ << >> fn op(self, other)
== < <= fn op(self, other) -> bool
- ~ fn op(self)

- is the one symbol with both forms, and the parameter count says which is being declared. !=, > and >= have no method: they come from ==, < and <=. A derived comparison swaps its operands, so it evaluates the right one first.

The other operand is of the receiver's type unless the method declares otherwise. Only the left operand picks the method, so 2.0 * v stays a type error however Vec2 defines *. The built-in types are closed: an operator method inside impl int or impl string is a compile error. &&, ||, !, = and the compound assignments are not defined by a type; the compound forms apply the matching operator instead.

Assignment

= assigns to a variable, an array element, a map entry, or a struct field:

count = 4;
row[0] = 9;
ages["ada"] = 36;
point.x = 3;

Assignment is a statement, not an expression, so it produces no value and cannot be chained.

The compound forms +=, -=, *=, /=, %=, ^=, &=, |=, ^^=, <<= and >>= apply the matching binary operator to the current value and assign the result. x += 1 is x = x + 1, with the same type rules, and every assignable target is allowed:

total += price;
counts[i] += 1;
tally["a"] += 1;
point.x *= 2;
flags |= read;

A compound form on a value of your own type applies the operator that type defines, so v += w reaches its + method and needs nothing of its own.

Use let to introduce a name and = to change one; see variables.

Indexing and slicing

a[i] reads one element of an array, or one character of a string as a one-character string. The index must be an int and must be within bounds; a negative index does not count from the end, and an out-of-range index raises the catchable index_out_of_bounds.

a[i..j] takes a slice from i up to but not including j. a[..j] starts at zero and a[i..] runs to the end. The bounds must be int. Either bound may equal the length, and a slice that falls outside the value raises slice_out_of_bounds.

The same .. closes a struct literal, Point { x: 1, ..origin }, where it takes the fields the literal does not write from another value; see types.

let word = "candela";
print(word[0]);      // c
print(word[0..4]);   // cand

Positions on a string count characters, so word[0..4] on a four-letter word ending in an accented vowel takes the whole word, and word[3] reads the accented vowel. A character is a Unicode scalar value, whatever it takes to store.

m[k] reads the value a map holds under the key k, which has to be of the map's key type, and raises unknown_map_key when the map holds no such key. m[k] = v writes the entry, adding it or replacing its value. m.get(k) is the lookup that answers None instead of raising; see collections.

Propagation

x? takes an Option or a Result. On a Some or an Ok it is the value inside; on a None or an Err it returns that value from the enclosing function at once. The function has to return the same enum, with the same error type for a Result, so ? works only inside one:

fn first_word_length(words: string[]) -> Option<int> {
    let w = words.first()?;
    return Some(w.len());
}

See error handling.

Access and calls

. reads a struct field or calls a method on a value. :: separates the parts of a namespaced name: a module bound with import ... as, an enum variant, a host or dylib block, a host block's struct, or a function inside any of them.

print(point.x);
print(name.len());
print(name.capitalize());
let c = Colour::Red;

A host or dylib block's name is a namespace, so its functions are called as app::rows(id); app.rows(id) is a compile error. A variable of that name is a value, and a dot on it calls the value's own method. See C libraries.

See methods, enums and modules.

Ranges

.. builds the range of a for loop and appears nowhere else. Both bounds must be int, and the end is exclusive. for i in ..n starts at zero.

for i in 0..3 {
    print(i);       // 0, 1, 2
}

See control flow.

Symbols that are not operators

  • | is not an operator where a type is read: it separates the members of a union type, as in int | string, including the type after is and as. In a match pattern it separates alternatives, as in 1 | 2 => .... Everywhere else an expression is read it is bitwise or; parenthesise to use bitwise or inside a pattern, (a | b).
  • as after an import's path names the module's alias, as in import "std/json";.
  • ... marks a variadic host function in a host block. See embedding.
  • -> gives the return type in a dylib or host signature. => means "evaluates to": it separates a match pattern from its body, and gives a function a body of one expression, as in fn(x) => x * 2.

Constant expressions

An expression built only from literals is folded while the file is parsed, so it costs nothing at run time. Folding applies the same type rule as everything else: 2.0 ^ 3 is rejected for mixing a float with an int, exactly as it would be if the operands were variables.

Folding also turns four mistakes into compile errors rather than runtime ones: dividing by a literal 0, taking the remainder by a literal 0, raising an integer to a negative literal exponent, and shifting by a literal count outside 0 to 63. Those apply to integer arithmetic. A float divided or remaindered by 0.0 follows IEEE 754 and produces an infinity or NaN, so it is folded rather than rejected.