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
| Operator | Overload-able | Const-overloadable | Notes |
|---|---|---|---|
+ - * / % | ok | no | Returns a new value. |
Unary + - | ok | no | Returns a new value. |
^^ | ok | no | Returns a new value. |
<< >> | ok | no | Returns a new value. |
& | ^ ~ (bitwise) | ok | no | Returns a new value. |
== | ok | no | Returns bool. |
!= | ok | no | Returns bool. Derived from op == when not declared. |
< <= > >= | ok | no | Return bool. Derived from op <=> when not declared. |
<=> | ok | no | Returns Ordering. |
l++ r++ l-- r-- | ok | no | Mutating operators. |
[] | ok | ok | Returns a place into self (*T / *const T). Member-only. |
-> | ok | ok | Returns a place into self (*T / *const T). Member-only. |
in (containment) | ok | no | Returns bool. |
in (iteration) | ok | no | Returns yield T. |
as | ok | no | Returns a converted value. Member-only. |
delete | ok | no | Custom destructor. Member-only. |
await | ok | no | Returns the awaited value. |
panic | ok | no | fn op panic (const self) -> string: the optional message for a panicked value. See op panic. |
+= -= *= /= %= &= |= ^= <<= >>= | ok | no | Returns nothing. Declare either this or its binary operator, not both. |
= | no | no | Generated by the compiler; cannot be overloaded. |
=== | no | no | Built in; needs a nullable operand. |
. :: | no | no | Language syntax; not overloadable. |
Unary & * | no | no | Built-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. |
&& || ! | no | no | Preserve built-in short-circuit semantics. |
?. ?-> | no | no | Derived automatically from the corresponding non-null-safe operator. |
.. ..= | no | no | Implemented through the Steppable interface. |
sizeof alignof typeof | no | no | Compile-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 += bisa = a + b, withaevaluated once. - From
op +=:a + bcopiesa, applies+=to the copy withb, 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
| Operator | Signature | Description |
|---|---|---|
as | fn op as (self) -> TargetType | Type conversion takes no parameters |
in (containment) | fn op in (self, other: T) -> bool | if item in collection checks membership |
in (iteration) | fn op in (self) -> yield T | for x in collection yields elements |
delete | fn op delete (self) | Custom destructor called when the value goes out of scope |
panic | fn op panic (const self) -> string | The 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
Tproduced this way is not converted any further, so it does not fill aT?or a wider numeric type. - Conversions never chain: at most one
@implicitoperator applies, and never on top of another implicit conversion. if/matcharms 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 astringresult. A plainselfreceiver is an error whose fix-it insertsconst. - The message is built only when the error is displayed, for example by
value_or_throwon 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 panicto 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 theTthe pointer points at. It is an lvalue, andconstif the pointer was.a[i] = xanda[i] += 1write intoself, 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.
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.