Skip to content
CryoCryo home
LanguageAbstraction

11Traits

A trait names a set of methods that a type may implement. Traits are how generic code expresses requirements on its type parameters and how the standard library models capabilities such as equality, ordering, hashing, formatting, and I/O.

11.1 Declaring a Trait

type trait Eq {
    equals(&this, other: &This) -> boolean;
}

Inside the trait body, This refers to the implementing type. Methods may have default bodies:

type trait Read {
    read(mut &this, buf: u8*, len: u64) -> Result<u64, IoError>;

    /// Default: keep reading until end-of-stream or error.
    read_all(mut &this, out: mut &Array<u8>) -> Result<u64, IoError> {
        // ... default implementation calls self.read in a loop ...
    }
}

A trait may inherit from a base trait; implementations of the derived trait must also implement the base:

type trait Ord : Eq {
    compare(&this, other: &This) -> Ordering;
}

11.2 Implementing a Trait

implement trait <Trait> for <Type> { ... } provides the method bodies for a concrete type.

implement trait Eq for i32 {
    equals(&this, other: &i32) -> boolean {
        return this == *other;
    }
}

implement trait Ord for i32 {
    compare(&this, other: &i32) -> Ordering {
        if (this < *other) { return Ordering::Less;    }
        if (this > *other) { return Ordering::Greater; }
        return Ordering::Equal;
    }
}

You may implement a trait for any type defined in the same crate, including primitive types.

11.3 Trait Bounds with where

A generic parameter is constrained to types that implement specific traits via a where clause:

function smallest<T>(xs: &Array<T>) -> Option<T>
    where T: Ord + Clone {
    if (xs.length() == 0) { return Option::None; }
    mut best: T = xs.get(0).clone();
    for (mut i: u64 = 1; i < xs.length(); i++) {
        const next: T = xs.get(i).clone();
        if (next.compare(&best) == Ordering::Less) {
            best = next;
        }
    }
    return Option::Some(best);
}

Multiple bounds on the same parameter are joined with +. Multiple constrained parameters are separated with ,:

where T: Hash + Eq, V: Clone

11.4 Standard Library Traits

TraitPurpose
CopyMarker for types that are bitwise-copyable (no Drop impl).
DropExplicit destructor; types implementing it are non-Copy.
CloneExplicit deep duplication via clone().
DefaultA canonical zero value: static default() -> This.
EqEquality (backs == / !=; see section 11.6).
Ord (: Eq)Total ordering via compare(...) -> Ordering (backs < > <= >=).
HashType-level hashing into a Hasher.
Add/Sub/Mul/Div/Rem, Neg, BitAnd/BitOr/BitXor/Shl/Shr, Not/BitNot, Index, DerefOperator overloading - see section 11.6.
IteratorLazy sequence with an associated Item and next() -> Option<Item> (see section 11.5).
IntoIteratorConversion into an iterator.
From<T> / Into<T>Infallible conversions.
TryFrom<T> / TryInto<T>Fallible conversions returning Result.
Read / WriteByte-level I/O.
Display / DebugFormatting.
FmtWriteSink trait for the formatter.
AllocatorHeap allocation strategy.

Every standard-library trait is declared in stdlib/core/ with the exception of Read/Write (in stdlib/io/traits.cryo), Display/Debug/FmtWrite (in stdlib/fmt/), and Allocator (in stdlib/alloc/allocator.cryo).

The collection iterator entry points (Array::iter, HashMap::keys/values, Str::split, ...) return implement Iterator<...> rather than naming their concrete cursor structs, so you consume them through the trait and a for-in loop without ever spelling the underlying type.

11.5 Associated Types

A trait may declare an associated type - a type that each implementation supplies, named once in the trait and referred to by every method. The standard Iterator is written this way:

type trait Iterator {
    type Item;                              // each impl chooses its element type
    next(mut &this) -> Option<This::Item>;  // `This::Item` projects it
    // ... defaults (count, fold, map, filter, ...) all in terms of This::Item ...
}

