Antra

Struktur und Typen

Classes und bless-Objekte

Dieses Kapitel beschreibt die erste kleine Objekt-Stufe in Antra.

Wichtig ist dabei das mentale Modell:

  • Daten bleiben normale Maps oder Tagged Maps.
  • Packages bleiben normale Packages.
  • bless(...) haengt nur Methoden-Dispatch an einen Wert.
  • obj->method(...) ist Zucker ueber normale Package-Funktionen.

Antra fuehrt hier also bewusst kein schweres Klassensystem ein.

Die Kurzversion#

Wenn du nur das Wesentliche mitnehmen willst:

  • bless(value, class) bindet einen Wert an ein Package.
  • Package->new(...) ist ein normaler Package-Call mit class als erstem Argument.
  • obj->method(...) ruft eine Package-Funktion mit self als erstem Argument auf.
  • obj.field bleibt normaler Datenzugriff.
  • field und pub field registrieren Metadaten ueber bekannte Slots.
  • deklarierte Felder koennen auch steuern, ob obj.prop = ... von aussen erlaubt ist.
  • Antra::Class liefert kleine Helper fuer Copy/Reflection.

Warum das ueberhaupt existiert#

Viele praktische Modelle sehen in Antra so aus:

  • ein Wert mit benannten Feldern
  • ein Package mit Konstruktion und Verhalten
  • funktionale Updates statt stiller In-Place-Mutation

Genau dafuer ist die bless-Stufe gedacht. Sie ist vor allem fuer Dinge wie kleine Domain-Objekte, ORM-nahe Werte, Service-Wrapper oder API-Modelle interessant.

Das Grundmodell#

Eine typische erste Form sieht so aus:

package Person {
    pub field name
    pub field age

    pub fn new(class, [name:, age:]) ->
        bless(%Person{name: name, age: age}, class)

    pub fn greet(self) -> "Hi " <> self.name
}

val p = Person->new(name: "Ada", age: 30)
println(p->greet())
println(p.name)

Wichtig daran:

  • %Person{...} bleibt ein normaler Tagged-Map-Wert.
  • bless(...) fuegt nur die Package-Bindung hinzu.
  • p.name liest Daten.
  • p->greet() nutzt das gebundene Package fuer Dispatch.

bless(value, class)#

bless(...) ist aktuell ein Built-in.

bless(%Person{name: "Ada", age: 30}, Person)

Die Reihenfolge ist absichtlich wert-zentriert:

  • erst der Wert
  • dann das Package

Das passt gut zum Datenmodell und auch gut zum Pipe-Stil:

%Person{name: name, age: age} |> bless(class)
%{self | age: self.age + 1} |> bless(Person)

Die zugrunde liegende Idee ist: ein bestehender Wert bekommt eine Methoden-Bindung. bless(...) konstruiert also kein schweres Klassenobjekt, sondern annotiert einen normalen Wert fuer den spaeteren ->-Dispatch.

Klassenmethoden mit Package->method(...)#

Package->method(...) ist normaler Dispatch ueber ein Package.

Person->new(name: "Ada", age: 30)

Das ist sinngemaess:

Person.new(Person, name: "Ada", age: 30)

Darum ist die uebliche Konvention:

  • erster Parameter heisst class
  • new(...) ist nur ein normaler Name, kein Spezialkeyword

Ein Konstruktor bleibt also eine ganz normale Package-Funktion.

Instanzmethoden mit obj->method(...)#

Wenn ein Wert per bless(...) an ein Package gebunden wurde, kann er Methoden ueber -> dispatchen:

val p = Person->new(name: "Ada", age: 30)
println(p->greet())

Sinngemaess laeuft das wie:

Person.greet(p)

Auch hier gibt es kein zweites Objektmodell. Der wichtige Punkt ist nur, dass self explizit erster Parameter bleibt.

Datenzugriff bleibt obj.field#

obj.field und obj->method(...) sind bewusst getrennt:

  • obj.field liest Daten direkt aus der Map oder Tagged Map
  • obj->method(...) ist Package-Dispatch

Das ist wichtig, weil Felder und Methoden in Antra nicht dieselbe Art von Ding sind.

println(p.name)
println(p.age)
println(p->greet())

field und pub field#

Package-Felder sind aktuell deklarative Slot-Metadaten.

package Person {
    pub field name
    pub field age
}

Die aktuelle MVP-Bedeutung ist absichtlich klein:

  • Felder dokumentieren bekannte Slots eines bless-Objekts.
  • pub field markiert sie als oeffentlich sichtbar.
  • field/pub field erzeugen keine automatischen Getter oder Setter.

