hanki

10. Traits and impls

Traits and impls use angle brackets for type application. impl Display<Point> reads "Display applied to Point", where Point is the implementing type.

struct Point
  x: i32
  y: i32
end

trait Display
  def to_string(self) -> string
end

impl Display<Point>
  def to_string(self) -> string
    "(#{self.x}, #{self.y})"
  end
end

An impl's method signatures must conform to the trait's. Each method's parameter and return types must equal the trait's declaration, with Self read as the implementing type, and an action's effect row must be a subset of the trait method's declared row: an impl may perform fewer effects than the trait promises, and never more. Bounded and static dispatch, below, type a caller against the trait's signature and charge the trait's effect row, and a divergent type or a wider effect row would return a type the caller never expects or perform effects it was never charged for. Both are rejected (H0617). A call on a concrete receiver resolves to that impl and is charged the impl's row: the trait's row is the ceiling a bound is typed against, and no floor every implementor pays. Narrowing an impl's row is therefore worth doing, at no cost to a caller that knows the receiver's type, and only the two bound-mediated forms sit at the ceiling.

A variable row removes that ceiling for the bound-mediated case. A trait method may declare its row as a single effect variable, def read!(self) -> bytes [e], in place of a concrete set. No ceiling applies, each impl declares whatever it performs, and a bounded generic names that row through its bound (<S: Stream[e]>, §6) and is charged the impl's own row at each instantiation. The impl's row must itself be concrete, the impl being the instance and a variable there having nothing to solve it (H0648), and a trait method's row is one or the other, never a variable among concrete effects (H0647). A supertrait's variable-row methods come through the bound with the rest of its surface, and the bound's variable covers them too. Under a concrete trait row every sentence above is unchanged.

A trait method signature may carry a default body. A type that impls the trait without overriding that method uses the default, and an inherent or impl override outranks it, per the inherent-wins coherence below. A default body may use the trait's other members on self, which dispatch to the implementing type:

struct Dog
  age: i32
end

trait Greet
  prop name(self) -> string          # required - no default
  prop greeting(self) -> string      # carries a default body
    "hi, #{self.name}"
  end
end

impl Greet<Dog>
  prop name(self) -> string
    "rex"
  end
  # `greeting` is not overridden, so `Dog.greeting` uses the default: "hi, rex"
end

Properties (prop)

A property is a pure, self-only value read exposed with bare-dot syntax and no parentheses: 2.days, point.magnitude. It is declared with the contextual keyword prop inside a trait or impl body, shaped like a def and read like a field:

trait DurationUnits
  prop days(self) -> Duration        # signature; a trait prop may carry a default body
end

impl DurationUnits<int>
  prop days(self) -> Duration
    Duration.of_days(self)
  end
end

x = 2.days                           # reads the prop — lowers to an ordinary call
y = 2.days()                         # error (H0567): `days` is a property — write `2.days`

Reading one with parentheses is H0567, here against the real datetime fluent units, which have the form sketched above:

use datetime

open datetime

def bad() -> datetime.Duration
  2.days()                           # `days` is a property — write `2.days`
end

The call syntax states the semantics, one spelling per member:

SpellingMember kindMeaning
bare dot x.namestruct field, or propa pure value read, and nothing happens
parens x.name()def methodan invocation, computation asked for
bang x.name!(…)action def name!an invocation with world effects

Exclusivity is total: for any member one spelling is grammatical. A prop read with parentheses (2.days()) is an error (H0567), and a def method read without them is a no-field error with a "did you mean .name()?" hint.

The stdlib follows the same rule, and its attribute reads are properties. length and empty? on List, Map, Set, Range, string and bytes are read bare, xs.length and s.empty?, and never called. The rest of the collection surface asks for computation (xs.sort, s.trim, m.keys) and is def.

Constraints, enforced at registration:

Props are declarable in traits, trait impls and inherent impls, and never on actors or at the top level. They may not declare method-own generics: a bare-dot read has no syntax to supply type arguments, and such a prop could never be instantiated. Bare-dot access works on a bounded generic receiver too (<T: DurationUnits> x.days, <T: Hash> x.hash), where the read dispatches through the bound's dictionary like a bounded method call. A prop read crosses the opacity boundary like any method: a prop on an opaque type is readable from any file, opacity gating construction and field access and no member read (§9).

