Antra

Struktur und Typen

Protokolle

Ein Protokoll beschreibt eine Fähigkeit, die viele Typen haben können: sich anzeigen, addieren, vergleichen, indexieren, aufrufen. Ein Typ bekommt die Fähigkeit, indem er das Protokoll implementiert. Danach funktionieren die passenden Operatoren und Funktionen für seine Werte, ohne dass der aufrufende Code wissen muss, welcher Typ dahintersteckt.

Die Idee kennt man aus Clojure (Protokolle), Rust (Traits) oder Python (Dunder-Methoden). In Antra sind es kleine benannte Gruppen von Methoden, die nach dem Wert dispatchen, auf dem sie aufgerufen werden.

Ein erstes Beispiel#

protocol Shape {
    fn __area__(self)
    fn __describe__(self) -> "Fläche " <> str(__area__(self))
}

impl Shape for %Circle{..} {
    fn __area__(%Circle{r:}) -> 3 * r * r
}

impl Shape for %Rect[..] {
    fn __area__(%Rect[w, h]) -> w * h
    fn __describe__(_) -> "ein Rechteck"
}

println(__area__(%Circle{r: 2}))                  # 12
println(__describe__(%Circle{r: 1}))              # Fläche 3
println(__describe__(%Rect[2, 3]))                # ein Rechteck
println(map([%Circle{r: 1}, %Rect[1, 2]], &__area__))   # [3, 2]

Das Protokoll Shape verlangt __area__ und bringt für __describe__ einen Default mit. %Circle{..} liefert nur __area__ und erbt den Default, %Rect[..] überschreibt beide. __area__(x) ruft die Methode, die zum Typ von x gehört.

Protokoll, impl und Methodennamen#

  • protocol Name { … } deklariert die Methoden. Eine Methode ohne Rumpf (fn __area__(self)) ist abstrakt und muss von jedem impl geliefert werden, eine mit Rumpf ist ein Default. Fehlt eine abstrakte Methode, meldet Antra das schon bei der Definition des impl, nicht erst beim ersten Aufruf.
  • impl Name for Ziel { … } liefert die Methoden für einen Typ.
  • Methodennamen sind Dunder-Namen (__area__): Unter diesem Namen ist die Methode überall als Funktion aufrufbar und dispatcht über ihr erstes Argument. Ein Name ohne Unterstriche (area) ist nur innerhalb der impl-Methoden sichtbar.
  • Dispatch über das erste Argument: __area__(x) sucht das impl für den Typ von x. Fehlt eins, ist das ein klarer Fehler (No implementation of __area__ for type '%Tri[]').
  • Innerhalb eines impl sind die anderen Methoden desselben Protokolls direkt aufrufbar, wie __area__(self) im Default oben.

Worauf ein impl zielt#

ZielBeispielWerte
Getaggtes Arrayimpl P for %Rect[..]%Rect[2, 3]
Getaggte Mapimpl P for %Circle{..}%Circle{r: 1}, auch ein bless-Objekt des Packages Circle
Getaggtes Setimpl P for %Tags<..>%Tags<:a, :b>
Builtin-Typimpl P for Number5, 1.5 (auch String, Array, Map, Set, Range, Generator, Bool)
Opaker Typimpl P for FileHandleoffene Dateien (auch ByteString, Socket, Process, Blob)
Getaggte Map mit Feldtypenimpl P for %Vec{x :- Int, y :- Int}%Vec{x: 1, y: 2}; --check kennt die Felder
typedefimpl P for Resultjedes Mitglied der Union, hier %Ok[..] und %Err[..]
typedef eines Recordsimpl P for Vec mit typedef Vec = %Vec{x :- Int, y :- Int}%Vec{x: 1, y: 2}; --check kennt die Felder

%Tag[..], %Tag{..} und %Tag<..> sind drei verschiedene Ziele. Ein Package-Name (impl P for Wallet) ist kein Ziel; für Package-Objekte nimmt man %Wallet{..} oder package Wallet with P.

