Docs · Language
Operators
Every binary operator in Vyi is a method call: a + b means a.@add(b),
xs[i] means xs.@index(i). The compiler implements these methods for the
primitive types; user types implement them as ordinary scope-resolved methods.
This page is the exhaustive table form — precedence, the operator↔method
mapping, compound assignment, and the built-in semantics. The narrative
introduction is guide
13 — Operators and overloading.
Precedence
From loosest to tightest binding. All binary operators of one level associate left:
| Level | Operators | Class |
|---|---|---|
| 1 | .. ... | range (does not chain) |
| 2 | || | logical or (short-circuit) |
| 3 | && | logical and (short-circuit) |
| 4 | | | bitwise or |
| 5 | ^ | bitwise xor |
| 6 | & | bitwise and |
| 7 | == != | equality |
| 8 | < > <= >= | comparison |
| 9 | << >> | shift |
| 10 | + - | additive |
| 11 | * / % | multiplicative |
| 12 | - ! * (deref) & (address-of) | unary prefix |
| 13 | .field [index] (call) | postfix chain, left to right |
Two syntactic rules that interact with precedence:
- A newline ends the expression. A binary operator must sit on the same
line as its left operand; there is no line-continuation form.
- Ranges do not chain —
1..2..3is a parse error.
The consequences, verified:
import { stdout } from "@io"
fn main() !u8 {
mut ok = 0
if 2 + 3 * 4 == 14 { ok = ok + 1 } // * before +, arithmetic before ==
if (2 + 3) * 4 == 20 { ok = ok + 1 } // parens override
if 10 - 2 - 3 == 5 { ok = ok + 1 } // left associative
if 1 << 2 + 3 == 32 { ok = ok + 1 } // + before <<
if 1 << 3 < 10 { ok = ok + 1 } // << before <
if 3 < 4 && 5 < 6 { ok = ok + 1 } // comparison before &&
if true || false && false { ok = ok + 1 } // && before ||
con masked = (1 | 2 ^ 3 & 5) == 3 // & then ^ then |
if masked { ok = ok + 1 }
stdout.writeInt(i64(ok)) // prints 8
stdout.write("\n")
return Ok(0)
}Bitwise &/|/^ sit looser than ==/!= (matching C), so mask tests
need parentheses — without them the equality is computed first and the operand
types no longer line up:
fn main() !u8 {
con flags = 6
con x = flags & 4 != 0 // parses as flags & (4 != 0): i32 & bool
return Ok(0)
}Write (flags & 4) != 0.
Binary operators → methods
The left operand is the receiver; the right operand is the single argument. Both operands must have the same type — there is no implicit numeric widening, and the desugar target's parameter type is the receiver's type.
| Operator | Desugars to | Notes |
|---|---|---|
+ | @add | |
- | @sub | |
* | @mul | |
/ | @div | |
% | @rem | |
== | @eq | returns bool |
!= | @eq | derived: result negated |
< | @lt | returns bool |
<= | @le | returns bool |
> | @lt | derived: operands swapped — a > b is b.@lt(a) |
>= | @le | derived: operands swapped |
& | @band | infix; prefix & is address-of |
| | @bor | infix; | in type position builds a union |
^ | @bxor | |
<< | @shl | |
>> | @shr | |
&& | — | not a method: short-circuits, both sides bool |
|| | — | not a method: short-circuits, both sides bool |
There is no @neq, @gt, or @ge — !=, >, and >= are always derived
from @eq, @lt, and @le, so three methods buy the whole comparison set.
import { stdout } from "@io"
struct Vec2 { x: i32, y: i32 }
fn (a: Vec2) @add(b: Vec2) Vec2 {
return Vec2{ x: a.x + b.x, y: a.y + b.y }
}
fn (a: Vec2) @eq(b: Vec2) bool {
return a.x == b.x && a.y == b.y
}
fn main() !u8 {
con a = Vec2{ x: 1, y: 2 }
con b = Vec2{ x: 3, y: 4 }
con c = a + b // a.@add(b)
con d = a.@add(b) // the explicit spelling — same call
mut ok = 0
if c == d { ok = ok + 1 } // a.@eq(b)
if c != a { ok = ok + 1 } // !(c.@eq(a)) — derived
stdout.writeInt(i64(c.x + c.y + ok)) // prints 4 + 6 + 2 = 12
stdout.write("\n")
return Ok(0)
}Using an operator with no method in scope is a compile error, never a silent default:
struct Point { x: i32, y: i32 }
fn main() !u8 {
con a = Point{ x: 1, y: 2 }
con b = Point{ x: 3, y: 4 }
con c = a + b // error: operator `+` is not implemented for type Point
return Ok(0)
}The same-type rule applies to the operands as written, so mixed-type operator
methods (say, scaling a vector by an i32) are not reachable through the
operator symbol — spell the call explicitly:
import { stdout } from "@io"
struct V { pub x: i32 }
fn (a: V) @mul(k: i32) V { return V{ x: a.x * k } }
fn main() !u8 {
con a = V{ x: 3 }
con b = a.@mul(2) // explicit call: fine
stdout.writeInt(i64(b.x)) // `a * 2` would not type-check
stdout.write("\n")
return Ok(0)
}fn main() !u8 {
con a: i32 = 1
con b: i64 = 2
con c = a + b // error: operands must share one type — cast first
return Ok(0)
}Unary operators
| Operator | Meaning | Operand |
|---|---|---|
-x | numeric negation | built-in integers and floats |
!x | logical not | bool only; not overloadable |
&x | address-of | any addressable value → *mut T / *con T |
*p | dereference | pointers |
@neg is the method name reserved for unary minus, and an explicit
a.@neg() call resolves like any method — but operator dispatch of -a to a
user-defined @neg is (planned); today - is the built-in numeric negation
only.
fn main() !u8 {
con x = !1 // error: `!` requires a bool operand
return Ok(0)
}Prefix &/* bind tighter than every binary operator and looser than the
postfix chain: -p.x is -(p.x), *p + 1 is (*p) + 1. Address-of and
dereference semantics are covered in
reference/memory-model.
Indexing and slicing
Subscripts are methods:
| Syntax | Desugars to |
|---|---|
xs[i] (read) | xs.@index(i) |
xs[i] = v (write) | xs.@setIndex(i, v) |
xs[a..b] | xs.@slice(Range{ start: a, end: b }) |
xs[a...b] | xs.@slice(Range{ start: a, end: b + 1 }) |
A compound assignment through a subscript reads through @index and writes
through @setIndex. @setIndex typically takes a *mut receiver so it can
mutate:
import { stdout } from "@io"
struct Counts { inner: [*]i32 }
fn (c: Counts) @index(i: i32) i32 { return c.inner[i] }
fn (c: *mut Counts) @setIndex(i: i32, v: i32) { c.inner[i] = v }
fn (c: Counts) @slice(r: Range) [*]i32 { return c.inner[r.start..r.end] }
fn main() !u8 {
mut cells = [3]i32[5, 0, 9]
mut c = Counts{ inner: [*]i32(&cells) }
c[1] = 9 // c.@setIndex(1, 9)
c[1] += 1 // read via @index, write via @setIndex
con head = c[0..2] // c.@slice(Range{ start: 0, end: 2 })
stdout.writeInt(i64(c[1] + head.len)) // prints 10 + 2 = 12
stdout.write("\n")
return Ok(0)
}This is exactly how @core's collections work — @index/@setIndex/@slice
on Vec(T) and [*]T are written in ordinary Vyi; nothing about subscripting
is reserved for built-in types.
Range operators
a..b builds a Range — an ordinary @core struct with start and end
fields — whose end is exclusive by convention everywhere a Range is
consumed. a...b is the inclusive spelling; the bump to end + 1 is
applied where the range expression is used directly:
| Use site | a..b | a...b |
|---|---|---|
for x in a..b | iterates a to b - 1 | iterates a to b |
xs[a..b] | slice, end exclusive | slice, end inclusive |
| plain value position | Range{ start: a, end: b } | Range{ start: a, end: b } |
Note the last row: a stored range is a plain two-field value with half-open
meaning, so the inclusive spelling only changes behaviour at a direct use site
— bind con r = a...b and you have stored the same value as a..b. Ranges at
level 1 bind loosest, so 1..2 + 3 is 1..(2 + 3):
import { stdout } from "@io"
fn main() !u8 {
con r = 1..2 + 3
mut s = 0
for x in r.start..r.end { s = s + x } // 1+2+3+4 = 10
for y in 1...3 { s = s + y } // + 1+2+3 = 16
con w = "hello"
con mid = w[1..3] // "el"
con midInc = w[1...3] // "ell"
stdout.writeInt(i64(s + mid.len + midInc.len)) // prints 16 + 2 + 3 = 21
stdout.write("\n")
return Ok(0)
}Open-ended forms exist in index position only: xs[..b] (from the start),
xs[a..] (to the end), xs[..] (the whole view). A range stored in a binding
is not itself iterable — for x in a..b iterates the range syntactically
(guide 05).
Compound assignment
All ten compound forms exist. Each looks for its in-place override method first, and falls back to the plain operator plus a store when the override is absent:
| Compound | Override | Fallback | Compound | Override | Fallback |
|---|---|---|---|---|---|
+= | @compAdd | @add | &= | @compBand | @band |
-= | @compSub | @sub | |= | @compBor | @bor |
*= | @compMul | @mul | ^= | @compBxor | @bxor |
/= | @compDiv | @div | <<= | @compShl | @shl |
%= | @compMod | @rem | >>= | @compShr | @shr |
(The one naming asymmetry: % is @rem, but %= is @compMod.)
Rules:
- Fallback rewrite:
place OP= valuebecomesplace = place OP value,
with the place evaluated once. Because the fallback goes through the
plain operator, it inherits the same-type rule — a compound with a
different right-hand type requires the @comp* override.
- Override shape: a
@comp*method takes a*mutreceiver, mutates
through it, and returns (). Its parameter type is free — t += 20 with
fn (t: *mut Tracked) @compAdd(n: i32) is fine.
- Assignment is a statement. Neither
=nor any compound form produces a
value; con y = (x += 1) does not parse as an expression.
import { stdout } from "@io"
struct Tracked { pub v: i32, pub writes: i32 }
fn (a: *mut Tracked) @compAdd(b: i32) {
a.v = a.v + b
a.writes = a.writes + 1
}
fn main() !u8 {
mut t = Tracked{ v: 0, writes: 0 }
t += 20
t += 20
stdout.writeInt(i64(t.v + t.writes)) // prints 40 + 2 = 42
stdout.write("\n")
return Ok(0)
}Tracked has only @compAdd, so t = t + 20 would not compile — the
override, not the fallback, is what fires. When both exist, the override wins
for OP= and the plain method serves OP.
The effectful-place rule. "Evaluated once" is enforced: a place containing a call would have to run the call twice under the fallback rewrite, so the compiler rejects it and tells you to bind the index first:
fn pick() i32 { return 0 }
fn main() !u8 {
mut buf = [2]i32[1, 2]
buf[pick()] += 1 // error: would evaluate the place twice; bind pick() first
return Ok(0)
}Built-in vs user resolution
An operator resolves exactly like a method call at that spot (guide
14): the compiler provides the
implementations for primitives, @core provides them for the core types in
ordinary Vyi source, and anything else is a compile error until a method of
yours is in scope.
| Operand type | Provided operators |
|---|---|
| integers, floats | arithmetic, comparisons, equality; integers add bitwise and shifts |
bool | == !=, && || ! |
char | == !=, ordering |
| pointers | == != (address identity) |
singleton unions (1 | 2, "a" | "b") | == != by value, built-in |
| payload-less enums | == != (tag compare), built-in |
| enums whose payloads are all equatable | == !=, deep: tag and live payload |
[*]u8 (byte views, string literals) | == != content equality; [i], slicing — written in @core |
String, Vec(T), HashMap(K, V) | @eq/@hash, subscripts — written in @core |
| structs | none built-in — every operator is opt-in via @-methods |
Struct equality is deliberately not synthesized (field-by-field equality is
not always the equality you want), and an enum's built-in deep == never
reaches into a payload type's user @eq — give the enum its own @eq instead
(guide 13).
One operator-adjacent method to know: @hash. It backs no operator, but
HashMap locates keys by key.@hash() and confirms them with @eq; @core
ships @hash for the integer primitives, char, and String. Keep the
contract: @eq-equal values must produce the same @hash.
Built-in numeric semantics
| Operation | Rule |
|---|---|
integer / | truncates toward zero: -7 / 2 == -3 |
integer % | remainder takes the dividend's sign: -7 % 2 == -1 |
>> | arithmetic (sign-extending) on signed types, logical on unsigned |
| overflow | wraps (two's complement) |
integer / 0, % 0 | runtime trap (halts the program) |
float % | remainder of truncated division: 7.5 % 2.0 == 1.5, sign follows the dividend |
@-methods and member access
The @ sigil is part of the method's name. Plain member access never lands on
an operator method — a.add(b) does not find @add — and the explicit
a.@add(b) spelling is always available and resolves like any other method
call. Declaring an @-method is the only way to hook an operator; there is
no other overloading mechanism.
See also: reference/expressions for the full expression grammar, reference/methods-dispatch for how the method is found once the operator has desugared.