A def with a property's form is an error (H0574). A pure, self-only, zero-argument def in an inherent impl or a trait declaration has the form of a property, and one spelling per member covers the def-and-prop split. It fails the check, with a machine-applicable def-to-prop fix that hanki check --fix applies. In a trait declaration the decision cascades, every impl inheriting the declared kind (H0568), and the rule and its diagnostic sit at the declaration and never at the impls; Hash.hash is a prop for that reason. The escape is semantic. A member with that form whose meaning is a transformation of the value, in place of an attribute of it, remains a def: the conversion prefixes to_, into_ and from_ are exempt by naming convention (to_iso(), to_seconds(), Display.to_string), and any other transformation is marked @transform def (sort, trim, hash_u64), in trait declarations as in inherent impls. A bare-dot read must never hide real work behind what looks like a field access. A misplaced @transform, anywhere the def-and-prop question cannot arise, on a top-level def, a member with arguments or generics, or a trait impl, is its own error (H0620), and stale markers cannot accumulate. Out of scope for H0574: trait impl members, whose kind the trait declaration mandates; meta and @encapsulated defs; members with method-own generics, a bare-dot read having no syntax for type arguments; and @intrinsic defs, that surface being the primitive conversion and math set, transformations by name. @intrinsic prop is itself a legal form, and str.length and List.length are declared that way. Where the member name shadows a field the fix is downgraded to a suggestion, to rename the backing field on an opaque type or drop the accessor on a transparent struct, the flip otherwise tripping H0566.

Generic implementations introduce type parameters implicitly:

struct Container<T>
  item: T
end
impl Display<Container<T>>    # T introduced from the parameter position
  def to_string(self) -> string
    "a container"
  end
end

Constrained generics, where the bound makes T's own Show dictionary available inside the body:

trait Show
  prop show(self) -> string
end

struct Crate<T>
  item: T
end

impl<T: Show> Display<Crate<T>>
  def to_string(self) -> string
    self.item.show
  end
end

Trait type parameters and associated types

A trait may declare type parameters and associated types, the machinery operator overloading builds on. The design record is docs/design/associated-types.md.

struct Thing
  n: i32
end

trait Combine<B>              # a type parameter, bound per-impl
  type Out                    # an associated type, bound per-impl
  def mk(self, b: B) -> Out
end

impl Combine<Thing, string>   # positional: Self = Thing, B = string
  type Out = bool             # every declared associated type must be bound
  def mk(self, b: B) -> Out   # sigs may spell the names or the concrete types
    self.n > 0i32
  end
end

Core into applies that machinery to explicit conversions. trait Into<To> declares @transform def into(self) -> To: Self names the source and the trait parameter names the target. A bound reads S: Into<HttpError> and fixes the result before the body calls s.into(). Coherence applies per slot, permitting one source to implement several targets. A concrete .into() has no value argument to select among several such impls and is ambiguous when the program contains more than one. A bounded call gets the target from its dictionary. There is no reverse From twin or blanket impl, and conversions are written by hand. The trait and bounded dispatch are ordinary front-end machinery shared by the bytecode and AOT tiers.

Two traits giving one type the same prop name is the ambiguity above, with nothing to select by:

trait First
  prop tag(self) -> string
end

trait Second
  prop tag(self) -> string
end

impl First<int>
  prop tag(self) -> string
    "first"
  end
end

impl Second<int>
  prop tag(self) -> string
    "second"
  end
end

def read(n: int) -> string
  n.tag                      # H0619: resolves through both impls
end

Trait bounds and bounded dispatch

A trait bound on a generic parameter lets the body call that trait's methods on a value of the bounded type. <T: Eq> on a def head, or on an impl head, brings Eq's methods into scope for any T-typed value:

def same?<T: Eq>(a: T, b: T) -> bool
  a.eq?(b)        # resolves through the `Eq` bound on `T`
end

Without the bound, a.eq?(b) is a compile error: a bare T has no methods of its own. The call dispatches to the concrete type's impl of the bound trait. same?(3i32, 3i32) runs Eq<i32>'s eq?, and same?("a", "a") runs Eq<string>'s. Inference fixes the concrete type at each call site (§11), nothing is named in source, and there is no witness or dictionary syntax.

A parameter may carry several bounds, joined by + (<T: Hash + Decode>). Each brings its trait's methods into scope on a T-typed value, and each supplies its own dictionary at the call site, in the order written. Use a second bound for an unrelated trait; a supertrait comes with the first, and T: Hash brings Eq's methods too, Hash having Eq as its supertrait.

def labelled<T: Display + Eq>(a: T, b: T) -> string
  if a.eq?(b)              # method from the `Eq` bound
    a.to_string()         # method from the `Display` bound
  else
    "differ"
  end
end

Interpolation goes through the same bound. A bare #{a} hole on a T-typed value with T: Display is a.to_string(), dispatching through the parameter's Display dictionary the same way on both tiers.

An impl-head bound (impl<K: Hash, V> Map<K, V>) is in scope for every method body in the block, and an inherent-impl method's own bound is in scope for that method's body:

