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 mitclassals erstem Argument.obj->method(...)ruft eine Package-Funktion mitselfals erstem Argument auf.obj.fieldbleibt normaler Datenzugriff.fieldundpub fieldregistrieren Metadaten ueber bekannte Slots.- deklarierte Felder koennen auch steuern, ob
obj.prop = ...von aussen erlaubt ist. Antra::Classliefert 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.nameliest 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.fieldliest Daten direkt aus der Map oder Tagged Mapobj->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 fieldmarkiert sie als oeffentlich sichtbar.field/pub fielderzeugen 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) # EURDer 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 ausdruecklichescode: undefbleibtundef— 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 Aufrufernew 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 fehltOhne .. 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üssel | deklarierte Felder geprüft | |
|---|---|---|
package Body { field … } | Fehler | ja |
package Body { field …; .. } | erlaubt | ja |
@dynamic package Body | erlaubt | nein |
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 := 7erzeugt den Slot,obj.dynliest 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.ageundobj.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.ageundself.age = ...selbstverstaendlich erlaubt. - Auch
obj?.ageumgeht das nicht:?.faengt fehlende Werte ab, nicht fehlende Rechte. - Wenn das Package Felder deklariert und
propkeines davon ist, istobj.prop = ...ein Fehler (package 'W' has no field 'prop') — derselbe wie beibless. Ein Package ohne jedefield-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 ClassAktuell 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:
namepublic
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 gebundenBei 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::UserPrivate 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) # trueDie Methoden stehen einfach im Package-Body — kein separater impl-Block. Was
du dabei wissen solltest:
newblesst weiter. Ein Package-Objekt entsteht wie bisher ueberbless(...)— nur so greifen der Feld-Check undobj->method(...). Fuer den Protokoll-Dispatch aendert das nichts:blesssetzt 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 uebernewlaufen 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 < bschreibt; ein handgeschriebenesimplwird genauso geprueft.Mehrere Protokolle trennst du mit Komma:
package Wallet with Show, Currency.Alles andere im Body bleibt normal.
newund 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::Userhat keine Tag-Form — dieselbe Grenze wie beiblessauf 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:
| Operator | Methode |
|---|---|
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:
| Ausdruck | Methode | Ergebnis |
|---|---|---|
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)) # 3Ein 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.
