Lowent Manual←↑→

23 Traits — one promise kept by many types

What to know first

chapter 10, Aggregates · struct and field
chapter 15, Effects · the effects line narrows and spreads to callers
chapter 22, Generics · requires <trait> t . is a type condition

Looking back

What did max_of in chapter 22 call in its body, trusting requires ordered t. . .? And what happened when plain, which did not satisfy the condition, was given?

A. It called method a less b. Giving plain was rejected with E-BOUND-UNSAT, because the condition is a promise and making an instance past it would make the callee’s contract a lie. This chapter covers declaring and satisfying that promise — the trait — in full.

The need for this chapter, and its context

A rectangle and a square compute their areas differently, but the promise “can tell its area” is the same. Naming that promise lets you write, once, an op that accepts “anything that can tell its area”. This language has no inheritance, so traits are the way to treat several types under one name. This chapter properly unfolds the tool already used for allocators (chapter 20) and sorting (chapter 22).

By the end of this chapter

You will learn to attach ops to types (fn rect.area) and call them with method. You will pick up how to declare a trait and satisfy it with satisfies, and how to use traits with several ops. You will see the rule that trait signatures do not say fn or proc and that the effects line decides the implementer, the diagnostics for failing to satisfy a trait, via self, and why a trait is not inheritance.

The questions this chapter answers

  1. Can a trait op have a default implementation?

23.1 What they are for#

examples/ch23/why.low

module why .
rem run: demo

trait shape do
  area input s self . output u64 .
end .

struct rect do
  satisfies shape .
  w u64 .
  h u64 .
end .

struct square do
  satisfies shape .
  side u64 .
end .

fn rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .

fn square.area input s square . output u64 .
do
  return mul field s. side . field s. side . . .
end .

fn double_area input comptime t type . input s t . output u64 .
  requires shape t. . .
do
  return mul 2 method s. area. . . .
end .

fn demo output u64 .
do
  let r lit rect do w 2 . h 3 . end . .
  let q lit square do side 4 . end . .
  return add double_area rect. r. . double_area square. q. . . .
end .

Output

$ lowentc --run demo why.low
demo() = 44

Think of a trait as a qualification. There is a requirement, “can tell its area”, and only types that meet it may enter an op that asks for it.

                trait shape  ── requirement: area input s self . output u64 .
                   ▲        ▲
       satisfies   │        │   satisfies
     ┌─────────────┴─┐    ┌─┴─────────────┐
     │ rect          │    │ square        │
     │ rect.area     │    │ square.area   │   ← the ops that actually meet it
     └───────────────┘    └───────────────┘

 double_area has requires shape t. . .  ── only types that meet shape
   double_area rect r    →  method s area  =  rect.area
   double_area square q  →  method s area  =  square.area

double_area rect r and double_area square q are each monomorphised to their own instance (chapter 22), so method is resolved at translation, not looked up at run time. There is no virtual function table.

23.2 Ops attached to types, and method#

An op can be attached to a type even without a trait. Put the type name and a dot in front of the op name, and its first input is a value of that type. method <value> <name> <args…> calls that op. The receiver may be the result of another form — method method r. grow. 1 . area. .. If no such op is attached, the call is rejected.

examples/ch23/method_undef.low

module method_undef .
rem expect: E-METHOD-UNDEF

struct point do
  x u8 .
end .

fn f input p point . output u8 .
do
  return method p. nosuch. . .
end .

Output

$ lowentc --check method_undef.low
method_undef.low:10:0 E-METHOD-UNDEF: no op of that name is associated with the receiver's type — declare it as `fn <type>.<name> input <recv> <type> . …` (RFC-0062)

Calling a name that does not exist does not search upwards. Attached ops are a device for dividing the name space, not inheritance.

23.3 Traits with several ops#

examples/ch23/many.low

module many .
rem run: demo 3 4
rem trap: demo_empty

trait shape do
  area input s self . output u64 .
  perimeter input s self . output u64 .
  grow input s self . input k u64 . output self .
  checked_area input s self . output u64 . effects panic .
