Operators are the compact half of Atoll’s expression grammar. Parsing decides how they group; the checker decides whether the operand types support them. Nothing here is dynamic — every operator resolves to a primitive lowering or to one named prelude trait at compile time.
Here is most of the surface in one unit you can paste into a file and run:
fn main(): void {
// arithmetic and comparison
price := 250
quantity := 4
subtotal := price * quantity
discounted := subtotal - subtotal / 10
// logic — short-circuiting, no truthiness
in_stock := quantity > 0
affordable := discounted <= 1000
can_order := in_stock and affordable
// bitwise
flags := 0b1010
readable := flags & 0b0010 != 0
// ranges and membership
tiers := [1, 2, 3]
known_tier := tiers.contains(2)
in_window := 250 in 100..500
// option operators
first: int? = tiers.get(0)
head := first ?? 0
// casts
as_bytes := discounted as u16
println("${subtotal} ${discounted} ${can_order} ${readable}")
println("${known_tier} ${in_window} ${head} ${as_bytes}")
}Arithmetic
| Operator | Meaning | Trait on user types |
|---|---|---|
a + b |
addition | Numeric.add |
a - b |
subtraction | Numeric.sub |
a * b |
multiplication | Numeric.mul |
a / b |
division | Numeric.div |
a % b |
remainder | primitive only |
-a |
negation | Neg.neg |
Division keeps the operand category: int / int truncates, float / float
does not. % is a primitive integer/float operation — Numeric does not
supply it, so it is not available on user types.
fn main(): void {
println("${7 / 2}") // 3 — integer division truncates
println("${7.0 / 2.0}") // 3.5 — float division does not
println("${7 % 2}") // 1
println("${-7 % 2}") // -1 — sign follows the dividend
position := 12
println("${-position}")
}There is no implicit numeric promotion. Mixing an int and a float in one
arithmetic expression is an error, not a widening:
fn main(): float {
return 1 + 2.5
}Write the conversion you mean:
fn main(): float {
n := 1
return n.to_float() + 2.5
}+ also concatenates strings.
fn greeting(name: string): string {
return "hello, " + name
}
fn main(): void {
println(greeting("ada"))
}Comparison
| Operator | Meaning | Trait on user types |
|---|---|---|
a == b |
equal | Equatable.equals |
a != b |
not equal | Equatable.equals |
a < b |
less than | Comparable[T].compare_to |
a <= b |
less or equal | Comparable[T].compare_to |
a > b |
greater than | Comparable[T].compare_to |
a >= b |
greater or equal | Comparable[T].compare_to |
Structs and enums whose fields are all comparable get structural equality,
ordering, and hashing at the operator level without any declaration. The
compiler compares them field by field in declaration order, so ==, <, and
use as a Set or Map key all work on a bare struct:
struct Version {
major: int
minor: int
}
fn main(): void {
a := Version { major: 1, minor: 5 }
b := Version { major: 2, minor: 0 }
println("${a == b} ${a != b} ${a < b}") // false true true
mut seen := Set.new[Version]()
seen.add(a)
seen.add(a)
println("${seen.size()}") // 1 — structural hashing
}That synthesis covers operator syntax only. A generic bound such as the
T: Comparable[T] that sorted() requires is checked against declared
implementations, and the structural fallback does not satisfy it:
struct Version {
major: int
minor: int
}
fn main(): void {
releases := [Version { major: 2, minor: 0 }, Version { major: 1, minor: 5 }]
oldest := releases.sorted().get(0)
println("${oldest?.major ?? 0}")
}Declare the implementation when a generic function has to see it. compare_to
returns a negative number, zero, or a positive number:
struct Version {
major: int
minor: int
}
impl Equatable for Version {
fn equals(self, other: Version): bool {
return self.major == other.major and self.minor == other.minor
}
}
impl Comparable[Version] for Version {
fn compare_to(self, other: Version): int {
if self.major != other.major { return self.major - other.major }
return self.minor - other.minor
}
}
fn main(): void {
releases := [Version { major: 2, minor: 0 }, Version { major: 1, minor: 5 }]
ordered := releases.sorted()
oldest := ordered.get(0)
println("${oldest?.major ?? 0}.${oldest?.minor ?? 0}") // 1.5
}Comparisons do not chain. a < b < c parses as (a < b) < c, and the checker
rejects comparing a bool with an int:
fn main(): bool {
a := 1
b := 2
c := 3
return a < b < c
}Write the conjunction explicitly:
fn between(value: int, low: int, high: int): bool {
return low <= value and value <= high
}
fn main(): void {
println("${between(2, 1, 3)} ${between(9, 1, 3)}")
}Logic
and / && and or / || are the same operators spelled two ways; not and
! are the same negation. Both operands must already be bool. Atoll has no
truthiness — an int, a string, a collection, or an Option is never
implicitly a condition.
fn main(): void {
n := 5
if n { println("nonzero") }
}fn main(): void {
n := 5
if n != 0 { println("nonzero") }
ready := true
println("${not ready} ${!ready} ${ready && !ready} ${ready || false}")
}Both operators short-circuit. This unit records what actually ran, so you can see the right operand being skipped:
fn main(): void {
mut log: []string = []
note := (label: string, value: bool) => {
log.add(label)
value
}
a := note("or-left", true) or note("or-right", false) // right skipped
b := note("and-left", false) and note("and-right", true) // right skipped
c := note("or-left2", false) or note("or-right2", true) // right runs
d := note("and-left2", true) and note("and-right2", true)// right runs
println("${a} ${b} ${c} ${d}")
for entry in log { println(entry) }
}The log holds or-left, and-left, or-left2, or-right2, and-left2,
and-right2 — the two skipped calls never appear.
Prefix ! and not dispatch through the Not trait on a user type.
Bitwise and shifts
| Operator | Meaning | Trait on user types |
|---|---|---|
a & b |
bitwise AND | BitAnd.bit_and |
a | b |
bitwise OR | BitOr.bit_or |
a ^ b |
bitwise XOR | BitXor.bit_xor |
~a |
bitwise complement | BitNot.bit_not |
a << n |
left shift | Shl.shl |
a >> n |
right shift | Shr.shr |
const READ: int = 0b0001
const WRITE: int = 0b0010
const EXEC: int = 0b0100
fn main(): void {
mut perms := READ | WRITE
println("${perms}")
perms = perms | EXEC // grant
perms = perms & ~WRITE // revoke
println("${perms}")
println("${perms & READ != 0}") // still readable
println("${perms ^ EXEC}") // toggle
println("${1 << 4} ${256 >> 3}")
}Note the grouping in perms & READ != 0. Unlike C, Atoll binds &, ^, and
| tighter than the comparison operators, so that reads as
(perms & READ) != 0 and needs no parentheses. Shifts, on the other hand, bind
looser than +: 1 << 2 + 1 is 1 << 3, which is 8.
Prefix & is a different operator from infix & — it takes a reference. The
parser never confuses them because a prefix & can only appear where an
expression starts.
struct Packet {
id: int
payload: string
}
fn describe(view: &Packet): string {
return "packet ${view.id}"
}
fn main(): void {
packet := Packet { id: 7, payload: "ping" }
view: &Packet = &packet
println(describe(view))
}Ranges
lo..hi is half-open; lo..=hi includes the upper endpoint. Both are ordinary
expressions producing Range[T] and RangeInclusive[T], each with start and
end fields.
fn width(r: Range[int]): int {
return r.end - r.start
}
fn main(): void {
half_open := 2..7
closed := 2..=7
println("${half_open.start} ${half_open.end}")
println("${closed.start} ${closed.end}")
println("${width(3..9)}")
mut total := 0
for i in 0..4 { total = total + i } // 0 1 2 3
for i in 1..=3 { total = total + i } // 1 2 3
println("${total}")
}Ranges bind looser than arithmetic, so 0..n + 1 means 0..(n + 1) — the
common “one past the end” idiom needs no parentheses.
Range patterns are their own thing in match:
fn classify(status: int): string {
return match status {
200..=299 => "success"
300..=399 => "redirect"
400..=499 => "client error"
500..=599 => "server error"
_ => "unknown"
}
}
fn main(): void {
println(classify(204))
println(classify(404))
println(classify(101))
}Membership
value in range is a bounds test that lowers to a pair of comparisons — no
Range value is built.
fn is_printable_ascii(code: int): bool {
return code in 32..127
}
fn main(): void {
println("${5 in 0..10}")
println("${10 in 0..10}") // false — the upper bound is excluded
println("${10 in 0..=10}") // true
println("${is_printable_ascii(65)}")
}in against a collection type-checks, but it does not yet lower — the code
generator rejects it. Use the collection’s own membership method, which is
also the shorter read:
fn main(): void {
tiers := [1, 2, 3]
mut ids := Set.new[int]()
ids.add(42)
mut labels := Map.new[string, int]()
labels.put("gold", 3)
println("${tiers.contains(2)}")
println("${ids.contains(42)}")
println("${labels.contains_key("gold")}")
println("${"hello".contains("ell")}")
}The same in token separates the pattern from the iterable in a for head;
the loop parser suppresses the expression-level meaning while it looks for its
own in.
Option operators
?? supplies a fallback for an absent Option, and only evaluates its right
side when the left is None. ?. walks a member chain, propagating None
instead of failing.
struct Profile {
city: string
}
struct Account {
profile: Profile?
}
fn city_of(account: Account?): string {
return account?.profile?.city ?? "unknown"
}
fn main(): void {
filled := Account { profile: Some(Profile { city: "oslo" }) }
hollow := Account { profile: None }
println(city_of(Some(filled)))
println(city_of(Some(hollow)))
println(city_of(None))
}The right operand of ?? is the unwrapped type, not another Option, and
?? is left-associative. A chain of optional sources therefore needs
parentheses:
fn main(): string {
a: string? = None
b: string? = None
return a ?? b ?? "fallback"
}fn main(): string {
a: string? = None
b: string? = None
return a ?? (b ?? "fallback")
}?? is an Option operator only. It does not discharge a Result; use
catch, ?, or unwrap_or for that.
error LoadError { Missing }
fn load(): int ! LoadError { error Missing }
fn main(): int {
return load() ?? 0
}error LoadError { Missing }
fn load(): int ! LoadError { error Missing }
fn main(): int {
return load().unwrap_or(0)
}?. requires an optional receiver — applying it to a plain value is an error,
because there is no absence to propagate:
struct Point { x: int }
fn main(): int {
p := Point { x: 1 }
return p?.x
}Propagation
Postfix ? unwraps the success side and returns the failure side to the
caller. It works on Result in a fallible function and on Option in a
function that returns an Option.
error LoadError { NotFound }
fn load_user(id: int): string ! LoadError {
if id < 0 { error NotFound }
return "ada"
}
fn greet(id: int): string ! LoadError {
name := load_user(id)?
return "hello, ${name}"
}
fn doubled_head(values: []int): int? {
head := values.get(0)?
return Some(head * 2)
}
fn main(): void {
println("${doubled_head([4, 5]) ?? -1}")
println("${doubled_head([]) ?? -1}")
}Casts
value as T requests an explicit conversion. Between primitives it is a
representation change; on a user type it dispatches to a method named after the
target — to_int, to_float, to_string.
fn main(): void {
n := 300
println("${n as u8} ${n as i8} ${n as u16} ${n as i64} ${n as usize}")
ratio := 2.75
println("${ratio as int} ${ratio as f32}")
letter: byte = 65
println("${letter as int}")
println("${42 as string}")
}struct Celsius {
degrees: float
}
impl Celsius {
fn to_int(self): int { return self.degrees.to_int() }
fn to_float(self): float { return self.degrees }
fn to_string(self): string { return "${self.degrees}C" }
}
fn main(): void {
reading := Celsius { degrees: 21.5 }
println("${reading as int} ${reading as float} ${reading as string}")
}Writing an unrelated target type does not make the conversion legal — the checker looks for the conversion method and reports its absence:
struct Packet { id: int }
fn main(): int {
p := Packet { id: 1 }
return p as int
}There is no value is Type runtime type test in Atoll. is is a reserved word
used by the query grammar, and it does not parse as an expression operator:
fn check(v: int | string): bool {
return v is int
}Refine a closed set of alternatives with match instead.
Assignment
:= declares; = updates an existing mutable place. The two are not
interchangeable.
fn main(): void {
mut count := 0 // declare
count = count + 1 // update
println("${count}")
}Compound assignment reads, operates, and writes one place.
| Group | Operators |
|---|---|
| arithmetic | += -= *= /= %= |
| bitwise | &= |= ^= <<= >>= |
| option | ??= |
struct Counter {
hits: int
}
fn main(): void {
mut total := 10
total += 5
total -= 2
total *= 3
total /= 2
total %= 7
mut flags := 0b1010
flags &= 0b1100
flags |= 0b0001
flags ^= 0b1111
flags <<= 2
flags >>= 1
mut c := Counter { hits: 0 }
c.hits += 1
mut cached: string? = None
cached ??= "computed"
println("${total} ${flags} ${c.hits} ${cached ?? "-"}")
}??= stores the fallback only when the target is None, and evaluates the
right side only in that case.
Assignment is a statement, not an expression: it produces void, so it cannot
be chained or embedded.
fn main(): int {
mut a := 0
mut b := 0
a = b = 5
return a + b
}For an element of a list, prefer the explicit read-modify-write form; it is
also clearer about the Option that indexing returns.
fn main(): void {
mut items := [1, 2, 3]
items[0] = 99
items[1] = (items[1] ?? 0) + 10
println("${items[0] ?? -1} ${items[1] ?? -1}")
}Precedence
From tightest to loosest, as the parser groups them:
| Level | Forms | Associativity |
|---|---|---|
| 14 | postfix . ?. () [] ? as catch |
left |
| 13 | prefix - ! not ~ & |
right |
| 12 | ?? |
left |
| 11 | * / % |
left |
| 10 | + - |
left |
| 9 | << >> |
left |
| 8 | & |
left |
| 7 | ^ |
left |
| 6 | | |
left |
| 5 | .. ..= |
non-chaining |
| 4 | == != < <= > >= in |
non-chaining |
| 3 | and && |
left |
| 2 | or || |
left |
| 1 | |> (parses, does not check — see below) |
left |
| 0 | = and every compound assignment |
right |
Three levels differ from C-family languages and are worth committing to memory:
??binds tighter than*, somaybe ?? 2 * 3is(maybe ?? 2) * 3.- the bitwise operators bind tighter than comparison, so
flags & MASK != 0is(flags & MASK) != 0. - ranges bind looser than arithmetic, so
0..n + 1is0..(n + 1).
fn main(): void {
missing: int? = None
present: int? = Some(7)
println("${missing ?? 2 * 3}") // (0 ?? 2) * 3 == 6
println("${present ?? 2 * 3}") // 7 * 3 == 21
flags := 0b1010
println("${flags & 0b0100 != 0}") // (flags & 4) != 0 == false
n := 3
r := 0..n + 1
println("${r.start} ${r.end}") // 0 4
println("${1 << 2 + 1}") // 1 << 3 == 8
}When a mixed expression is not obvious at a glance, parenthesise it. The compiler does not need the parentheses; the next reader does.
Overloading
Operator syntax is statically dispatched. The checker handles the primitive cases first, then looks for one specific prelude trait implemented for the operand type. It never searches for an arbitrary similarly named method.
| Operator | Trait | Method |
|---|---|---|
+ - * / |
Numeric |
add sub mul div |
-a |
Neg |
neg |
!a / not a |
Not |
not |
~a |
BitNot |
bit_not |
& | ^ |
BitAnd / BitOr / BitXor |
bit_and / bit_or / bit_xor |
<< >> |
Shl / Shr |
shl / shr |
== != |
Equatable |
equals |
< <= > >= |
Comparable[T] |
compare_to |
xs[k] |
Index[K, V] |
get |
xs[k] = v |
IndexMut[K, V] |
set |
"${x}" |
Display |
to_string |
| map/set keys | Hashable |
hash_code |
There is no Add trait — arithmetic is one contract, Numeric, and it also
carries the to_int / to_float conversions that back as int and as float.
Because Numeric extends Comparable[Self], which extends Equatable, a type
that wants + must supply ordering and equality too:
struct Money {
cents: int
}
impl Equatable for Money {
fn equals(self, other: Money): bool {
return self.cents == other.cents
}
}
impl Comparable[Money] for Money {
fn compare_to(self, other: Money): int {
return self.cents - other.cents
}
}
impl Numeric for Money {
fn to_int(self): int { return self.cents }
fn to_float(self): float { return self.cents.to_float() }
fn add(self, other: Money): Money { return Money { cents: self.cents + other.cents } }
fn sub(self, other: Money): Money { return Money { cents: self.cents - other.cents } }
fn mul(self, other: Money): Money { return Money { cents: self.cents * other.cents } }
fn div(self, other: Money): Money { return Money { cents: self.cents / other.cents } }
}
impl Display for Money {
fn to_string(self): string {
return "$${self.cents / 100}.${self.cents % 100}"
}
}
fn main(): void {
price := Money { cents: 250 }
fee := Money { cents: 125 }
println("${price + fee}")
println("${price - fee}")
println("${price > fee} ${price == fee}")
println("${price as int}")
}Indexing is two traits, one per direction. Index[K, V] backs the read
xs[k]; note that unlike a builtin List, a user Index implementation
returns V directly rather than V?, so the implementation decides what an
out-of-range key means.
struct Grid {
cells: []int
width: int
}
impl Index[int, int] for Grid {
fn get(self, key: int): int {
return self.cells[key] ?? 0
}
}
impl Grid {
fn at(self, row: int, col: int): int {
return self[row * self.width + col]
}
}
fn main(): void {
g := Grid { cells: [1, 2, 3, 4], width: 2 }
println("${g[0]} ${g[3]}")
println("${g.at(1, 0)}")
println("${g[99]}") // the impl's own fallback, not a trap
}IndexMut[K, V] backs the write xs[k] = v, routing it to set:
struct Grid {
cells: []int
}
impl Index[int, int] for Grid {
fn get(self, key: int): int {
return self.cells[key] ?? 0
}
}
impl IndexMut[int, int] for Grid {
fn set(mut self, key: int, value: int): void {
self.cells[key] = value
}
}
fn fill(mut g: Grid, value: int): void {
for i in 0..4 {
g[i] = value
}
}
fn main(): void {
mut g := Grid { cells: [1, 2, 3, 4] }
g[2] = 30
println("${g[2]}")
fill(g, 7)
println("${g[0]} ${g[3]}")
}The unary and bitwise traits follow the same shape:
struct Mask {
bits: int
}
impl BitAnd for Mask {
fn bit_and(self, other: Mask): Mask { return Mask { bits: self.bits & other.bits } }
}
impl BitOr for Mask {
fn bit_or(self, other: Mask): Mask { return Mask { bits: self.bits | other.bits } }
}
impl BitXor for Mask {
fn bit_xor(self, other: Mask): Mask { return Mask { bits: self.bits ^ other.bits } }
}
impl BitNot for Mask {
fn bit_not(self): Mask { return Mask { bits: ~self.bits } }
}
impl Shl for Mask {
fn shl(self, n: int): Mask { return Mask { bits: self.bits << n } }
}
impl Shr for Mask {
fn shr(self, n: int): Mask { return Mask { bits: self.bits >> n } }
}
fn main(): void {
a := Mask { bits: 0b1100 }
b := Mask { bits: 0b1010 }
combined := ((a & b) | (a ^ b)) & ~b
println("${(combined << 1).bits} ${(combined >> 1).bits}")
}An implementation’s return type need not be the left operand’s type — it must
only satisfy the trait signature and the expected type at the use site. Keep
the usual laws: equality reflexive, symmetric, and transitive; ordering
consistent with equality; equal values hashing equally; and a += b meaning
exactly a = a + b.
Effects are not waived by operator syntax. If an implementation can fail or suspend, that shows up in the effect row of every function that uses the operator.
Pipe-forward
|> occupies a precedence slot in the lexer and parser, but no later pass
reshapes it into a call. Sema types the whole expression as the right-hand
operand — the function value itself — and MIR lowers it to nothing. Every
pipeline is therefore a type error today, with a diagnostic that mentions the
function type rather than the pipe:
fn double(x: int): int { return x * 2 }
fn main(): int {
return 5 |> double
}The error reads type fn(int) -> int does not implement ..., which is the
tell: the pipe contributed nothing and the left operand was discarded.
Write the nested call, or bind the intermediate steps, until the feature lands; see Feature Status.
fn parse_count(raw: string): int { return raw.len() }
fn normalize(n: int): int { return n * 10 }
fn main(): void {
counted := parse_count("abc")
println("${normalize(counted)}")
println("${normalize(parse_count("abcd"))}")
}Putting it together
One function that uses most of the page: bitwise flags, compound assignment,
range patterns, ??, integer division, and ? propagation through a fallible
call discharged with unwrap_or.
const FLAG_PAID: int = 0b0001
const FLAG_RUSH: int = 0b0010
error OrderError { EmptyCart }
struct Line {
unit_cents: int
quantity: int
}
impl Line {
fn total_cents(self): int { return self.unit_cents * self.quantity }
}
struct Order {
lines: []Line
flags: int
coupon: string?
}
fn discount_bps(order: Order): int {
code := order.coupon ?? ""
return match code.len() {
0 => 0
1..=4 => 500
_ => 1000
}
}
fn settle(order: Order): int ! OrderError {
if order.lines.len() == 0 { error EmptyCart }
mut gross := 0
for line in order.lines {
gross += line.total_cents()
}
// `&` binds tighter than `!=`, so no parentheses are needed here.
net := gross - gross * discount_bps(order) / 10000
rush := if order.flags & FLAG_RUSH != 0 { 900 } else { 0 }
return net + rush
}
fn main(): void {
order := Order {
lines: [Line { unit_cents: 250, quantity: 4 }, Line { unit_cents: 1200, quantity: 1 }],
flags: FLAG_PAID | FLAG_RUSH,
coupon: Some("SAVE"),
}
println("${settle(order).unwrap_or(-1)}")
println("${settle(Order { lines: [], flags: 0, coupon: None }).unwrap_or(-1)}")
}