23 Traits — one promise kept by many types
What to know first
struct and fieldrequires <trait> t . is a type conditionLooking 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
By the end of this chapter
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
- 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
trait shape do … endis the promise “hasarea”.selfin a signature is the type that will satisfy the promise.rectandsquarewritesatisfies shape .in their bodies, declaring they will satisfy it.fn rect.areaandfn square.areaactually satisfy it. Therect.in the name means the op is attached torect.double_areaaccepts any shape.requires shape t .pins it down to “only types that can tell their area”.method s areacalls theareaattached to the type ofs—rect.areafor arect,square.areafor asquare.
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.areadouble_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.
- One line per signature. A line starts with the op’s name and then writes clauses in the same order as an op head — inputs, output, effects. The next name starts the next signature. Write as many as you like.
- When the output is
self, as ingrow, the implementer returns its own type (rect). - An implementing op is named
<type>.<name>.
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
--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 effects | Implementer |
|---|---|
| none | a fn, or a proc that writes effects |
present, e.g. effects panic | a 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#
| Diagnostic | Meaning |
|---|---|
E-TRAIT-UNDEF | satisfies names an undeclared trait |
E-TRAIT-MISSING | one op in the list is missing |
E-TRAIT-SIG | fn/proc written in a signature, or the op exists with a different parameter count |
E-TRAIT-EFFECT | the implementing op has more effects than the signature |
E-TRAIT-RECV | satisfies 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 .
endA 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
- The signature is
issue input s self . output u64 . effects state via self ..countersatisfies it witheffects state .andcarverwitheffects alloc state ..allocbelongs to the allocation family, sovia selfallows it. issue_twosayseffects state via t .. The instance specialised tocounterhas the effectstate; the one specialised tocarverhasalloc state. The effects are exact per instance.- That is why
mainmust receivecap allocatorand declarealloc. Without the line that calls withcarver,mainwould need noalloc. - The answer 33 is
counter’s 1 + 2 pluscarver’s 10 + 20.
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
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#
| Shape | Meaning | Why |
|---|---|---|
fn rect.area input s rect . … | attach an op to a type — the first input is the receiver | a way to divide the name space, not inheritance |
method r area · rect.area r | call by the value’s type · call directly by name | fixed at translation time — no virtual table |
trait shape do area input s self . output u64 . end | the list of ops a type must have | self is the adopting type itself |
| signature line: name · inputs · output · effects | same order as an op head; no fn/proc | the effects line caps the implementer |
struct rect do satisfies shape . … end | declare that this type keeps the promise | the declaration triggers the full check |
requires shape t . | type condition of a generic op | a type that does not adopt it: E-BOUND-UNSAT |
effects state via self . (signature) | only allocation effects may be added | allocators differ in effects |
E-TRAIT-MISSING · -SIG · -EFFECT · -RECV · -UNDEF | one diagnostic per way of falling short | satisfies is not a comment |
effects state via t . (generic op) | inherits the extra effects the specialised type declared with via self | state 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.