Defaults#

Ein Feld darf einen Default mitbringen:

package Wallet {
    pub field cents
    pub field code = "EUR"

    pub fn new(c) -> bless(%{cents: c}, Wallet)
}

println(Wallet.new(950).code)     # EUR

Der Default gehoert damit ans Feld statt in jeden Konstruktor — new, from_json und jeder weitere Weg bekommen ihn gleichermassen.

  • Er wird bei jeder Bindung ausgewertet, nicht einmal bei der Package-Definition. Ein field xs = [] gibt also jeder Instanz ein eigenes Array.
  • Ein vorhandener Wert gewinnt: bless(%{cents: 5, code: "USD"}, Wallet) behaelt "USD". Auch ein ausdrueckliches code: undef bleibt undef — sonst gaebe es keinen Weg, ein Feld bewusst leer zu lassen.
  • Im Default stehen duerfen const-Bindungen, Funktionen, Importe und frueher deklarierte Felder (field b = a * 2). Eine veraenderliche Bindung von aussen ist ein Fehler — dieselbe Regel wie bei Parameter-Defaults.

bless nimmt auch ein Keyword-Array#

Optionen und ..rest-Parameter kommen in Antra als Keyword-Array an. bless nimmt diese Form direkt, sodass ein Konstruktor sie durchreichen kann, statt jeden Key aufzuzaehlen:

package Wallet {
    pub field cents
    pub field code = "EUR"

    pub fn new([cents:, ..rest]) -> bless([cents: cents, ..rest], Wallet)
}

println(Wallet.new(cents: 950).code)             # EUR   - aus dem Default
println(Wallet.new(cents: 5, code: "USD").code)  # USD   - vom Aufrufer

new muss code damit gar nicht kennen. Bei doppelten Keys gewinnt der letzte — wie beim Destructuring eines Keyword-Arrays. Ein Array ohne Keyword-Keys ([1, 2]) bleibt ein Fehler.

Der Feld-Check bei bless#

Deklariert ein Package Felder, prueft bless die Daten dagegen:

package Person { pub field name }

bless(%{nmae: "Ada"}, Person)
# Error: package 'Person' has no field 'nmae'

Ein Package ohne jede field-Deklaration nimmt weiter beliebige Maps an — bless bleibt so fuer fremde Daten (JSON, DB-Zeile) benutzbar.

Derselbe Check gilt spaeter beim Schreiben: obj.prop = ... mit einem unbekannten Namen ist ebenfalls ein Fehler (siehe unten).

Pflichtfelder ergeben sich aus der Annotation: Ein Feld mit Typ, ohne Default und mit einem Typ, der undef nicht zulässt, muss angegeben werden. Felder ohne Annotation, mit Default oder mit optionalem Typ (String?) dürfen fehlen und sind dann undef:

package Body {
    pub field m :- Int                        # Pflicht
    pub field v :- %Vec{x :- Int, y :- Int}   # Pflicht
    pub field note :- String?                 # optional
    pub field size = 1                        # optional: Default
}

bless(%{m: 2}, Body)
# Error: package 'Body' requires field 'v' (typed and without a default); ...

Ein Literal %Body{...} verhaelt sich genauso wie bless, sobald das Package Felder deklariert: Defaults werden gefuellt, ein unbekannter Schluessel oder ein fehlendes Pflichtfeld ist ein Fehler, und der Wert ist ein gebundenes Objekt.

package Vec {
    pub field x :- Int = 0
    pub field y :- Int = 0
}
println(%Vec{x: 5})   # %Vec{ :x => 5, :y => 0 }

Offene Packages: ..#

Eine ..-Zeile im Rumpf erlaubt Schlüssel über die deklarierten Felder hinaus. Die deklarierten Felder gelten weiter, mit ihren Typen und als Pflicht:

package Body {
    pub field m :- Int
    pub field v :- %Vec{x :- Int, y :- Int}
    ..
}

%Body{m: 2, v: %Vec{x: 0, y: 0}, name: "Erde"}   # ok
%Body{m: 2}                                      # Fehler: v fehlt

Ohne .. ist ein unbekannter Schlüssel ein Fehler, bei bless zur Laufzeit, bei einem Literal %Body{…} schon bei --check. Ein Literal %Body{…} eines Packages ohne Felder kann deshalb gar keinen Schlüssel haben; dafür gibt es .. oder @dynamic. bless bleibt bei einem Package ohne Felder offen für fremde Daten.