end .

struct rect do
  satisfies shape .
  w u64 .
  h u64 .
end .

fn rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .

fn rect.perimeter input s rect . output u64 .
do
  return mul 2 add field s. w . field s. h . . . .
end .

fn rect.grow input s rect . input k u64 . output rect .
do
  return lit rect do w add field s. w . k. . . h add field s. h . k. . . end . .
end .

proc rect.checked_area input s rect . output u64 . effects panic .
do
  if eq field s. w . 0 . do
    panic "empty rect" .
  end .
  return mul field s. w . field s. h . . .
end .

proc demo input w u64 . input h u64 . output u64 . effects panic .
  requires le w. 1000 . .
  requires le h. 1000 . .
do
  let r lit rect do w w. . h h. . end . .
  let big rect method r. grow. 1 . .
  return add method big. perimeter. . method big. checked_area. . . .
end .

proc demo_empty output u64 . effects panic .
do
  let r lit rect do w 0 . h 5 . end . .
  return method r. checked_area. . .
end .

Output

$ lowentc --run demo many.low 3 4
demo(3, 4) = 38
$ lowentc --run demo_empty many.low
== ir diagnostics (1) ==
0:0 E-VM-PANIC: the program called `panic` — this is an unrecoverable stop, and it is NOT a contract violation (the code chose to stop, it did not break a promise)

There are three things to know about writing them.

demo_empty calls checked_area on a rectangle of width 0 and stops. The signature promised effects panic, so callers know it may stop.

In practice. A defect: effects of ops called through method do not spread

Running --check on the example above attaches W-EFFECT-OVER (panic declared but never performed) to demo. It is a false warning — demo_empty really does stop. While writing this book it turned out that the processor in this edition does not spread the effects of ops called through method to the caller. So even a pure fn can call an op that panics through method and pass translation. Calling the same op directly as rect.checked_area r. . is correctly rejected. The behaviour the specification (chapter 15) requires is the direct call’s; the method side is a defect. Until it is fixed, do not call effectful attached ops through method inside pure ops.

23.4 Signatures do not say fn or proc#

Writing fn or proc in a trait signature is rejected.

examples/ch23/sig_kind.low

module sig_kind .
rem expect: E-TRAIT-SIG

trait shape do
  fn area input s self . output u64 .
end .

Output

$ lowentc --check sig_kind.low
5:3 E-TRAIT-SIG: a trait signature does not say `fn` or `proc` — write `area input s self . output u64 .`. Its `effects` line says what the op may do (none = pure), and the implementation chooses `fn` or `proc` (§6.11.2)

What the op may do is decided by the signature’s effects line.

Signature’s effectsImplementer
nonea fn, or a proc that writes effects
present, e.g. effects panica proc declaring those effects or fewer; a fn if it uses none

Table 23.1 — A signature’s effects and the implementer

A proc always carries an effects line (chapter 5). One without it is rejected on its own, before any signature is matched — an effect-free signature is implemented by a proc that says effects none ., or by a fn.

examples/ch23/proc_noeff.low

module proc_noeff .
rem expect: E-EFFECT-MISSING

trait shape do
  area input s self . output u64 .
end .

struct rect do
  satisfies shape .
  w u64 .
  h u64 .
end .

proc rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .

Output

$ lowentc --check proc_noeff.low
proc_noeff.low:14:0 E-EFFECT-MISSING: a `proc` states its effects — add `effects <atoms> .` (or `effects none .`) to the head. Omitting it used to mean three different things (the checker, `--doc` and the spec disagreed). `lowentc --doc` prints the effects the body actually performs (`inferred:`) — that is the clause to write (RFC-0113 R1)
9:0 E-TRAIT-EFFECT: the implementation is a `proc` with NO `effects` clause, so it may do anything — more than the trait declares. Write the same `effects` line the trait has (or fewer), or make it a `fn` if it is pure

Declaring more effects than the signature is rejected too.

