Operator Overloading

Operator Overloading

Operators are overloaded by defining fn op methods on a class or struct, or in an extension. The syntax mirrors the operator being defined. An extension may declare any overloadable operator except as, [], -> and delete, which are member-only: they must be declared in the type's own body.

The built-in types follow the same rules. [T], {T}, {K: V}, [T;] and string are ordinary types to the compiler, so an operator they declare (for example fn op in (self) -> yield T, or fn op [] (self, i: usize) -> *T) is the one a for loop or a subscript calls. Only when such a type declares no operator of that kind does the compiler fall back to the built-in behavior.

All Overload-able Operators for User-Defined Types

OperatorOverload-ableConst-overloadableNotes
+ - * / %oknoReturns a new value.
Unary + -oknoReturns a new value.
^^oknoReturns a new value.
<< >>oknoReturns a new value.
& | ^ ~ (bitwise)oknoReturns a new value.
==oknoReturns bool.
!=oknoReturns bool. Derived from op == when not declared.
< <= > >=oknoReturn bool. Derived from op <=> when not declared.
<=>oknoReturns Ordering.
l++ r++ l-- r--oknoMutating operators.
[]okokReturns a place into self (*T / *const T). Member-only.
->okokReturns a place into self (*T / *const T). Member-only.
in (containment)oknoReturns bool.
in (iteration)oknoReturns yield T.
asoknoReturns a converted value. Member-only.
deleteoknoCustom destructor. Member-only.
awaitoknoReturns the awaited value.
panicoknofn op panic (const self) -> string: the optional message for a panicked value. See op panic.
+= -= *= /= %= &= |= ^= <<= >>=oknoReturns nothing. Declare either this or its binary operator, not both.
=nonoGenerated by the compiler; cannot be overloaded.
===nonoBuilt in; needs a nullable operand.
. ::nonoLanguage syntax; not overloadable.
Unary & *nonoBuilt-in pointer operations. Not overloadable on Kairo types. On an imported C++ class, *x calls the class's operator*, which is how C++ iterators and smart pointers are read.
&& || !nonoPreserve built-in short-circuit semantics.
?. ?->nonoDerived automatically from the corresponding non-null-safe operator.
.. ..=nonoImplemented through the Steppable interface.
sizeof alignof typeofnonoCompile-time language keywords.

Standard binary and unary operators

class Vec3 {
    var x: f32
    var y: f32
    var z: f32

    fn op + (self, other: Vec3) -> Vec3 {
        return Vec3 { x: self.x + other.x, y: self.y + other.y, z: self.z + other.z }
    }

    fn op - (self) -> Vec3 {   // unary negation
        return Vec3 { x: -self.x, y: -self.y, z: -self.z }
    }

    fn op == (self, other: Vec3) -> bool {
        return self.x == other.x && self.y == other.y && self.z == other.z
    }
}

Operators have no default arguments, and every comparison operator (==, !=, <, <=, >, >=) must return bool.

Derived comparisons

A comparison the type does not declare is derived: != from op ==, and <, <=, >, >= from op <=>. A declared operator always wins over a derived one, so a type can declare op <=> for ordering and still declare its own op < when it has a faster path.

struct Version {
    var major: i32
    var minor: i32

    fn op == (self, o: Version) -> bool { return self.major == o.major && self.minor == o.minor }
    fn op <=> (self, o: Version) -> Ordering { /* ... */ }
}

var a = Version { major: 1, minor: 2 }
var b = Version { major: 1, minor: 3 }
a != b   // derived from op ==
a < b    // derived from op <=>

Compound assignment

Each compound assignment and its binary operator form a pair: += / +, -= / -, *= / *, /= / /, %= / %, &= / &, |= / |, ^= / ^, <<= / <<, >>= / >>. A type declares one half of a pair and the other is derived from it. Declaring both halves is an error.

  • From op +: a += b is a = a + b, with a evaluated once.
  • From op +=: a + b copies a, applies += to the copy with b, and yields the copy.

A compound assignment operator mutates self and returns nothing, so a += b on a struct or class is a statement, not a value:

struct Acc {
    var total: i32
    fn op += (self, n: i32) { self.total = self.total + n }
}

var acc = Acc { total: 0 }
acc += 5             // calls op +=
var acc2 = acc + 7   // op + derived from op +=: acc2.total == 12

Declaring op + instead derives += the other way. Declare whichever form is natural for the type; the in-place form usually avoids a copy.

Imported C++ types are not paired: they get exactly the operators their C++ declaration has.

Increment and decrement

Use the l (left/prefix) or r (right/postfix) modifier to specify which variant you are overloading. A type may declare both. An unmarked op ++ or op -- is taken as the prefix form, with a warning.

fn op r-- (self) -> T    // postfix: x--
fn op l-- (self) -> T    // prefix:  --x
fn op l++ (self) -> T    // prefix:  ++x the ++ is on the left of the operand
fn op r++ (self) -> T    // postfix: x++ the ++ is on the right of the operand

Const Overloading Operators

op [] and op -> may overload on receiver const-ness, the one exception to the const-overload restriction that applies to named methods. For a place overload, the const self version returns *const T where the self version returns *T (see Places and values). This exception exists because operators cannot be renamed. See Functions for details.

class Buffer {
    priv var data: [u8]

    fn op [] (self, index: usize) -> *u8 {
        return &self.data[index]
    }

    fn op [] (const self, index: usize) -> *const u8 {
        return &self.data[index]
    }
}

var buff = Buffer { data: [1, 2, 3] }
var p: u8 = buff[1] // `buff` is not const: the `self` overload is picked, and the read
                    // dereferences the returned `*u8` to `u8`