weitere Schlüsseldeklarierte Felder geprüft
package Body { field … }Fehlerja
package Body { field …; .. }erlaubtja
@dynamic package Bodyerlaubtnein

Deklarierte Felder sind die vollstaendige Slot-Liste#

Ein Name, den das Package nicht als field deklariert und der im Wert auch nicht existiert, ist beim Lesen ein Fehler — nicht undef:

package Wallet { pub field cents; pub field code }

rent.cnts    # Error: package 'Wallet' has no field 'cnts'

Das ist dieselbe Regel, die beim Package selbst schon gilt (W.gibtsnicht → package 'W' has no member 'gibtsnicht'), und aus demselben Grund: ein Tippfehler soll nicht als undef weiterlaufen und erst drei Funktionen spaeter als „Undef cannot be used with <>" auffallen.

Drei Faelle bleiben bewusst undef:

  • Deklariert, aber ohne Wert (rent.code, wenn nichts gesetzt wurde, es keinen Default gibt und das Feld kein Pflichtfeld ist) — „leer" ist dann ein normaler Zustand.
  • Dynamisch angelegt: obj.dyn := 7 erzeugt den Slot, obj.dyn liest ihn. := ist der „erzeuge, was fehlt"-Operator und bleibt es auch hier.
  • Package ohne jede field-Deklaration — bleibt map-artig, ein @dynamic-Package ebenfalls, und bei einem offenen Package (..) die nicht deklarierten Namen.

pub field gilt fuers Lesen und Schreiben#

pub entscheidet ueber Lesen und Schreiben von aussen:

  • pub field age — obj.age und obj.age = ... sind von aussen erlaubt.
  • field age — beides ist von aussen ein Fehler (Cannot read private field 'age' of package 'Person'.). Wer den Wert nach aussen geben will, schreibt einen Accessor: pub fn age(self) -> self.age.
  • Innerhalb des eigenen Package-Kontexts bleiben self.age und self.age = ... selbstverstaendlich erlaubt.
  • Auch obj?.age umgeht das nicht: ?. faengt fehlende Werte ab, nicht fehlende Rechte.
  • Wenn das Package Felder deklariert und prop keines davon ist, ist obj.prop = ... ein Fehler (package 'W' has no field 'prop') — derselbe wie bei bless. Ein Package ohne jede field-Deklaration darf Properties weiterhin frei anlegen und frei lesen; die Sichtbarkeitsregel greift nur fuer deklarierte Felder.

Ein kleines Beispiel:

package Person {
    pub field name
    field age

    pub fn new(class, [name:, age:]) ->
        bless(%Person{name: name, age: age}, class)

    pub fn set_age(self, age) -> self.age = age
}

my p = Person->new(name: "Ada", age: 30)

p.name = "Grace"      # erlaubt, weil pub field
p.age = 50            # Fehler von aussen, weil privates field
p.age                 # ebenfalls Fehler - `pub` gilt fuers Lesen genauso
p.nickname = "Addy"   # Fehler: Person deklariert kein field 'nickname'

Metadaten wie #: und @... duerfen direkt vor einem Field stehen:

package Person {
    #: Anzeigename fuer UI
    @data("profile")
    pub field name
}

Funktionale Updates mit Antra::Class#

Fuer das bless-MVP gibt es das kleine Stdlib-Paket Antra::Class.

import Antra::Class as Class

Aktuell sind vor allem diese drei Helfer wichtig:

  • Class.copy(class, value, opts = [])
  • Class.fields(class)
  • Class.class_of(value)
  • Class.class_name(value)

Class.copy(...)#

Class.copy(...) baut ein flaches funktionales Update und bless't das Ergebnis wieder an dasselbe Package.

fn _copy(self, opts = []) ->
    Class.copy(Person, self, opts)

pub fn birthday(self) ->
    self->_copy(age: self.age + 1)

Das ist vor allem dann praktisch, wenn du nicht jedes Mal selbst schreiben willst:

bless(%{self | age: self.age + 1}, Person)

Nebenbei: %{self | …} behaelt den Tag von self, das Ergebnis ist also schon ein %Person{…}. Das erneute bless(...) setzt nur die Methoden-Bindung — und laesst dabei den Feld-Check laufen. Ein neuer Schluessel ist schon im Update selbst ein Fehler, wenn das Package ihn nicht als field deklariert (und nicht offen ist): %{self | z: 2} meldet package 'Person' has no field 'z', zur Laufzeit bei einem bless-ten Wert und in --check, wenn self als Package bekannt ist (self :- Self). Ein Spread (%{..self, age: …}) wuerde dagegen ein neues, ungetaggtes Literal bauen; den Unterschied beschreibt 10-mutationen-und-updates.md.