examples/ch23/more_effect.low

module more_effect .
rem expect: E-TRAIT-EFFECT

trait shape do
  checked_area input s self . output u64 . effects panic .
end .

struct rect do
  satisfies shape .
  w u64 .
  h u64 .
end .

proc rect.checked_area input s rect . output u64 . effects panic wait .
do
  if eq field s. w . 0 . do
    panic "empty rect" .
  end .
  return mul field s. w . field s. h . . .
end .

Output

$ lowentc --check more_effect.low
9:0 E-TRAIT-EFFECT: the implementation has an EFFECT the trait does not declare. A caller reasons against the TRAIT's contract — if the implementation does more, that reasoning is a lie (this is exactly what makes dynamic dispatch safe or unsafe)
more_effect.low:15:1 W-EFFECT-OVER: this op DECLARES `wait` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)

Callers reason against the trait’s contract. If the implementer does something outside that contract, the reasoning goes wrong. A trait whose names match but whose effects differ cannot carry a contract.

23.5 When a trait is not satisfied#

DiagnosticMeaning
E-TRAIT-UNDEFsatisfies names an undeclared trait
E-TRAIT-MISSINGone op in the list is missing
E-TRAIT-SIGfn/proc written in a signature, or the op exists with a different parameter count
E-TRAIT-EFFECTthe implementing op has more effects than the signature
E-TRAIT-RECVsatisfies was written on an op instead of a type

Table 23.2 — Where a trait is not satisfied

examples/ch23/missing.low

module missing .
rem expect: E-TRAIT-MISSING

trait shape do
  area input s self . output u64 .
  perimeter input s self . output u64 .
end .

struct rect do
  satisfies shape .
  w u64 .
  h u64 .
end .

fn rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .

Output

$ lowentc --check missing.low
10:0 E-TRAIT-MISSING: this type claims to satisfy a trait, but the trait REQUIRES an op that the type does not have. Declare it as `fn <Type>.<name> …` (RFC-0062). A claim that is not checked is the defect this language exists to remove

satisfies is not a comment. The moment you write that you will satisfy a trait, the processor checks the whole list.

Q. Can a trait op have a default implementation?

A. No. A trait is only a list and holds no code. If several types want to share an implementation, write it as a generic op (like double_area) and have the types satisfy only the minimum that op needs. With default implementations you have to search upwards to find where a type’s area came from, and that is the entropy inheritance creates.

23.6 via self — more allocation effects only#

Some promises, like the allocator trait, have allocation effects that differ by implementation. When a signature’s effects line says via self, the implementer may declare more allocation-family effects (alloc, heap, lock, atomic) than the signature. Declaring more of any other effect is still E-TRAIT-EFFECT.

export trait byte_allocator do
  reserve input s self . input n usize . output option mut slice u8 . effects state via self .
  grow input s self . input old mut slice u8 . input newn u64 . output option mut slice u8 . effects state via self .
  used input s self . output u64 . effects state .
end .

A bump allocator’s reserve is just state, while heap_bytes, carving from the heap, has heap state. That difference rises through a generic op’s via a all the way to its callers (chapter 20).

The same device serves promises that are not allocators. Two actors satisfy a ticket-issuing trait: one only counts, the other carves bytes from the fixed window for every ticket.

examples/ch23/viaself.low

module viaself .
rem run: main

rem a promise to hand out tickets; an implementation may add allocation-family effects
trait ticketer do
  issue input s self . output u64 . effects state via self .
end .

rem only counts, so its effect is just state
actor counter do
  satisfies ticketer .
  state do
    upcoming u64 .
  end .
  proc issue output u64 . effects state . do
    set upcoming. add upcoming. 1 . .
    return upcoming. .
  end .
end .

rem carves 8 bytes from the fixed window per ticket, so it adds alloc
actor carver do
  satisfies ticketer .
  state do
    root cap allocator .
    count u64 .
  end .
  proc issue output u64 . effects alloc state . do
    let g option mut slice u8 alloc_bytes root. capacity 8 . .
    guard is_some g. . else return 0 . .
    set count. add count. 1 . .
    return mul count. 10 . .
  end .