This::Item is a projection: inside the trait it stands for "the Item this implementation bound". Projections also work off a generic parameter - a generic adapter names its source's element as I::Item:

type struct MapIter<I, O> { inner: I; f: (I::Item) -> O; }

Binding the associated type. An implementation binds each associated type in one of two ways:

// 1. Positional sugar - `Iterator<i32>` == `Iterator<Item = i32>`. Available
//    only when the trait has no generic params of its own (Iterator's case).
implement trait Iterator<i32> for struct Counter { ... }

// 2. Explicit body form - always available, and required when a value
//    expression rather than sugar is clearer.
implement trait Iterator for struct Counter {
    type Item = i32;
    ...
}

The positional form scales to where-clause adapters, whose element flows from the source: implement<I, A> trait Iterator<A> for struct TakeIter<I> where I: Iterator<A> binds Item := A := I::Item.

Declaration-site bounds. An associated type may carry a bound (type Item: Copy;). Every impl's concrete binding is checked against it - an impl whose Item does not satisfy the bound is rejected with E0306:

type trait Seq { type Item: Copy; next_one(&this) -> i32; }
implement trait Seq<NotCopy> for struct Holder { ... }   // E0306: Item not Copy

Diagnostics. Three errors guard the rules:

  • E0306 - an impl binds an Item that does not satisfy a declaration-site bound (type Item: Copy;).
  • E0309 - an impl of a trait that declares an associated type binds none of them (no positional arg and no type Item = ...; body). The projection could never reduce, so it is rejected up front.
  • E0310 - an associated type bound positionally on a trait that also has generic parameters. Positional args fill the declared generic params in order, so an associated type of such a trait must be bound with the explicit body form (type Out = ...;).

Because Cryo monomorphises, a projection is fully resolved at compile time: MapIter<Range<i32>, i64>::Item reduces to i64 with no runtime cost.

11.6 Operator Overloading

Operators on a user-defined type desugar, in the compiler, to a call to the corresponding operator trait method. Implement the trait and the operator works on your type; the rewrite happens during semantic analysis, so there is no runtime dispatch and the result monomorphises like any other method call.

The rewrite is type-directed and LHS-driven: a OP b dispatches on the type of a, and it only fires when the built-in rule does not already apply. Primitive arithmetic (1 + 2), pointer stepping, and native integer/pointer comparison keep emitting raw instructions - primitives deliberately do not implement the arithmetic traits. The operator traits live in stdlib/core/ops.cryo (Eq/Ord are in stdlib/core/cmp.cryo).

Operator(s)Trait (core::ops / core::cmp)Method / desugar
+ - * / %Add Sub Mul Div Rema + b -> a.add(&b) (etc.)
- (unary)Neg-a -> a.neg()
& | ^ << >>BitAnd BitOr BitXor Shl Shra & b -> a.bitand(&b) (etc.)
! ~ (unary)Not BitNot!a -> a.not(), ~a -> a.bitnot()
== !=Eqa == b -> a.equals(&b), a != b -> !a.equals(&b)
< > <= >=Ord (: Eq)a < b -> a.compare(&b).is_lt() (is_gt/is_le/is_ge)
a[i]Index<Idx, Output>a[i] -> *(a.index(i))
*a (deref)Deref<Target>*a -> *(a.deref())

Each arithmetic/bitwise trait carries Rhs and Output type parameters, so an operator can mix types (add a scalar to a vector, shift by a plain integer) and choose its result type. The right operand is taken by reference (rhs: &Rhs) to avoid moving an owned aggregate.

type struct Vec2 { x: i64; y: i64; }