Nennt das Ziel seine Felder, gelten sie für die Operanden der Methoden:

import Antra::Ops.{Add}

typedef Vec = %Vec{x :- Int, y :- Int}

impl Add for Vec {
    fn __add__(a, b) -> %Vec{x: a.x + b.x, y: a.y + b.y}
}

println(%Vec{x: 1, y: 2} + %Vec{x: 3, y: 4})   # %Vec{ :x => 4, :y => 6 }

Hier sind a und b beide Vec (__add__ aus Antra::Ops erwartet zweimal Self), und ein Tippfehler wie a.z ist ein --check-Fehler. Mit %Vec{..} als Ziel ist nur bekannt, dass es eine %Vec-Map ist.

Das Protokoll muss sichtbar sein: deklariert in der Datei oder importiert (import Antra::Ops.{Add}, import Antra::Ops.*). Ohne Import ist impl Add for … ein Fehler, in --check wie zur Laufzeit.

Defaults, Klauseln und Guards#

Eine Methode darf mehrere Klauseln haben. Sie werden der Reihe nach gegen die Argumente gematcht, mit Patterns und when-Guards wie bei einer fn:

protocol Size {
    fn __size__(self)
}

impl Size for %Box[..] {
    fn __size__(%Box[]) -> :empty
    fn __size__(%Box[..xs]) when len(xs) > 2 -> :big
    fn __size__(_) -> :small
}

println([__size__(%Box[]), __size__(%Box[1]), __size__(%Box[1, 2, 3])])   # [:empty, :small, :big]

Ein Default im Protokoll kann die abstrakten Methoden benutzen und gilt für jedes impl, das ihn nicht überschreibt.

package P with Proto#

Ein Package kann Protokolle direkt im Header nennen. Die Protokoll-Methoden stehen dann im Package-Body, das Ziel ist %P{..}:

import Antra::Ops.*

package Money with Add, Eq, Ord {
    pub field cents = 0
    pub fn new(c) -> bless(%{cents: c}, Money)
    fn __add__(a, b) -> Money.new(a.cents + b.cents)
    fn __eq__(a, b) -> a.cents == b.cents
    fn __cmp__(a, b) -> a.cents <=> b.cents
}

val m = Money.new(5) + Money.new(7)
println([m.cents, m == Money.new(12), m > Money.new(3)])   # [12, true, true]

Wie bei einem impl prüft Antra schon bei der Package-Definition, ob jede abstrakte Methode da ist. Mehr zu Packages, bless und Feldern steht in Kapitel 14.

Operatoren#

Hat der linke Operand einen Tag (%T[..], %T{..}, %T<..>), sucht der Operator die passende Methode für dessen Typ:

OperatorMethode
a + b, a - b, a * b, a / b, a % b__add__, __sub__, __mul__, __div__, __mod__
-a__neg__(a)
a == b, a != b__eq__ (!= ist das Gegenteil); ohne Impl Vergleich der Inhalte
a < b, a <= b__lt__ bzw. __le__, sonst aus __cmp__
a > b, a >= b, a <=> b__cmp__ (liefert :lt, :eq oder :gt)

__cmp__ reicht für alle Vergleiche, und Array.sort, Array.min und Array.max ordnen getaggte Werte ebenfalls darüber:

import Antra::Array

protocol Ord {
    fn __cmp__(a, b)
}

impl Ord for %Version[..] {
    fn __cmp__(%Version[a, b], %Version[c, d]) -> if a == c then b <=> d else a <=> c
}

println(%Version[1, 2] > %Version[1, 0])                 # true
println(Array.sort([%Version[2, 0], %Version[1, 5]]))    # [%Version[1, 5], %Version[2, 0]]