end .

rem takes any implementation; via t carries that implementation's effects up to here
proc issue_two
  input comptime t type .
  input who t .
  output u64 .
  effects state via t .
  requires ticketer t. . .
do
  let a u64 send who. issue . .
  let b u64 send who. issue . .
  return add a. b. . .
end .

proc main input al cap allocator . output u8 . effects alloc state .
do
  var c counter spawn actor counter . .
  var k carver spawn actor carver . .
  let x u64 issue_two counter. c. . .
  let y u64 issue_two carver. k. . .
  return narrow u8 add x. y. . . .
end .

Output

$ lowentc --run main viaself.low
main() = 33

carver’s state has a capability field, root cap allocator .. The rule for that field is covered in chapter 25.

A common misconception. Satisfying a trait inherits something from it

Nothing is inherited. Satisfying a trait is only the fact that “these ops exist”, and no hierarchy arises between types. rect and square have no relation to each other even after satisfying shape. This language has no inheritance.

23.7 Common mistakes#

Counter-example. Writing the name before the value after method

examples/ch23/mistake_methodorder.low

module mistake_methodorder .
rem expect: E-METHOD-UNDEF

struct rect do
  w u64 .
  h u64 .
end .
fn rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .
fn f output u64 .
do
  let r lit rect do w 2 . h 3 . end . .
  rem ✘ after `method` comes the value first, then the name
  return method area. r. . .
end .

Output

$ lowentc --check mistake_methodorder.low
mistake_methodorder.low:16:0 E-METHOD-UNDEF: no op of that name is associated with the receiver's type — declare it as `fn <type>.<name> input <recv> <type> . …` (RFC-0062)

Translating r.area() from an object-oriented language backwards easily gives method area r. method takes the value first, because the value’s type decides which attached op to look for. With the name first, the tool reads area as the value, looks for an op r on its type, and reports E-METHOD-UNDEF. Write method r area.

Counter-example. Leaving the type prefix off the name of the implementing op

examples/ch23/mistake_noprefix.low

module mistake_noprefix .
rem expect: E-TRAIT-MISSING

trait shape do
  area input s self . output u64 .
end .
struct rect do
  satisfies shape .
  w u64 .
  h u64 .
end .
rem ✘ no `rect.` in the name --- an ordinary op, not attached to the type
fn area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .

Output

$ lowentc --check mistake_noprefix.low
8:0 E-TRAIT-MISSING: this type claims to satisfy a trait, but the trait REQUIRES an op that the type does not have. Declare it as `fn <Type>.<name> …` (RFC-0062). A claim that is not checked is the defect this language exists to remove

fn area input s rect . is an ordinary op that takes a rect, not an op attached to rect. The trait looks for rect.area, so this is E-TRAIT-MISSING. The tool does not attach ops by looking at their input types because, when several ordinary ops take the same type, the name alone should tell you which one fulfils the promise. This diagnostic lacks the file name; its line number points at the type declaration that says satisfies.

Counter-example. Treating a type as adopting a trait just because the op exists

examples/ch23/mistake_nosatisfies.low

module mistake_nosatisfies .
rem expect: E-BOUND-UNSAT

trait shape do
  area input s self . output u64 .
end .
rem ✘ `rect.area` exists, but `satisfies shape .` is not written
struct rect do
  w u64 .
  h u64 .
end .
fn rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .
fn double_area input comptime t type . input s t . output u64 .
  requires shape t. . .
do
  return mul 2 method s. area. . . .
end .
fn f output u64 .
do
  let r lit rect do w 2 . h 3 . end . .
  return double_area rect. r. . .
end .

Output

$ lowentc --check mistake_nosatisfies.low
17:0 E-BOUND-UNSAT: this generic was instantiated with a type that does NOT satisfy the required trait. A bound is a PROMISE the callee relies on — instantiating past it would make the callee's contract a lie (RFC-0021 §6.3). Add `satisfies <trait> .` to the struct, and the required ops as `fn <Type>.<name>`