implement trait Add<Vec2, Vec2> for struct Vec2 {
    add(&this, rhs: &Vec2) -> Vec2 { return Vec2 { x: this.x + rhs.x, y: this.y + rhs.y }; }
}
implement trait Mul<i64, Vec2> for struct Vec2 {          // scalar on the right
    mul(&this, rhs: &i64) -> Vec2 { return Vec2 { x: this.x * *rhs, y: this.y * *rhs }; }
}
implement trait Neg<Vec2> for struct Vec2 {
    neg(&this) -> Vec2 { return Vec2 { x: -this.x, y: -this.y }; }
}

const a: Vec2 = Vec2 { x: 1, y: 2 };
const b: Vec2 = Vec2 { x: 3, y: 4 };
const c: Vec2 = a + b;      // Vec2 { 4, 6 }
const d: Vec2 = a * 3;      // Vec2 { 3, 6 }
const e: Vec2 = -a;         // Vec2 { -1, -2 }

Equality and ordering. Implement Eq for ==/!= and Ord (which extends Eq) for < > <= >=. A single compare backs all four relational operators through the Ordering predicates:

implement trait Eq for struct Vec2 {
    equals(&this, other: &Vec2) -> boolean { return this.x == other.x && this.y == other.y; }
}
implement trait Ord for struct Vec2 {
    compare(&this, other: &Vec2) -> Ordering {
        if (this.x != other.x) { return this.x.compare(&other.x); }
        return this.y.compare(&other.y);
    }
}
const eq: boolean = a == b;   // a.equals(&b)      -> false
const lt: boolean = a < b;    // a.compare(&b).is_lt() -> true

Compound assignment routes through the same trait: a += b evaluates a.add(&b) and stores the result back into a. This holds for every binary arithmetic, bitwise, and shift operator (+= -= *= /= %= &= |= ^= <<= >>=).

Indexing. Index<Idx, Output>::index returns a pointer (Output*), so the desugar a[i] -> *(a.index(i)) is a place: one implementation serves reads (v = a[i]), writes (a[i] = v), and compound assignment (a[i] += v). Built-in array/slice/string/pointer indexing keeps its native path.

type struct Grid { cells: i32[9]; }
implement trait Index<u64, i32> for struct Grid {
    index(&this, i: u64) -> i32* { return &this.cells[i]; }
}
mut g: Grid = ...;
g[0] = 42;        // *(g.index(0)) = 42
g[0] += 1;        // index called once

Dereference and auto-deref. Deref<Target>::deref also returns a pointer (Target*), so *b -> *(b.deref()) is likewise a place (read/write/compound). Member access coerces through Deref too: when a receiver lacks a field or method but implements Deref, b.field / b.method() retry on the pointee (transitively), inserting the .deref() chain for you - the smart-pointer pattern.

type struct Boxed<T> { value: T; }
implement<T> trait Deref<T> for struct Boxed<T> {
    deref(&this) -> T* { return &this.value; }
}
mut b: Boxed<Vec2> = Boxed<Vec2> { value: Vec2 { x: 1, y: 2 } };
const v: Vec2 = *b;   // *(b.deref())
b.x = 10;             // auto-deref: (*b.deref()).x - b has no field `x`

Reference operands. A by-reference operand (&T, the common container/parameter case) overloads exactly like a value - v[i], a + b, -a, and a == b all work when a/v is a &T. In particular &x == &y (where the referent implements Eq) compares by value (x.equals(&y)), not by pointer identity. A raw pointer T* is never unwrapped this way: p == null and pointer arithmetic stay on the primitive path.

Reflected (primitive left operand). When the left operand is a primitive and the right is a user type, the operator dispatches to a trait implemented on the primitive - 2 * v calls (2).mul(&v) given implement trait Mul<Vec2, Vec2> for i64. This is the one case where the right operand's type pulls in the impl; Eq/Ord are excluded (their operands share This).

Generic and bounded-param dispatch work throughout: a T: Add parameter overloads a + b in a generic body (Phase 2), and the desugar is carried through monomorphisation so Container<i32>::index (etc.) specialises correctly. When a type lacks the required impl, the compiler names the missing trait (e.g. "Vec2 does not implement Mul; add implement trait Mul ... for Vec2").