Fehlt die Methode, ist das ein Fehler (No implementation of __cmp__ for tag 'Version'), nur ==/!= vergleichen dann die Inhalte. Ungetaggte Werte (Zahlen, Strings, Arrays) rechnen wie immer.

Zugriff: x[k], in, len#

AusdruckMethodeErgebnis
x[k], x?[k]__get__(x, k)der Wert
v in x__contains__(x, v)true oder false
len(x)__count__(x)%Some[n], oder %None[] für die Länge der Daten

Innerhalb der Methode greift derselbe Zugriff auf denselben Wert direkt auf die Daten zu. Ein Array mit Index ab 1 ist deshalb ein Einzeiler:

protocol Access {
    fn __get__(self, k)
    fn __contains__(self, v)
    fn __count__(self)
}

impl Access for %Vec[..] {
    fn __get__(self, k) -> self[k - 1]
    fn __contains__(self, v) -> v in [..self]
    fn __count__(self) -> %Some[len(self)]
}

val v = %Vec[10, 20, 30]
println([v[1], 20 in v, len(v)])   # [10, true, 3]

Bereiche und Slices (x[1..2], x[0:2]) lesen immer die Daten. __count__ ist dieselbe Methode wie in Antra::Enumerable und liefert deshalb ein Option.

Sets und Aufrufe: __hash__ und __call__#

__hash__(x) bestimmt, wann zwei getaggte Werte in einem Set dasselbe Element sind. __call__(x, …) macht einen Wert aufrufbar, auch als Callback:

protocol Key {
    fn __hash__(self)
}
impl Key for %User{..} {
    fn __hash__(self) -> self.id
}

protocol Callable {
    fn __call__(self, x)
}
impl Callable for %Adder{..} {
    fn __call__(self, x) -> x + self.n
}

val users = %<%User{id: 1, name: "Ada"}, %User{id: 1, name: "Ada L."}>
println([len(users), %User{id: 1} in users])   # [1, true]

val add2 = %Adder{n: 2}
println([add2(40), map([1, 2], add2)])        # [42, [3, 4]]

__hash__ gilt für Set-Literale, in und Set.*, aber nur für das Element selbst; Map-Schlüssel bleiben Text. In __call__ ist self(…) nicht aufrufbar, damit es keine Endlosschleife gibt.

Anzeige: __str__#

str(...), print/println und String-Interpolation suchen __str__. Ohne Impl zeigen sie die Struktur (%Wallet{ :cents => 950 }):

import Antra::Display.*

impl Display for %Euro[..] {
    fn __str__(%Euro[c]) -> str(c / 100) <> " EUR"
}

println(%Euro[950])      # 9.5 EUR
println([%Euro[100]])    # [1 EUR]

dd(...) und Fehlermeldungen nutzen __str__ bewusst nicht; sie zeigen, was wirklich im Wert steht.

Eigene Sequenzen: impl Enumerable#

Arrays, Ranges, Strings, Maps, Sets und Generatoren durchlaufen for, die Built-ins map/filter und alle Funktionen aus Antra::Enum von sich aus. Ein eigener Typ kommt mit einer Methode dazu: __reduce__ aus Antra::Enumerable.

import Antra::Enumerable.*
import Antra::Enum
import Antra::Array

# ein Stapel: das zuletzt gelegte Element liegt oben und kommt zuerst
impl Enumerable for %Stack[..] {
    fn __reduce__(self, acc, f) -> Enum.reduce(Array.reverse([..self]), acc, f)
    fn __count__(self) -> %Some[len([..self])]
}

val s = %Stack[1, 2, 3]
println(Enum.map(s, \_ * 10))           # [30, 20, 10]
println(Enum.find(s, \_ < 3))           # %Some[2]
println(Enum.count(s))                  # 3
println(map(s, |x, i| -> "${i}:${x}"))  # ['0:3', '1:2', '2:1']
for x <- s { print(x, " ") }            # 3 2 1
println()
println(implements?(s, Enumerable))     # true

