Guides · 13
Operators and overloading
Vyi has no separate "operator overloading" feature. Every operator is a method
call: a + b means a.@add(b), xs[i] means xs.@index(i), and so on. For
primitive numbers the compiler implements these methods directly; for your own
types you write them, and they are ordinary methods in every respect — declared
at the top level, resolved by scope, importable, overridable. One dispatch rule
covers arithmetic on i32 and arithmetic on your Vec2.
Operators are methods
An operator method is a method whose name starts with @. Declare it like any
other method and the matching operator starts working on your type:
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 main() !u8 {
con a = Vec2{ x: 1, y: 2 }
con b = Vec2{ x: 3, y: 4 }
con c = a + b // desugars to a.@add(b)
con d = a.@add(b) // the explicit spelling — same call
stdout.writeInt(i64(c.x + c.y + d.x - d.x))
stdout.write("
")
return Ok(0) // 4 + 6 = 10
}The full binary table. The left operand is the receiver; the right operand is the single parameter:
| Operator | Method | Notes |
|---|---|---|
+ | @add | |
- | @sub | |
* | @mul | |
/ | @div | |
% | @rem | |
== | @eq | must return bool |
!= | @eq | derived: result negated |
< | @lt | must return bool |
<= | @le | must return bool |
> | @lt | derived: operands swapped (a > b is b.@lt(a)) |
>= | @le | derived: operands swapped |
& | @band | bitwise and (infix; prefix & is address-of) |
| | @bor | |
^ | @bxor | |
<< | @shl | |
>> | @shr |
Three things are not in the table:
!=,>,>=are derived. You never write@neq,@gt, or@ge— define@eq,@lt, and@leand the other three come for free.&&and||are not methods. They short-circuit — the right operand may never be evaluated — so they can't be a call, and they can't be overloaded. Both sides must bebool.- Unary
!is not overloadable. It requires abooloperand. Unary minus is:-adesugars toa.@neg().
Deriving > from @lt means three methods give you the whole comparison set:
import { stdout } from "@io"
struct Version { major: i32, minor: i32 }
fn (a: Version) @eq(b: Version) bool {
return a.major == b.major && a.minor == b.minor
}
fn (a: Version) @lt(b: Version) bool {
if a.major != b.major { return a.major < b.major }
return a.minor < b.minor
}
fn (a: Version) @le(b: Version) bool {
return a < b || a == b
}
fn main() !u8 {
con old = Version{ major: 1, minor: 2 }
con new = Version{ major: 2, minor: 0 }
mut score = 0
if old < new { score = score + 1 }
if new > old { score = score + 1 } // b.@lt(a), swapped
if new >= old { score = score + 1 } // b.@le(a), swapped
if old != new { score = score + 1 } // !(a.@eq(b))
stdout.writeInt(i64(score))
stdout.write("
")
return Ok(0) // 4
}And a unary example — -a is a.@neg():
import { stdout } from "@io"
struct Offset { v: i32 }
fn (a: Offset) @neg() Offset {
return Offset{ v: 0 - a.v }
}
fn main() !u8 {
con a = Offset{ v: 5 }
con b = -a
stdout.writeInt(i64(b.v + 10))
stdout.write("
")
return Ok(0) // -5 + 10 = 5
}Because operator methods are ordinary methods, everything from guide
14 applies: they can be exported and imported,
they travel with the type across files, and a closer definition overrides a
farther one. This is also how the language's own operators work — + on i32
is an @add the compiler provides, and indexing on [*]T is an @index
written in plain Vyi in the @core package.
Using an operator with no method in scope is a compile error, not 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 message tells you exactly what to write: `define fn (a: Point) @add(b:
Point) … (operators desugar to @-method calls)`.
Equality
== is built in wherever it has an obvious meaning, and opt-in where it
doesn't:
- Primitives (integers, floats,
bool,char) and pointers compare built-in. - Payload-less enums compare built-in — it's a tag compare.
- Enums whose payloads are all equatable compare built-in, and the compare is deep: tags must match and the live variant's payload must match too.
- Structs have no built-in
==. Field-by-field equality is not always the equality you want (think cached fields, capacity vs. contents), so struct equality is always an explicit@eq.
Deep enum equality:
import { stdout } from "@io"
enum Shape {
Empty,
Square(i32),
Rect(i32, i32),
}
fn main() !u8 {
mut r = 0
if Shape.Square(4) == Shape.Square(4) { r = r + 30 } // deep equal
if Shape.Square(4) != Shape.Square(5) { r = r + 10 } // same tag, different payload
if Shape.Rect(2, 3) != Shape.Square(2) { r = r + 1 } // different tag
if Shape.Empty == Shape.Empty { r = r + 1 }
stdout.writeInt(i64(r))
stdout.write("
")
return Ok(0) // 42
}A struct payload breaks the "all payloads equatable" rule — and giving the
struct an @eq does not restore it.
The enum's == is the enum's own operation; it never reaches into a payload
type's methods:
struct Point { x: i32, y: i32 }
fn (a: Point) @eq(b: Point) bool { return a.x == b.x && a.y == b.y }
enum Node { Leaf, At(Point) }
fn main() !u8 {
con a = Node.At(Point{ x: 1, y: 2 })
con b = Node.At(Point{ x: 1, y: 2 })
if a == b { return Ok(1) } // error: `==` is not implemented for type Node
return Ok(0)
}To compare such an enum, define @eq on the enum itself and unpack the
payloads yourself:
import { stdout } from "@io"
struct Point { x: i32, y: i32 }
enum Node { Leaf, At(Point) }
fn (a: Node) @eq(b: Node) bool {
switch a {
Leaf: { switch b { Leaf: { return true }, At(_): { return false } } },
At(p): {
switch b {
Leaf: { return false },
At(q): { return p.x == q.x && p.y == q.y },
}
},
}
}
fn main() !u8 {
con a = Node.At(Point{ x: 1, y: 2 })
con b = Node.At(Point{ x: 1, y: 2 })
mut r = 0
if a == b { r = 1 }
stdout.writeInt(i64(r))
stdout.write("
")
return Ok(0)
}@eq has one close relative: @hash. It backs no operator, but
HashMap finds keys by calling key.@hash() and confirms them with @eq —
so any type with those two methods can be a map key (@core ships @hash for
the integer primitives, char, and String). Keep the contract: values that
are @eq-equal must produce the same @hash.
Indexing and slicing
Subscripts are methods too:
xs[i](read) desugars toxs.@index(i)xs[i] = v(write) desugars toxs.@setIndex(i, v)xs[a..b](range) desugars to building aRangeand callingxs.@slice(r)
Range comes from @core (auto-loaded, no import needed) and carries start
and an exclusive end — an inclusive a...b at the use site is bumped before
the method sees it, so @slice only ever handles one convention.
import { stdout } from "@io"
struct Text {
bytes: [*]u8,
}
fn (t: Text) @index(i: i32) u8 {
return t.bytes[i]
}
fn (t: Text) @slice(r: Range) [*]u8 {
return t.bytes[r.start..r.end]
}
fn main() !u8 {
con t = Text{ bytes: "hello world" }
con h = t[0] // t.@index(0)
con word = t[6..11] // t.@slice(Range{ start: 6, end: 11 })
stdout.writeInt(i64(word.len))
stdout.write("
")
stdout.writeInt(i64(h))
stdout.write("
")
return Ok(0) // 5 and 104 ('h')
}Writes go through @setIndex, which typically wants a *mut receiver so it
can actually 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 main() !u8 {
mut cells = [3]i32[0, 0, 0]
mut c = Counts{ inner: [*]i32(&cells) }
c[1] = 9 // c.@setIndex(1, 9)
c[1] += 1 // read through @index, write through @setIndex
stdout.writeInt(i64(c[1]))
stdout.write("
")
return Ok(0) // 10
}This is exactly how Vec works: @core defines @index, @setIndex, and
@slice on Vec(T) in ordinary Vyi source, and v[i] on a Vec is those
methods — nothing about subscripting is reserved for built-in types.
Compound assignment
All ten compound forms exist: += -= *= /= %= <<= >>= &= ^=
|=. By default, place OP= value rewrites to place = place OP value with
the place evaluated once:
mut a = 10
a += 5 // 15
a *= 4 // 60
a >>= 2 // 15
a %= 7 // 1So on a struct, p += q just needs @add — the compound form falls back to
the plain operator plus a store:
import { stdout } from "@io"
struct Meters { v: i32 }
fn (a: Meters) @add(b: Meters) Meters { return Meters{ v: a.v + b.v } }
fn main() !u8 {
mut d = Meters{ v: 10 }
d += Meters{ v: 5 } // d = d.@add(Meters{ v: 5 })
stdout.writeInt(i64(d.v))
stdout.write("
")
return Ok(0) // 15
}"Evaluated once" is enforced, not assumed: a place that contains a call would have to run the call twice under the 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)
}In-place overrides: @comp*
The fallback builds a whole new value and stores it. When that's wasteful — or
when you want the compound form to mean something the plain operator doesn't —
define the in-place override. Each compound operator looks for its @comp
method first and only falls back to the plain method if it's absent:
| Compound | Override | Compound | Override |
|---|---|---|---|
+= | @compAdd | &= | @compBand |
-= | @compSub | |= | @compBor |
*= | @compMul | ^= | @compBxor |
/= | @compDiv | <<= | @compShl |
%= | @compMod | >>= | @compShr |
(One naming asymmetry: % is @rem, but %= is @compMod.)
An override takes a *mut receiver, mutates through it, and returns nothing:
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))
stdout.write("
")
return Ok(0) // 40 + 2 = 42
}Tracked has only @compAdd — t = t + 20 would not compile, which proves
the override, not the fallback, is what fires. The two forms are independent:
give a type @add for the expression, @compAdd for the statement, or both.
Precedence
From loosest to tightest binding:
| Level | Operators |
|---|---|
| 1 | .. ... (range) |
| 2 | || |
| 3 | && |
| 4 | | (bitwise or) |
| 5 | ^ (bitwise xor) |
| 6 | & (bitwise and) |
| 7 | == != |
| 8 | < > <= >= |
| 9 | << >> |
| 10 | + - |
| 11 | * / % |
| 12 | unary prefix: - ! * (deref) & (address-of) |
| 13 | postfix chain: .field [index] (call) — left to right |
Binary operators of equal level associate left: 10 - 2 - 3 is 5, and
20 / 2 / 5 is 2. The practical consequences:
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 3 < 4 && 5 < 6 { ok = ok + 1 } // comparisons before &&
if 1 > 2 || 4 > 3 { ok = ok + 1 } // || binds loosest of the logicals
stdout.writeInt(i64(ok))
stdout.write("
")
return Ok(0) // 4
}Bitwise &/|/^ sit looser than comparisons (matching C), so
mask tests usually need parentheses: write (flags & 4) != 0, because
flags & 4 != 0 parses as flags & (4 != 0) and fails to type-check.
The full grammar-level table lives in the operators reference.