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 rect be make rect do w 2 . h 3 . end .
  let q square be make 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 make 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 rect be make rect do w w . h h . end .
  let big rect be method r grow 1 .
  return add (method big perimeter) (method big checked_area) .
end

proc demo_empty output u64 . effects panic .
do
  let r rect be make 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 without an effects line is not narrowed and reads as able to do anything (chapter 5). So implementing an effect-free signature with such a proc is rejected.

examples/ch23/proc_noeff.low

module proc_noeff .
rem expect: E-TRAIT-EFFECT

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
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 u64 . 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
    next u64 .
  end
  proc issue output u64 . effects state . do
    set next (add next 1) .
    return next .
  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 . . be 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 be send who issue .
  let b u64 be send who issue .
  return add a b .
end

proc main input al cap allocator . output u8 . effects alloc state .
do
  var c counter be spawn actor counter .
  var k carver be spawn actor carver .
  let x u64 be issue_two counter c .
  let y u64 be 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 rect be make 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 rect be make 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 rect be make 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 rect be make 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 rcall by the value’s type · call directly by namefixed at translation time — no virtual table
trait shape do area input s self . output u64 . endthe 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 . … enddeclare 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.