Class.fields(...)#

Damit lassen sich deklarierte Felder reflektieren:

dd(Class.fields(Person))

Das Ergebnis ist aktuell eine Liste kleiner Descriptor-Maps wie:

  • name
  • public

Das ist bewusst klein gehalten und eher fuer Tools, UI oder leichte Reflection als fuer ein schweres Runtime-Typensystem gedacht.

Class.class_of(...)#

Damit kannst du das gebundene Package eines Werts lesen:

println(Class.class_of(p).__antra_package_name__)

Wenn kein Package gebunden ist, kommt undef zurueck.

Class.class_name(...)#

Wenn nur der Name gebraucht wird, ist class_of der falsche Weg — es liefert das ganze Package-Objekt samt Feldern und Methoden. class_name gibt direkt den Namen als String:

println(Class.class_name(p))          # Person
println(Class.class_name(%Person{}))  # undef - kein Package gebunden

Bei einem verschachtelten Package ist der Unterschied zum Tag sichtbar: der Tag eines Werts traegt nur den letzten Namensteil, die Bindung den vollen Pfad.

import Antra::Value as Value

val u = bless(%User{ name: "a" }, Model::User)
println(Value.tag(u))            # User
println(Class.class_name(u))     # Model::User

Private Hilfsmethoden#

Private Package-Funktionen duerfen auch ueber self->... intern benutzt werden, solange der Aufruf im selben Package-Kontext passiert.

package Person {
    fn _copy(self, opts = []) ->
        Class.copy(Person, self, opts)

    pub fn birthday(self) ->
        self->_copy(age: self.age + 1)
}

Von ausserhalb bleibt so eine private Methode weiter verborgen.

Ein komplettes Beispiel#

Im Repository gibt es dazu auch ein lauffaehiges Beispiel:

  • examples/bless_person.ant

Die Kerngedanken daraus:

import Antra::Class as Class

package Person {
    pub field name
    pub field age

    pub fn new(class, [name:, age:]) ->
        bless(%Person{name: name, age: age}, class)

    pub fn greet(self, postfix = "") -> "Hi " <> self.name <> postfix

    fn _copy(self, opts = []) ->
        Person |> Class.copy(self, opts)

    pub fn birthday(self) ->
        self->_copy(age: self.age + 1)
}

Das zeigt gleichzeitig:

  • Konstruktion
  • Methoden-Dispatch
  • Feldzugriff
  • Feld-Sichtbarkeit fuer direkte Assignments
  • private Helper
  • funktionales Update mit Class.copy(...)

Protokolle: package X with P#

Eine Einführung in Protokolle selbst (protocol, impl, Ziele, Defaults und alle Dunder-Methoden) steht in Kapitel 15; hier geht es um ihr Zusammenspiel mit Packages.

Verhalten, das nicht nur dein Package kennt, sondern eine ganze Familie von Typen teilt, kommt aus einem protocol. Ein Package kann die Protokolle, die es erfuellt, direkt im Header nennen:

protocol Currency {
    fn __add__(self, other)
    fn __lt__(self, other)
}

package Wallet with Currency {
    pub field amount

    pub fn new(n) -> bless(%{amount: n}, Wallet)

    fn __add__(self, o) -> Wallet.new(self.amount + o.amount)
    fn __lt__(self, o)  -> self.amount < o.amount
}

val a = Wallet.new(3)
val b = Wallet.new(4)

println((a + b).amount)   # 7
println(a < b)            # true

Die Methoden stehen einfach im Package-Body — kein separater impl-Block. Was du dabei wissen solltest:

  • new blesst weiter. Ein Package-Objekt entsteht wie bisher ueber bless(...) — nur so greifen der Feld-Check und obj->method(...). Fuer den Protokoll-Dispatch aendert das nichts: bless setzt den Tag, das Ergebnis traegt also denselben %Wallet{}.

  • Das Protokoll gilt trotzdem fuer die Datenform, nicht fuer das bless. Auch ein blankes %Wallet{amount: 9} bekommt + und <. Wer das nicht will, laesst die Konstruktion ueber new laufen und dokumentiert es so.

  • Fehlende Methoden fallen sofort auf. Liefert der Body eine abstrakte Protokoll-Methode nicht (und hat das Protokoll auch keinen Default dafuer), meldet Antra das schon bei der Package-Definition:

    Error: package 'Wallet' does not satisfy protocol 'Currency': missing method '__lt__'

    Du erfaehrst es also beim Laden, nicht erst, wenn jemand a < b schreibt; ein handgeschriebenes impl wird genauso geprueft.

  • Mehrere Protokolle trennst du mit Komma: package Wallet with Show, Currency.

  • Alles andere im Body bleibt normal. new und Hilfsfunktionen sind gewoehnliche Package-Funktionen; nur die Namen, die zum Protokoll gehoeren, werden als Protokoll-Methoden registriert.

  • Verschachtelte Package-Namen koennen kein with. package Model::User hat keine Tag-Form — dieselbe Grenze wie bei bless auf eine ungetaggte Map.