__reduce__(self, acc, f) ist die Pflicht: Sie ruft f(acc, x) für jedes Element in der gewünschten Reihenfolge und gibt den letzten acc zurück. Am einfachsten reicht sie an ein Array weiter, wie oben ([..self] sind die Daten des getaggten Werts; ein len(self) oder Enum.*(self) in der Methode würde sich selbst aufrufen). Damit funktioniert alles andere: map, filter, find, reduce, zip, chunk, for, die Position im Callback (|x, i|, der Index) und implements?.

Drei weitere Methoden sind optional und nur Abkürzungen, die Enum nutzt, wenn sie da sind; jede liefert %Some[…] oder %None[] (dann wird gezählt bzw. durchlaufen):

MethodewofürErgebnis
__count__(self)Enum.count, Enum.empty?, Enum.drop%Some[n]
__member__(self, v)Enum.member?%Some[true] / %Some[false]
__slice__(self, start, n)Enum.take, Enum.drop%Some[array]

Ohne __count__ zählt Enum.count die Elemente über __reduce__ ab. Für len(x) gilt unabhängig davon __count__ aus dem Zugriff (siehe oben).

Der statische Checker weiß das auch: ein Wert mit impl Enumerable passt auf Seq[T], also auf jede Funktion aus Antra::Enum; ein getaggter Wert ohne impl ist dort ein Typfehler.

Eigene Literale: impl Refine#

Ein getaggtes Literal wie regex"\d+" ist zuerst ein getaggter Wert: ein String-Literal name"…" wird zu %name["…"], %Tag{…} und %Tag<…> bleiben, was sie sind. Gibt es für diese Form ein impl Refine (aus Antra::Refine), ruft der Interpreter beim Erzeugen __refine__(self) und nimmt dessen Ergebnis. So wird aus regex"…" ein kompilierter Regex, und so baut man eigene Literale, die schon beim Schreiben geprüft werden:

import Antra::Refine.*

# email"…": beim Erzeugen geprüft, danach ein %Email{…}
impl Refine for %email[..] {
    fn __refine__(%email[text]) -> {
        val parts = split(text, "@")
        throw "not an email address: ${text}" unless len(parts) == 2 && parts[0] != "" && parts[1] != ""
        %Email{user: parts[0], domain: parts[1]}
    }
}

val e = email"ada@example.org"
println(e.domain)                 # example.org

val name = "grace"
println(email"${name}@navy.mil")  # %Email{ :domain => 'navy.mil', :user => 'grace' }

println <| try {
        email"kaputt"
    } catch {
        err -> str(err)
    }   # not an email address: kaputt
  • Die drei String-Formen gelten wie überall: email"…" interpoliert vor __refine__, email'…' bleibt roh, email`…` interpoliert ohne Escapes.
  • Ziel des impl ist die getaggte Form: %email[..] für email"…", %Sum{..} für %Sum{a: 3, b: 4}, %Marker<..> für %Marker<1, 2>.
  • __refine__ darf alles liefern: einen anderen getaggten Wert, eine Zahl, einen String, auch dieselbe Form. Ein Literal derselben Form innerhalb von __refine__ wird nicht noch einmal verfeinert, so kann eine Methode den Wert ergänzen (%Pt{x:, y:} → %Pt{x: x, y: y, len: x + y}).
  • Ein getaggtes Array-Literal %tag[…] wird nicht verfeinert, nur die String-Form tag"…"; %Some[…] & Co. bleiben so einfache Werte.
  • regex, js und css funktionieren ohne Import über einen eingebauten Weg; Antra::Refine::Regex zeigt dieselbe Umsetzung als impl Refine und taugt als Vorlage.

Fertige Protokolle der Standardbibliothek#