struct Holder
  tag: i32
end

impl Holder
  def both_eq?<U: Eq>(self, x: U, y: U) -> bool
    x.eq?(y)        # resolves through the method-own `U: Eq` bound
  end
end

All five forms dispatch end to end on both tiers: free def heads, impl-head bounds, generic trait-impl heads (impl<T: Display> Display<Wrap<T>>), an inherent method's own bound above, and a trait method's own bound below. A method may carry both an impl-head bound and its own. Two restrictions apply. A method's own generic must not reuse an enclosing impl-head generic's name: impl<K, V> containing def shadow<K>(…) is a compile error, and the method's parameter needs a different name. And trait-impl coherence is one impl per (trait, head type name): a concrete and a generic impl sharing a head, impl Encode<Box<i32>> alongside impl<T: Encode> Encode<Box<T>>, are rejected as overlapping. Trait dispatch resolves impls by the head's type name, specialization is unsupported, and the concrete case folds into the generic impl, matching the inherent-impl rule in this section's coherence notes. A head that names no type is refused (H0570): impl Wrap<T> and impl<T> Wrap<T> are blanket impls, which Hanki does not have, and a bare parameter leaves dispatch nothing to resolve by. A head that names a type may still be generic, and impl<T> Base<Wrap<T>> resolves by Wrap. That name is the module-qualified one, and a stdlib impl never reserves its head's bare name: your own Duration or Pair is a different type from datetime.Duration or pair.Pair, has its own impls, and leaves theirs intact.

A bounded free function or inherent associated function also retains its bounds when used as a value. For example, f: (i32) -> string = render instantiates render<T: Display> at i32 and captures that implementation's dictionary; a generic caller forwards its own dictionary. The same applies to qualified references and higher-order arguments, on bytecode, AOT and during constant evaluation. A type without the required implementation is rejected at the reference, even if the value is never called. An inherent associated function captures both its impl-head and method-own dictionaries, in declaration order. Its associated result type becomes available as soon as the arguments or surrounding function type select the implementation. A generic caller must carry the required trait with the same default-expanded type arguments; a missing or incompatible bound is H0555 at the call or function reference, including bounds forwarded through a generic implementation. A stored function's bound effect row follows the type that selects the implementation; a caller may forward it under its own row name and a concrete function annotation may select a concrete row.

A trait method may declare its own bounded generics: trait Encode { def encode!<S: Serializer>(self, s: S) }, including a static one like trait Decode { def decode<D: Deserializer>(d: D) -> Self }. An impl of that trait must declare the same bounds for that method. Parameter names are local, and the bounds must match in order, or it is a compile error, the call site supplying those dictionaries positionally. The method-own dictionaries are chosen at the call site and passed after the value arguments, and they are no part of the trait's fixed-arity dictionary: on a dynamic dispatch a generic impl's impl-head dictionaries arrive through the dictionary closure, while the method-own ones arrive from the caller.

# main.hk
open io

trait Sink
  def put!(self, x: i32) -> () [io]
end

impl Sink<i32>
  def put!(self, x: i32) -> () [io]
    print!("#{x}")
  end
end

trait Encoder
  def encode_to!<S: Sink>(self, s: S) -> () [io]   # method-own generic `S`
end

impl Encoder<i32>
  def encode_to!<S: Sink>(self, s: S) -> () [io]
    s.put!(self)                  # `S`'s dictionary is supplied by the caller
  end
end

def main!(args: List<string>) -> () [io]
  42i32.encode_to!(0i32)          # prints 42
end

