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:

vyi
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:

OperatorMethodNotes
+@add
-@sub
*@mul
/@div
%@rem
==@eqmust return bool
!=@eqderived: result negated
<@ltmust return bool
<=@lemust return bool
>@ltderived: operands swapped (a > b is b.@lt(a))
>=@lederived: operands swapped
&@bandbitwise 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 @le and 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 be bool.
  • Unary ! is not overloadable. It requires a bool operand. Unary minus is: -a desugars to a.@neg().

Deriving > from @lt means three methods give you the whole comparison set:

vyi
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():

vyi
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:

vyi-error
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:

vyi
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:

vyi-error
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:

vyi
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 to xs.@index(i)
  • xs[i] = v (write) desugars to xs.@setIndex(i, v)
  • xs[a..b] (range) desugars to building a Range and calling xs.@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.

vyi
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:

vyi
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:

vyi
mut a = 10
a += 5      // 15
a *= 4      // 60
a >>= 2     // 15
a %= 7      // 1

So on a struct, p += q just needs @add — the compound form falls back to the plain operator plus a store:

vyi
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:

vyi-error
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:

CompoundOverrideCompoundOverride
+=@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:

vyi
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 @compAddt = 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:

LevelOperators
1.. ... (range)
2||
3&&
4| (bitwise or)
5^ (bitwise xor)
6& (bitwise and)
7== !=
8< > <= >=
9<< >>
10+ -
11* / %
12unary prefix: - ! * (deref) & (address-of)
13postfix 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:

vyi
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.