PaketProtokolleWofür
Antra::OpsAdd, Sub, Mul, Div, Mod, Neg, Eq, Ord, Index, Contains, Hashje ein Operator oder Zugriff
Antra::DisplayDisplay__str__
Antra::EnumerableEnumerableeigene Sequenzen für Antra::Enum (__reduce__, __count__, __member__, __slice__)
PreludeUnwrap, Propagate, UnwrapOrThrow.?, .^ und .! für eigene Ergebnis-Typen (Kapitel 11)
Antra::RefineRefineeigene getaggte Literale (__refine__, siehe oben)
Antra::DurationDurationRechnen und Vergleichen von 10sec, 2min usw. über eine gemeinsame Basis (__base__, __add__, __eq__, …; Stdlib-Referenz)

Antra::Ops hat bewusst kein Sammel-Protokoll wie Num: Ein impl muss jede abstrakte Methode liefern, und ein 2D-Vektor soll addieren können, ohne % zu erfinden. Ein Typ nimmt sich mit package Vec with Add, Mul, Eq genau die, die er kann. Nötig sind die fertigen Protokolle nicht: Die Operatoren schauen nur nach dem Methodennamen, ein eigenes Protokoll mit __add__ wirkt genauso.

Typen an Protokollen#

Protokoll- und impl-Methoden dürfen :--Annotationen tragen. Die der Deklaration gelten für jedes impl, das selbst keine schreibt, und Self steht für den implementierenden Typ:

protocol Semigroup {
    fn __add__(a :- Self, b :- Self) :- Self
}

impl Semigroup for %V[..] {
    fn __add__(%V[a, b], %V[c, d]) -> %V[a + c, b + d]
}

val v :- %V[Number, Number] = %V[1, 2] + %V[3, 4]
println(v)               # %V[4, 6]

--check prüft dann den Rumpf, die Operanden (%V[1, 2] + 5 ist ein Fehler) und das Ergebnis. In den Methoden eines Packages steht Self für das Package selbst (pub fn moved(self :- Self) :- Self); ein impl mit eigenen, abweichenden Annotationen meldet impl-signature-mismatch. --check-types oder @types(runtime) vor dem impl prüfen dasselbe zur Laufzeit. Die Einzelheiten stehen in Kapitel 16.

Fehler und ihre Bedeutung#

MeldungBedeutung
No implementation of __area__ for type '%Tri[]'Für diesen Typ gibt es kein impl (oder das Paket ist nicht importiert)
impl Shape for %Tri[..] does not satisfy protocol 'Shape': missing method '__area__'Ein impl liefert eine abstrakte Methode nicht (bei der Definition)
package 'Money' does not satisfy protocol 'Ord': missing method '__cmp__'Ein package … with liefert eine abstrakte Methode nicht
Conflicting implementations for __count__ on %Bag{..} …Zwei Protokolle definieren dieselbe Methode für denselben Typ
Duplicate impl P for %Ok[]Dasselbe Protokoll ist für diesen Typ zweimal implementiert
Unknown impl target 'Wallet'Das Ziel ist kein Typ (ein Package-Name statt %Wallet{..})
impl Add for %V[..]: unknown protocol 'Add'Das Protokoll ist weder deklariert noch importiert (import Antra::Ops.{Add})
No matching clause for protocol method '__size__' …Keine Klausel der Methode passt zu den Argumenten
__cmp__ must return :lt, :eq or :gt__cmp__ hat etwas anderes geliefert
__count__ for %V[] must return %Some[n] or %None[]__count__ hat etwas anderes geliefert

Kurzreferenz#

MethodeWirkt auf
__add__, __sub__, __mul__, __div__, __mod__+ - * / %
__neg__-a
__eq__==, !=
__lt__, __le__, __cmp__< <= > >= <=>, Array.sort/min/max
__get__x[k], x?[k]
__contains__v in x
__count__len(x), Enum.count
__hash__Gleichheit in Sets
__call__x(…), Callbacks
__str__str, print, Interpolation
__unwrap__, __propagate__, __unwrap_bang__.?, .^, .!

Quelle dieser Seite