With rect.area in place, rect is indeed “a type that reports an area”. Without satisfies shape ., though, double_area rect r is E-BOUND-UNSAT. Under duck typing, where matching shape is enough, an op that happens to share a name is wrongly read as fulfilling the promise. satisfies declares “I will keep this contract”, and only that declaration makes the processor check the whole list.

Counter-example. Putting the receiver somewhere other than the first input

examples/ch23/mistake_recvlast.low

module mistake_recvlast .
rem expect: E-METHOD-RECV

struct rect do
  w u64 .
  h u64 .
end .
rem ✘ the receiver `s` is not the first input
fn rect.scaled input k u64 . input s rect . output u64 .
  requires le k. 100 . .
do
  return mul k. field s. w . . .
end .
fn f output u64 .
do
  let r lit rect do w 2 . h 3 . end . .
  return method r. scaled. 5 . .
end .

Output

$ lowentc --check mistake_recvlast.low
mistake_recvlast.low:9:0 E-METHOD-RECV: an op attached to a type takes that type as its FIRST input — that is what `method <value> <name> …` passes. Here the receiver is not first, so a `method` call hands the value to the wrong parameter: it used to pass `--check` and stop at run time with `E-VM-TYPE`. Move the receiver to the first `input` clause

The first input of an attached op is the receiver. method r scaled 5 puts r in the first position and 5 in the second. scaled takes the first as k and the second as s, so it tries to multiply a struct and stops. Such a head is rejected at translation time with E-METHOD-RECV. Always put input s rect . first in an attached op.

A common misconception. An op attached to a type can only be called through method

examples/ch23/direct_call.low

module direct_call .
rem run: f

struct rect do
  w u64 .
  h u64 .
end .
fn rect.area input s rect . output u64 .
do
  return mul field s. w . field s. h . . .
end .
fn f output u64 .
do
  let r lit rect do w 2 . h 3 . end . .
  rem the same op called two ways --- directly by name, and through `method`
  return add rect.area r. . method r. area. . . .
end .

Output

$ lowentc --run f direct_call.low
f() = 12

rect.area r. . calls it directly by name, and method r area finds the same op from the value’s type. They are the same op, and the results are 6 and 6. method earns its place where the type is a generic parameter and its name cannot be written (method s area in double_area). Where the type is known, a direct call also propagates effects correctly (see the defect case above).

23.8 This chapter’s syntax at a glance#

ShapeMeaningWhy
fn rect.area input s rect . …attach an op to a type — the first input is the receivera way to divide the name space, not inheritance
method r area · rect.area r. .call by the value’s type · call directly by namefixed at translation time — no virtual table
trait shape do area input s self . output u64 . end .the list of ops a type must haveself is the adopting type itself
signature line: name · inputs · output · effectssame order as an op head; no fn/procthe effects line caps the implementer
struct rect do satisfies shape . … end .declare that this type keeps the promisethe declaration triggers the full check
requires shape t. . .type condition of a generic opa type that does not adopt it: E-BOUND-UNSAT
effects state via self . (signature)only allocation effects may be addedallocators differ in effects
E-TRAIT-MISSING · -SIG · -EFFECT · -RECV · -UNDEFone diagnostic per way of falling shortsatisfies is not a comment
effects state via t . (generic op)inherits the extra effects the specialised type declared with via selfstate for counter, alloc state for carver

Table 23.3 — Trait syntax — shape · meaning · why it looks this way

Recap

fn <type>.<name> attaches an op to a type and method calls it. A trait is a list of ops a type must have, and a type declares it satisfies one with satisfies in its body. Signatures start with a name and write inputs, output and effects in order, without fn or proc. A signature’s effects is the upper bound on the implementer’s effects. Mismatches are rejected with E-TRAIT-*, and via self allows only more allocation-family effects. A trait is not inheritance.