buff[1] = 42        // `self` overload; writes through the returned `*u8`

const cbuff = Buffer { data: [1, 2, 3] }
var c: u8 = cbuff[1] // `cbuff` is const: the `const self` overload is picked
cbuff[1] = 42        // error: the place is `*const u8`

A non-const receiver prefers the self overload; a const receiver can only use the const self one. The result of a place operator is used as the value it points to, as a read or as the target of an assignment. A named method returning *T is not dereferenced this way.

Every other operator follows the named-method rule: a const self overload of it is a redeclaration error, the same as for a named method.

struct V {
    var x: i32
    fn op + (self, o: V) -> V { /* ... */ }
    fn op + (const self, o: V) -> V { /* ... */ }   // error: only [] and -> overload on const-ness
}

Special operators

OperatorSignatureDescription
asfn op as (self) -> TargetTypeType conversion takes no parameters
in (containment)fn op in (self, other: T) -> boolif item in collection checks membership
in (iteration)fn op in (self) -> yield Tfor x in collection yields elements
deletefn op delete (self)Custom destructor called when the value goes out of scope
panicfn op panic (const self) -> stringThe message for an error of this type, used when the error is shown (optional)

The two in signatures are distinguished by context: the compiler uses the -> bool variant in if expressions and the -> yield T variant in for loops.

class IntSet {
    priv var data: [i32]

    fn op in (self, value: i32) -> bool {
        // containment check: "if 5 in my_set"
        for item in self.data {
            if item == value { return true }
        }
        return false
    }

    fn op in (self) -> yield i32 {
        // iteration: "for x in my_set"
        for item in self.data {
            yield item
        }
    }
}

var s = IntSet { data: [1, 2, 3] }

if 2 in s { /* ... */ }      // calls the bool variant

for x in s {                 // calls the yield variant
    std::println(f"{x}")
}

Implicit op as

An @implicit fn op as (self) -> T is applied automatically where a T is wanted, so the caller does not write as:

  • The result type must match exactly. A T produced this way is not converted any further, so it does not fill a T? or a wider numeric type.
  • Conversions never chain: at most one @implicit operator applies, and never on top of another implicit conversion.
  • if/match arms and operator operands are never converted this way.
struct Name {
    var first: string
    var last: string

    @implicit fn op as (const self) -> string {
        return f"{self.first} {self.last}"
    }
}

fn greet(who: string) { ... }
fn maybe_greet(who: string?) { ... }

var n = Name { first: "Ada", last: "Lovelace" }
greet(n)                 // n as string, applied implicitly
var s: string = n        // also implicit
maybe_greet(n)           // error: string? is not an exact match
var t = n + "!"          // error: operands are not converted; write (n as string) + "!"

Without @implicit, op as only runs on an explicit as.

op panic

Any movable type can be panicked; op panic is optional. When a type defines it, the operator returns the human-readable message for an error of that type:

struct ConfigError {
    var key: string

    fn op panic (const self) -> string {
        return f"missing config key {self.key}"
    }
}

fn port(cfg: Config) panic -> i32 {
    if !cfg.has("port") { panic ConfigError { key: "port" } }
    return cfg.get_int("port")
}
  • The signature is fixed: no parameters besides const self, and a string result. A plain self receiver is an error whose fix-it inserts const.
  • The message is built only when the error is displayed, for example by value_or_throw on the C++ side. Raising, propagating and catching an error never call it.
  • Without op panic, a displayed error shows its type name and source location instead of a message.
  • An extension may add op panic to a Kairo type it doesn't own, such as a primitive or another module's type. A C++ type cannot define it; it can still be panicked, and shows its type name.

Places and values: [], ->

op [] is either a place or a value operator. The declared return type decides which.

A safe pointer return (*T or *const T) makes it a place:

  • a[i] is the T the pointer points at. It is an lvalue, and const if the pointer was.
  • a[i] = x and a[i] += 1 write into self, and &a[i] gives the pointer back.
  • C++ sees the operator as returning T& / const T&.

Any other return makes it a value operator: a[i] is the returned value, an rvalue. You can read it but not assign to it, and &a[i] is an error.

unsafe *T is rejected as a return type, because it is neither a place nor a value. A pointer return from [] always means a place, so op [] cannot return a pointer by value. Use a named method to hand out a pointer.

op -> isn't part of this rule. It must return *T or *const T, and -> is then applied to that result, as with C++'s operator->. unsafe *T is rejected there too.

Both kinds can be overloaded on a const self receiver.

struct <T> Buf {
    var data: [T; 4]
    fn op [](self, i: usize) -> *T { return &self.data[i] }                  // place
    fn op [](const self, i: usize) -> *const T { return &self.data[i] }      // place, const receiver
    fn op [](self, r: Range<usize>) -> [T;] { ... }                          // value: a slice
}

var b = Buf<i32>{ data: [1, 2, 3, 4] }
b[0] = 10          // through the place
var s = b[0..2]    // a new value
var *p = &b[1]     // *i32 into b.data

Delete Operator

op delete defines custom destruction logic. If not defined, the compiler generates a default destructor. If any member has a deleted destructor (fn op delete() = delete), the containing type's destructor is also deleted and instances must be managed in an unsafe context.

Note

The op delete operator is not the same as C++'s delete operator. Kairo's op delete is simply a cleanup hook that can either be explicitly called or automatically invoked when a value goes out of scope. It is not used to free memory allocated with @create T().

class FileHandle {
    priv var fd: i32

    fn op delete (self) {
        close(self.fd)
    }
}

See Tether for destruction timing and Allocators for allocator interaction.


See also