Welche Operatoren ein Protokoll uebernehmen kann#

Traegt der linke Operand einen Tag (%T{..}, %T[..] oder %T<..>), schaut der Operator nach einem Impl fuer diesen Tag:

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 Deep-Equal
a < b, a <= b__lt__ bzw. __le__, sonst aus __cmp__
a > b, a >= b, a <=> b__cmp__

__cmp__(a, b) liefert :lt, :eq oder :gt, wie <=>. Eine Methode reicht damit fuer alle Vergleiche, und Array.sort, Array.min und Array.max ordnen solche Werte ebenfalls ueber sie:

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 das Impl, ist das ein klarer Fehler (No implementation of __cmp__ for tag 'Version'), nur ==/!= fallen auf den Vergleich der Inhalte zurueck. Ein getaggter Skalar ohne __neg__ (-30sec) negiert weiter seine Zahl.

Fertige Protokolle fuer jede dieser Methoden (Add, Sub, Mul, Div, Mod, Neg, Eq, Ord, Index, Contains, Hash) liefert Antra::Ops; mit package Money with Add, Eq, Ord { … } nimmt ein Typ genau die, die er kann. Protokoll- und impl-Methoden dürfen :--Annotationen tragen; ein impl erbt die der Deklaration (16-typannotationen.md).

Zugriff: x[k], in und len#

Drei weitere Methoden machen einen getaggten Wert (%T{..}, %T[..] oder %T<..>) zu einer eigenen Sammlung:

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[] fuer die Laenge der Daten

__count__ ist dieselbe Methode wie im Protokoll Enumerable, deshalb liefert sie ein Option. Ohne Impl bleibt alles beim Alten: x[k] und len(x) lesen die Daten, v in x ist fuer getaggte Arrays und Maps ein Fehler. Bereiche und Slices (x[1..2], x[0:2]) lesen immer die Daten.

Innerhalb der Methode greift derselbe Zugriff auf denselben Wert direkt auf die Daten zu, so laesst sich etwa ein Array mit Index ab 1 bauen:

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])        # 10
println(20 in v)     # true
println(len(v))      # 3

Ein anderes Ergebnis als %Some[n] oder %None[] aus __count__ ist ein Fehler.

Gleichheit in Sets und Aufrufbarkeit: __hash__ und __call__#

__hash__(x) bestimmt, wann zwei getaggte Werte in einem Set dasselbe Element sind: Sets vergleichen dann das, was die Methode liefert, statt des ganzen Werts. Das gilt für Set-Literale, v in set, Set.add/Set.member? und alles, was Sets baut. __call__(x, …) macht einen getaggten Wert aufrufbar: x(…), und überall, wo eine Funktion erwartet wird (map(xs, x), ein Callback-Parameter).

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))                  # 1
println(%User{id: 1} in users)       # true

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

__hash__ wirkt nur auf das Element selbst, nicht auf getaggte Werte tief in einem Array-Element; Map-Schlüssel bleiben Text. Ohne __call__ ist ein getaggter Wert weiter nicht aufrufbar, und in der Methode ist self(…) es ebenfalls nicht.

Was das bewusst noch nicht ist#

Die aktuelle bless-/Class-Stufe ist absichtlich klein.

Noch nicht Teil dieses MVP sind zum Beispiel:

  • automatische Getter/Setter aus field
  • implizite Vererbung oder MRO
  • ein eigener Klassen-Runtime-Typ
  • magische Hidden-Mutation
  • automatische Methoden auf allen Maps

Das ist Absicht. Die erste Version soll datennah, explizit und gut lesbar bleiben.

Wann du das benutzen solltest#

Die bless-Stufe ist sinnvoll, wenn du zugleich willst:

  • einen klaren Datenwert
  • benannte Felder
  • ein kleines Package mit Verhalten
  • explizite, funktionale Update-Pfade

Wenn du dagegen nur freie Funktionen ueber Daten schreibst und kein -> vermisst, brauchst du bless(...) oft gar nicht.

Quelle dieser Seite