The == and != operators on a generic-T operand follow the same rule. a == b where a: T dispatches through T's Eq bound as a.eq?(b) does, and != negates it. They therefore require T: Eq, and == or != on an unbounded generic parameter is a compile error, there being no Eq impl to dispatch through. A concrete == works the same way: it dispatches through the type's Eq, synthesised on demand or hand-written, with no structural fallback, and it too is a compile error where the type cannot have Eq (see §20's eq? row).

The structural comparison underlying == on an aggregate is depth-bounded. A comparison that recurses past a fixed cap traps as a catchable runtime error, an actor death routed to the supervisor and no silent whole-process abort. A deeply-nested value would otherwise overflow the actor thread's stack. A value is always finite: cyclic values cannot be constructed, and values are acyclic (§12). The cap is a stack-overflow guard set far deeper than any legitimately-nested value, and it fires identically on the bytecode, AOT and comptime tiers. The same bound governs the other structural trait recursions, Ord comparison (<, cmp), Hash, and Display rendering, which walk a value's nesting the way == does and trap at the same depth in place of exhausting the stack.

Supertraits

A trait may declare a supertrait with trait Sub: Super. The compiler enforces two things:

trait Hash: Eq          # every Hash type is an Eq type
  prop hash(self) -> u64
end

def bucket?<T: Hash>(x: T, y: T) -> bool
  x.eq?(y)               # `Eq`'s method, in scope via the `Hash: Eq` bound
end

One supertrait per trait. The relationship is transitive: a chain A: B, B: C makes C's methods available to a T: A bound and requires impl C<T> wherever impl A<T> appears, each link's impl requirement chaining to the next. The supertrait position accepts a module-qualified name like every other cross-module item reference (§14), and trait Comparable: eq.Eq works without an open eq.

Inherent impls

impl <Type> ... end, with no trait and no <args>, is an inherent impl: methods and associated functions attached to a type without a trait. The implementing type can be a built-in (impl i32), a user struct or sum (impl Email), or a module-qualified name (impl foo.Bar). Declaration order is free, as it is for actors (§15): an impl may be written above the struct or type it is for, and a struct field or a variant payload may name a type declared later in the file.

struct Email
  text: string
end

impl i32
  @transform def doubled(self) -> i32
    self + self
  end
end

impl Email
  prop empty?(self) -> bool
    self.text.length == 0
  end
end

A method, whose first parameter is self, dispatches through value.method(). The two members above follow the one-spelling rule of this section: the attribute read is a prop, and the prop-shaped transformation is marked @transform (H0574). An associated function, with no self parameter, dispatches through <Type>.fn(args): the receiver position names the type, and the call resolves to the inherent associated function of that name. Qualified types work in both calls and inherent function values: model.Colour.tag(x), xs.map(model.Colour.tag) and bytes.BytesBuilder.new!.

impl FromString<int>
  def parse(s: string) -> Result<int, ParseError>
    ...
  end
end

x = int.parse("42")     # Ok(42) — a trait static, reached by type name
e = Email.parse("a@b")  # Email("a@b") — same syntax on user types

Cross-tier conversion methods (i32.to_f64, int.to_i64, and the rest) are inherent methods on the source type, following the same convention.

A trait may also declare an associated function, a method whose first parameter is not self, and <Type>.fn(args) resolves it through the type's impl of that trait, as it does for an inherent one. An inherent associated function of the same name takes precedence: it is chosen, and no ambiguity is reported. The type is named in the receiver position, a Self in the function's signature is pinned to it, and no return-type inference is needed. The same call works through a bounded type parameter. Inside a <T: Trait> body, T.fn(args) dispatches to the concrete type's impl through the trait dictionary, the no-self counterpart of the value.method() dispatch above, and what lets a generic codec construct a value of its own type parameter with T.decode(bytes, pos). There is no inline-type-argument form (§11), the type is always named, and a generic instantiation (List<i32>) is reached through a type parameter and never spelled in receiver position.

# main.hk
open io

trait FromByte
  def from_byte(b: u8) -> Self
end

impl FromByte<u32>
  def from_byte(b: u8) -> Self
    b.to_u32()
  end
end

def widen<T: FromByte>(b: u8) -> T
  T.from_byte(b)
end

def main!(args: List<string>) -> () [io]
  direct = u32.from_byte(5u8)   # concrete: resolves FromByte<u32>
  generic: u32 = widen(7u8)     # T pinned to u32 by the binding; T.from_byte dispatches via the bound
  print!("#{direct == 5u32 and generic == 7u32}")   # true
end

Coherence: at most one inherent item per (target type, name) pair. Methods and associated functions share one namespace per target type. An inherent method named the same as a trait method that also matches the receiver's type takes precedence, being chosen with no ambiguity reported, under the same inherent-wins rule the associated-function dispatch above follows. Only a clash between two trait methods, two traits defining the same method for one type, remains ambiguous; rename one or qualify explicitly.

Generic inherent impls

impl<T> Container<T> ... end attaches inherent methods to a generic container. The impl's type parameters are written in angle brackets on the impl head and referenced in the head's instantiation, and methods inside use them in their signatures the way a generic def<T> does. At each method call site the impl's parameters are unified with the receiver's concrete arguments, and the method's signature is substituted accordingly.

impl<T> Option<T>
  def unwrap_or(self, default: T) -> T
    match self
      Some(v) -> v
      None    -> default
    end
  end
end

Some(7i32).unwrap_or(0i32)   # T pinned to i32 from the receiver

One impl per base target type (Option, List, and the rest). A generic impl and a concrete impl Option<i32> on the same target are rejected as mixing generic forms; pick one. Bounded generic inherent impls (impl<T: Show> Container<T>) carry the same bounds and dictionaries described above.