Antra

Sprache

Strings, Arrays, Maps und andere Sammlungen

Wer aus Python oder JavaScript kommt, findet in Antra viele vertraute Formen wieder. Der Unterschied liegt eher in der Kombination mit Pattern Matching und Tagged Values.

Strings#

Strings und Interpolation#

Strings unterstützen Interpolation, Indexing und Standardoperationen wie len, split, upper oder lower.

my name = "Felix"
println("Hallo $name")
println(len(name))
println(name[0])
# => "Hallo Felix"
# => 5
# => "F"
my s1 = "Hello, ${name}!"
my s2 = 'kein ${escape} hier'

my a = "Hi $name"
my b = "Alter: $user.age"
my c = "Erstes: $items[0]"
my d = "Preis: \$5"

Doppelte Strings unterstützen ${expr} und die Kurzform $name, $obj.field, $arr[0]. Einfache Strings sind raw.

$obj.field liest das Feld nur, wenn obj eine Map, getaggte Map oder ein Paket ist. Bei jedem anderen Wert bleibt der Punkt Text: mit n = 3 ist "$n.vox" der Dateiname 3.vox. Wer ausdrücklich zugreifen will, schreibt ${obj.field}.

String-Indexing und Ausschnitte#

Einzelne Zeichen liest du per Index, auch negativ vom Ende her:

my s = "Hallo"
s[0]        # => "H"
s[-1]       # => "o"
s[10]       # => undef
"xyz"[1]    # => "y"

Das Ergebnis ist entweder ein String der Länge 1 oder undef bei Out-of-Bounds.

Auch Ausschnitte funktionieren direkt auf Strings — mit denselben zwei Formen wie bei Arrays. Der Unterschied liegt im Ergebnistyp:

my s = "Hallo Welt"

s[0..4]     # => ["H", "a", "l", "l", "o"]   — Range: Zeichen-Array, inklusiv
s[0:5]      # => "Hallo"                     — Slice: String, end exklusiv
s[6:]       # => "Welt"
s[:5]       # => "Hallo"
s[-4:]      # => "Welt"

Willst du also ein Teilstueck als String weiterverwenden, nimm den Slice s[a:b]; willst du zeichenweise arbeiten (mappen, filtern), liefert der Range-Subscript s[a..b] direkt das Zeichen-Array. Die vollstaendigen Slice-Regeln (offene Grenzen, negative Indizes, Klemmen bei Out-of-Bounds) stehen unter Slices und Range-Subscripts.

Arrays und Ranges#

Arrays#

Arrays sind geordnete Listen:

my xs = [1, 2, 3]
println(xs[0])
println(xs[1..2])
# => 1
# => [2, 3]

Trailing Comma: Hinter dem letzten Element einer kommagetrennten Liste darf ein Komma stehen - in Literalen ([1, 2, 3,], %{a: 1,}, %<1, 2,>, [a: 1,]) ebenso wie in Funktionsaufrufen (f(1,)), Parameterlisten (fn f(x,), |x,|), Patterns (fn f([x,]), %{a: x,}, [a: = 1,]), Destructuring (val [a, b,] = ...) und Subscripts (arr[0,]). Praktisch fuer mehrzeilige Listen und Diffs: eine Zeile kommt dazu, ohne die vorherige anzufassen. Ein leeres Element bleibt ueberall ein Fehler - [1,, 2], f(1,,2), [,1].

Typische Built-ins oder Stdlib-Helfer arbeiten direkt auf Arrays:

map(xs, |x| -> x * 2)
filter(xs, |x| -> x > 1)
# => [2, 4, 6]
# => [2, 3]

Ein paar gaengige Helfer aus Builtins und Stdlib:

head(xs)
head!(xs)     # wirft bei leerem Array statt undef zu liefern
tail(xs)
tail!(xs)
concat([1], [2, 3])
len(xs)

my r = [1..5]      # Range-Literal materialisiert zum Array

Mehr dazu im Stdlib-Kapitel 13-module-und-builtins.md.

Slices und Range-Subscripts#

Bei Array- und String-Subscripts gibt es jetzt bewusst zwei verschiedene Formen fuer Ausschnitte:

  • value[a..b] bleibt ein inklusiver Range-Subscript
  • value[start:end] ist ein halboffener Slice mit exklusivem end

Fuer Arrays sehen die beiden Formen so aus:

my xs = [10, 20, 30, 40, 50]

println(xs[1..3])
println(xs[1:4])
println(xs[:3])
println(xs[3:])
println(xs[-3:-1])
# => [20, 30, 40]
# => [20, 30, 40]
# => [10, 20, 30]
# => [40, 50]
# => [30, 40]

Wichtig ist die Semantik:

  • start ist inklusiv
  • end ist exklusiv
  • offene Grenzen defaulten zu Anfang bzw. Ende der Sequenz
  • negative Indizes zaehlen vom Ende
  • out-of-bounds wird geklemmt statt als Fehler behandelt
  • wenn end <= start, ist das Ergebnis leer

Bei Strings bleibt der Unterschied besonders sichtbar:

my text = "Hallo"

println(text[1..3])
println(text[1:4])
# => ["a", "l", "l"]
# => "all"

text[1..3] liefert weiter ein Zeichen-Array, text[1:4] dagegen wieder einen String.

Warum inklusiver Range, aber exklusiver Slice? Die beiden Formen bedienen zwei unterschiedliche Denkweisen:

  • Ein Range a..b beschreibt eine Menge von Werten, die man wirklich meint — man nennt beide Endpunkte, die dazugehören. 1..10 heißt „1 bis einschließlich 10", 'a'..'z' heißt „a bis z". Inklusiv ist hier die intuitive Lesart (wie in Mathe-Notation und Sprachen wie Ruby/Kotlin/Swift), und derselbe ..-Range funktioniert auch außerhalb von Subscripts (for i <- 1..10, x in 1..10).
  • Ein Slice start:end beschreibt einen Ausschnitt zwischen Grenzen. Exklusives end ist die Konvention der meisten Sprachen (Python, Rust, Go, JavaScript) und hat handfeste Vorteile:
    • end - start ist direkt die Länge des Ausschnitts (xs[1:4] → 3 Elemente).
    • Angrenzende Slices kacheln lückenlos und überlappungsfrei: xs[0:k] und xs[k:] ergeben zusammen die ganze Sequenz — der Trennpunkt k kommt genau einmal vor.
    • xs[i:i] ist der leere Ausschnitt, und end darf len(xs) sein (eins hinter dem letzten Index) ganz ohne Off-by-one-Rechnerei.

Kurz: .. schreibt man, wenn man an die Endwerte denkt, : wenn man an Grenzen/Längen denkt.

Subscript ist einheitlich — auch fuer Grossbuchstaben-Namen#

name[...] ist immer ein Subscript, unabhaengig von der Gross-/ Kleinschreibung des Namens. Ein Tagged Array wird mit dem %-Praefix geschrieben (%Tag[...], siehe Tagged Arrays), sodass es keine Kollision zwischen Indexzugriff und Konstruktion gibt:

const GRID = [[1, 2], [3, 4]]

println(GRID[0])      # [1, 2]        Index einer Konstante — keine Sonderregel
println(GRID[1..1])   # [[3, 4]]      Slice/Range
println(%Ok[1, 2])    # %Ok[1, 2]     Tagged Array (Konstruktion mit %)

Es gibt zusaetzlich die aequivalente Punkt-Form x.[i] sowie die nil-sichere Variante x?.[i] (undef statt Fehler bei undef-Empfaenger); beide unterstuetzen Index, Slice, Range und Multi-Index wie x[i]:

maybe_grid?.[1..2]   # Slice, oder undef falls maybe_grid == undef

Zeichen-Ranges und Membership#

Neben numerischen Ranges gibt es auch Zeichen-Ranges auf Basis von Einzelzeichen-Strings:

'a'..'d'
'a'..='e'
'a'..<'d'

Die nackte ..-Form ist dabei die kurze inklusive Variante. Das ergibt normale Folgen von Ein-Zeichen-Strings:

println('a'..'c')       # ["a", "b", "c"]
println('a'..='c')      # ["a", "b", "c"]
println('a'..<'d')      # ["a", "b", "c"]

'x'..='z' |> take(2) |> &println
# => ["x", "y"]

Ein Zeichen-Range entsteht, sobald beide Endpunkte Strings der Länge 1 sind — egal ob als 'a', direktes Unicode-Zeichen 'α' oder Codepoint "\u{3b1}". Ein längerer String als Endpunkt ('ab'..'z') ist ein Laufzeitfehler.

Ranges kannst du jetzt auch direkt mit in als Membership-Test verwenden:

println(3 in 1..10)               # => true
println(23 in 1..10)              # => false
println(4 in range(0, 10, 2))     # => true
println('b' in 'a'..'d')          # => true
println('z' in 'a'..'d')          # => false

Dabei gilt:

  • bei numerischen Ranges prueft in Start, Ende und Step
  • bei Zeichen-Ranges prueft in auf einzelne Zeichen im Bereich
  • bei offenen Ranges wie 5..* ist jeder passende Wert ab dem Start enthalten

a..b zaehlt immer aufwaerts. Ist b kleiner als a, ist die Range leer (3..1 ergibt []) — so laeuft for i <- 0..(n - 1) bei n = 0 gar nicht statt rueckwaerts. Abwaerts zaehlt range mit negativem Schritt; das Ende ist dann die Untergrenze, und in, for und collect folgen derselben Richtung. Ein Schritt 0 ist ein Fehler:

println(collect(3..1))               # => []
println(collect(range(10, 0, -3)))   # => [10, 7, 4, 1]
println(4 in range(10, 0, -3))       # => true
println(5 in range(10, 0, -3))       # => false

Keyword Arrays#

Keyword Arrays im Ueberblick#

Keyword Arrays sind Arrays aus Key-Value-Paaren mit Atom-/Keyword-Keys. Sie wirken auf den ersten Blick ungewöhnlich, sind aber in Antra an mehreren Stellen praktisch:

  • als kompakte Optionslisten
  • als bare keyword args bei Aufrufen
  • als Datenform, die sich leicht pattern-matchen lässt
[host: "localhost", port: 8080]
# ist dasselbe wie [[:host, "localhost"], [:port, 8080]]
println([host: "localhost", port: 8080])
# => [host: 'localhost', port: 8080]

Angezeigt wird ein Keyword-Array so, wie man es schreibt (wie inspect in Elixir): jedes Array, das nur aus Paaren [:name, wert] besteht, erscheint als [name: wert, ...] — auch ein von Hand gebautes [[:a, 1]] oder ein Map.entries(...)-Ergebnis, denn zur Laufzeit ist das derselbe Wert. Keys, die man nicht als name: schreiben kann (:x-model), behalten die Paarform; dd(...) zeigt immer die Paare.

Durchlaufen (for, map, Enum.*) liefert die Paare, nicht wie bei einer Map die Werte — die Laufzeit kann ein Keyword-Array nicht von einer Liste von Paaren unterscheiden. Schluessel und Wert per Destructuring:

Enum.map([a: 1, b: 2], |[k, v]| -> "${k}=${v}")   # [':a=1', ':b=2']
for [k, v] <- [a: 1] { println(k, " ", v) }

Merksatz fuer Enduser: Ein Keyword-Array ist einfach eine Liste von Optionen. Du kannst die Paare entweder als host: "localhost" schreiben oder bewusst mit sichtbarem Atom-Key als :host => "localhost".

Ein kleines Beispiel mit typischen Render-Optionen:

my opts = [:pretty => true, :indent => 4]

println(opts)
# => [pretty: true, indent: 4]

Das ist besonders dann hilfreich, wenn du Optionen als Daten weiterreichen, speichern oder pattern-matchen willst.

Die Kurzform darf jetzt auch direkt mit ..spread gemischt werden:

[..opts, method: "POST"]
[method: "GET", ..opts, timeout: 30]
# => [..opts, [:method, "POST"]]
# => [[:method, "GET"], ..opts, [:timeout, 30]]

Die allgemeine Schreibweise key => value desugart jetzt ueberall ausserhalb echter Map-Literale %{...} zu [key, value].

:host => "localhost"
"port" => 8080
# => [:host, "localhost"]
# => ["port", 8080]

In Funktions- und Lambda-Parametern sind Keyword Arrays als Pattern mit Defaults nuetzlich — inklusive der Kurzformen, bei denen du beim Aufruf die eckigen Klammern oder sogar das ganze leere Keyword Array weglassen kannst:

fn connect([host: = "localhost", port: = 80] as opts) ->
  host <> ":" <> str(port)

connect()                      # => "localhost:80"
connect(host: "db", port: 5432)

Die Regeln dazu (alle Keys mit Default; als letzter Parameter, vor einem Callback oder vor ..rest) stehen ausfuehrlich in Kapitel 08, die Pattern-Seite in Kapitel 07.

Keywords duerfen neben Buchstaben und Ziffern auch *, +, -, _, /, ?, !, ., $, %, =, <, > und & enthalten.

:*
:ready?
:user/id
:a+b
:<=

::keyword hat in Antra aktuell keine Sonderbedeutung.

Wichtige Regeln fuer Keyword Arrays:

  • Keys sind immer Keywords/Atoms
  • doppelte Keys sind erlaubt; bei key-basiertem Lesen gewinnt typischerweise der letzte Wert
  • Keyword Arrays bleiben regulaere Arrays aus Paaren:
my kw = [port: 80, port: 443]
kw[0]       # => [:port, 80]
kw[0][0]    # => :port
kw[0][1]    # => 80

Bare Keyword Args#

fetch("https://example.com", q: "search", limit: 10)
fetch("https://example.com", query:, limit:)
fetch(q: "search", limit: 10, 3)
fetch(q: "search", ..[10, 20])

Das ist nur syntaktischer Zucker, intern bleibt es ein Keyword Array.

Wichtig dabei: Bare Keyword-Args muessen nicht am Ende der Argumentliste stehen. Aufeinanderfolgende key: value-Argumente werden an ihrer Position zu einem Keyword Array gebuendelt; das erste Nicht-Keyword-Argument beendet es. Die dritte Zeile oben uebergibt also zwei Argumente — [q: "search", limit: 10] und 3 —, und in der vierten Zeile folgen nach dem Keyword Array [q: "search"] die gespreizten Positionsargumente 10 und 20.

Das geht sogar mitten in der Argumentliste:

fn mid(a, opts, b) -> ...

mid(1, x: 5, 2)      # a = 1, opts = [x: 5], b = 2

Damit kann man im Prinzip das abbilden, was andere Sprachen als Named Parameters eingebaut haben — nur ohne Sondermechanik: Ein Aufruf wie mid(1, x: 5, 2) liest sich wie benannte Argumente, ist aber ein ganz normaler Wert (ein Keyword Array) an einer ganz normalen Parameterposition, den die Funktion per Pattern matchen kann. Viele Sprachen koennen das nicht: Dort sind benannte Argumente fest an die Signatur gebunden, oder Options landen zwingend als letzter Parameter (Ruby/Elixir: trailing Options-Hash bzw. Keyword-Liste). Das trailing Keyword Array ist auch in Antra der haeufigste Stil — aber eben nur Konvention, nicht Zwang. Als einziges hat es Extra-Komfort: Wenn alle Keys Defaults haben, darf es beim Aufruf komplett fehlen (siehe Kapitel 08, Abschnitt Keyword-Array-Parameter).

Die explizite Form :key => value in einer Argumentliste ist dagegen jetzt ein normales Pair-Argument und nicht mehr bare-keyword-args-Sugar.

Die implizite Kurzform ohne Wert funktioniert aber nur fuer bindbare Identifier. query: ist also Kurzform fuer query: query und ready?: fuer ready?: ready?, waehrend Keys wie x-model: oder user/id: ohne expliziten Wert nicht implizit gebunden werden.

Maps#

Maps und Zugriff#

Maps sind Schlüssel-Wert-Strukturen:

my user = %{name: "Ada", age: 36}
println(user.name)
println(user[:name])
# => "Ada"
# => "Ada"

name: "Ada" legt einen Keyword-Key :name an (gleichwertig: :name => "Ada"). String-Keys schreibt man mit =>: %{"name" => "Ada"}; %{"name": "Ada"} ist ein Parse-Fehler, weil der Doppelpunkt sonst immer einen Keyword-Key bedeutet. Beide sind verschiedene Keys, wie in Elixir: user["name"] findet :name nicht, user.name und user[:name] finden "name" nicht, und eine Map kann beide nebeneinander enthalten. Dasselbe gilt fuer in, Map.get, Zuweisungen und Map-Patterns (%{name: n} matcht nur :name, %{"name" => n} nur "name").

my m = %{name: "Ada", "name" => "Bea"}
println([m.name, m[:name], m["name"]])
println(["name" in m, :name in m])
# => ['Ada', 'Ada', 'Bea']
# => [true, true]

Daten von aussen (JSON, Formulare, Umgebung) kommen oft mit String-Keys an. JSON.parse liefert Keys, die wie Bezeichner aussehen, als Keywords; fuer dynamische Namen erzeugt keyword("name") den passenden Keyword-Key.

Fuer explizite oder dynamische Keys kannst du innerhalb von %{...} auch => verwenden:

my field = "display-name"
my user = %{ :role => "admin", field => "Ada" }
println(user[field])
# => "Ada"

Wichtig ist die Unterscheidung zwischen statischem und dynamischem Zugriff:

user.name
user[field]
# => "Ada"
# => "Ada"

Die stilistische Empfehlung ist jetzt bewusst einfach und JS-artig:

  • obj.name fuer statische Property-Namen
  • obj[key] fuer dynamische Keys oder numerische Indizes
  • obj?.name fuer sicheren statischen Property-Zugriff
  • obj?.[key] fuer sicheren Klammer-Zugriff
  • arr[i] und arr?[i] bleiben die normale Array-Form

Mit Klammern kannst du also sowohl Map-Keys als auch Array-Indizes lesen, ohne vorher die Zugriffsart wechseln zu muessen. Das ist besonders angenehm bei gemischten Datenstrukturen, in denen in einer Kette Maps und Arrays vorkommen koennen.

Beispiele:

my user = %{ profile: %{ city: "Berlin" } }
my key = "profile"

println(user.name)
println(user[key])
println(user?.[key]?.city)

my items = [10, 20, 30]
println(items[1])
println(items?[10] // "fehlt")
# => undef
# => %{ city: "Berlin" }
# => "Berlin"
# => 20
# => "fehlt"

Noch zwei kurze Beispiele fuer den sicheren Array-Zugriff:

my values = ["a", "b", "c"]
my missing = undef

println(values?[1])
println(values?[10] // "n/a")
println(missing?[0])
# => "b"
# => "n/a"
# => undef

Wenn ein Feld fehlt, liefert normaler Property-Zugriff in vielen Faellen undef statt sofort zu crashen. Fuer nil-sichere Ketten gibt es zusaetzlich ?., ?.[...] und ?[...].

Fehlende Felder kombinieren sich gut mit dem Default-Operator //:

user.missing          # => undef
user.missing // "?"   # => "?"

Deep-Set#

Mit := kannst du tief verschachtelte Strukturen aktualisieren:

my config = %{}
config.db.host := "localhost"
config.db.port := 5432
# => %{db: %{host: "localhost", port: 5432}}

Dabei gilt aktuell:

  • die Root-Bindung muss existieren
  • die Root-Bindung muss mutierbar sein
  • fehlende Zwischenstufen werden erzeugt
  • fehlende Keys und fehlende Index-Pfade werden ebenfalls angelegt

Das ist praktisch, wenn du verschachtelte Maps oder Array/Map-Mischformen aufbauen willst.

Wichtig ist die Erzeugungsregel: := schaut nur auf den naechsten Schritt im Pfad.

  • folgt als naechster Schritt ein Property- oder Key-Zugriff, wird eine Map erzeugt
  • folgt als naechster Schritt ein Index-Zugriff, wird ein Array erzeugt

Dadurch funktionieren auch gemischte Faelle ohne Spezialsyntax:

my data = %{}

data.users[0].name := "Ada"
data.users[0].tags[0] := :admin
data.settings[:theme] := "light"
# => %{users: [%{name: "Ada", tags: [:admin]}], settings: %{theme: "light"}}

Nach diesen Schreibzugriffen wurden die fehlenden Knoten unterwegs automatisch aufgebaut: users als Array, users[0] als Map, tags als Array und settings als Map.

Wichtig ist die Trennung der Operatoren:

  • = ist der strikte Write-Operator: der Zielpfad muss schon existieren
  • := ist der erzeugende Write-Operator: fehlende Zwischenknoten duerfen angelegt werden
  • beide Formen brauchen weiterhin eine vorhandene und mutierbare Root-Bindung
my config = %{}

config.db.port = 5432    # Fehler, weil db/port noch nicht da sind
config.db.port := 5432   # ok, legt den Pfad an
config.db.port = 6000    # ok, der Pfad existiert jetzt

Map-Update ohne Mutation#

Neben in-place Mutation gibt es auch funktionale Update-Formen:

my base = %{name: "Ada", age: 36}
my updated = %{base | age: 37}

Das erzeugt eine neue Map. base bleibt unverändert. Links vom | darf jeder Ausdruck stehen, der eine Map liefert — %{state.player | x: 0} oder %{load(id) | age: 37} brauchen keinen Zwischen-Namen.

Für Leute aus FP-Sprachen ist das ein vertrautes Muster. Für Leute aus JS kann man es sich wie ein fokussierteres Objekt-Update vorstellen.

Das Update ersetzt auf oberster Ebene: Steht rechts vom | ein Key mit einem verschachtelten Wert, wird der alte Unterbaum komplett ersetzt, nicht gemerged. Fuer tiefes Zusammenfuehren gibt es den Spread-Abschnitt unten.

Spread und Deep Merge#

Spread und Deep Merge:

my ext    = %{ ..base, z: 3 }
my merged = %{ ..base, ..ext }
my deep   = %{ ...nested1, ...nested2 }

Dabei ist der Unterschied bewusst klein, aber wichtig:

  • ..map ist ein flacher Spread beziehungsweise Merge auf der obersten Ebene
  • ...map ist ein tiefer Merge fuer verschachtelte Map-Strukturen

Praktisch heisst das: Bei .. wird ein verschachtelter Unterbaum komplett durch den spaeteren Wert ersetzt, waehrend ... versucht, verschachtelte Maps rekursiv zusammenzufuehren.

Spaetere Einträge gewinnen, egal ob sie aus Spreads oder expliziten Keys kommen.

Eine getaggte Map darf ebenfalls Quelle sein; gespreadet werden ihre Daten. Den Tag bestimmt dabei die aeussere Literal-Form:

my a = %Point{ x: 1 }

%Point{ ..a, y: 2 }   # %Point{ :x => 1, :y => 2 }
%{ ..a, y: 2 }        # %{ :x => 1, :y => 2 }  - ungetaggt

Genau hier unterscheiden sich die beiden Formen: %{base | …} aktualisiert einen Wert und behaelt darum dessen Tag, auch in einem ungetaggten Literal — %{self | age: 31} in einer Package-Methode bleibt also ein %Person{…}. Ein Spread dagegen baut ein neues Literal, dessen Form entscheidet.

Was nicht geht: | nimmt genau eine Basis (%{a | b} ist ein Parse-Fehler), Spread beliebig viele. Und als Spread-Quelle muss eine Map oder getaggte Map stehen, sonst: Error: spread in a map literal requires a map or tagged map; got Array.

Sets und Tagged Sets#

Sets#

Sets werden mit %<...> geschrieben:

my ids = %<1, 2, 2, 3>

Sie sind gut für Mitgliedschaftsprüfungen mit in.

Zur Einordnung:

  • x in set prueft Set-Membership
  • x in array prueft Equality-Membership
  • key in map prueft Map-Keys
  • x in 1..10 prueft Range-Membership

Tagged Sets#

Tagged Sets geben einem normalen Set zusaetzlich einen fachlichen Tag:

my seen = %Seen<1, 2, 3>
my roles = %Roles<:admin, :editor>

Das ist die Set-Schwesternform zu:

  • Tagged Arrays wie %Ok[42]
  • Tagged Maps wie %User{name: "Ada"}

Praktisch ist das dann, wenn die Werte semantisch klarer werden sollen, ohne die Set-Eigenschaften zu verlieren.

Zum Beispiel koennen zwei Sets denselben Inhalt haben, aber etwas anderes meinen:

my seen = %Seen<1, 2, 3>
my selected = %Selected<1, 2, 3>

Beide sind Sets, aber der Tag traegt die zusaetzliche Bedeutung.

Aktuell gilt fuer die erste Stufe:

  • %Tag<...> dedupliziert genauso wie %<...>
  • Tagged Sets bleiben normale Set-Werte mit zusaetzlichem Tag
  • Gleichheit beruecksichtigt sowohl Elemente als auch den Tag
  • tagged_set?(...) erkennt getaggte Sets
  • linksgerichtete Set-Operationen wie Set.union(...), Set.intersect(...), Set.diff(...) und a \\ b behalten den linken Tag bei
import Antra::Set as Set

my base = %Seen<1, 2>
println(Set.union(base, %<2, 3>)) # => %Seen<1, 2, 3>
println(base \\ %<2>) # => %Seen<1>

Auch select (...) yield ... kann Tagged Sets als Seed-Ziel verwenden:

my out = select (
  x <- [2, 3, 3, 4]
  into: %Seen<1, 2>
) yield x

println(out) # => %Seen<1, 2, 3, 4>

Set-Patterns (exakt, offen, mit Rest und mit Tag) sind in Kapitel 07 beschrieben — kurz:

match ids {
  %<1, 2>         -> "genau 1 und 2"
  %Seen<1, 2>     -> "genau dieses tagged set"
  %<1, 2, ..rest> -> rest
  _               -> "anderes"
}

Tagged Values#

Das %-Praefix ist der Tag-Marker. Tagged-Value-Literale schreibt man als %Tag[...] (Array), %Tag{...} (Map) und %Tag<...> (Set); Tagged Strings als tag"...". Der Tag darf gross oder klein geschrieben sein — %Ok[1, 2], %ok[1, 2] und %point{x: 1} sind alle gueltige Literale.

Das % ist noetig, weil name[...] ohne Praefix schon eine andere Bedeutung hat: Es liest einen bestehenden Wert name und indiziert hinein. Erst das % macht daraus ein neues Tagged-Array-Literal, dessen Tag der Name selbst ist:

val xs = [10, 20, 30]
xs[1]     # => 20        (Subscript: greift auf Element 1 von xs zu)
%xs[1]    # => %xs[1]    (Tagged Array mit Tag "xs" und dem Element 1)

Dieselbe Regel bei Grossschreibung: Config[0] schlaegt Config als Variable oder Paket nach (und indiziert), waehrend %Config[0] das Tagged-Array-Literal mit Tag Config ist. Fuer Maps ({...}) und Sets (<...>) gilt das analog.

%rgb[255, 128, 0]  # Tagged Array mit Tag "rgb"
%point{x: 1}       # Tagged Map
%seen<1, 2>        # Tagged Set
sql"SELECT 1"      # Tagged String (== %sql["SELECT 1"])
10sec              # Tagged Scalar (Zahl + Tag, == %sec[10])

Tags sind case-sensitiv: %ok und %Ok sind verschiedene Tags — genau wie ok und Ok verschiedene Bezeichner sind. Das ist kein Sonderfall der Schreibweise, sondern die normale Namensgleichheit. Ein %ok[1] matcht also nicht ein %Ok[1], und umgekehrt.

Technisch ist %ok[...] voellig gueltig — es gibt keine Einschraenkung. In der Praxis solltest du aber Tags vermeiden, die sich nur in der Gross-/ Kleinschreibung von den eingebauten unterscheiden (%ok vs %Ok, %some vs %Some): Das fuehrt leicht zu Verwirrung — besonders als Rueckgabewert, wo Leser %ok faelschlich fuer das Result-%Ok mit .^/.? halten. Nimm fuer eigene Daten lieber klar unterscheidbare Tags.

Sonderfall Result/Option. Die Tags %Ok/%Err (Result) und %Some/%None (Option) verhalten sich nur deshalb besonders (Operatoren .^ Propagation, .? Soft-Unwrap), weil die Standardbibliothek dafuer Protokoll-Impls mitbringt (impl Propagate for %Ok[..] in lib/Antra/Propagate/*, analog Unwrap). Es ist also keine Magie an der Grossschreibung: jeder andere Tag — ob %User, %Point oder %ok — ist ein schlichter Tagged Value, bis man selbst ein solches Protokoll ergaenzt. Konkret: %Ok[5].^ funktioniert, %ok[5].^ nicht. Deshalb Ok/Err/Some/None als die kanonischen (grossgeschriebenen) Result/Option-Tags nutzen und eigene Daten mit eigenen Tags versehen.

Antra::Value ist fuer DYNAMISCHE Tags. Das Literal nimmt den Tag woertlich (%t[1] ist der Tag "t", nicht der Wert der Variable t). Soll der Tag erst zur Laufzeit aus einem String/einer Variable kommen, nimmt man die Konstruktoren:

import Antra::Value as Value

val kind = "ok"
Value.tagged_array(kind, [1, 2])       # => %ok[1, 2]  (Tag aus der Variable)
Value.tagged_map("point", %{x: 1})     # => %point{ :x => 1 }
Value.tagged_string("sql", "SELECT 1") # => %sql["SELECT 1"]
Value.tagged("mytag", 42)              # => %mytag[42]

Value.tag(v), Value.data(v) und Value.untag(v) lesen einen Tagged Value wieder aus.

Tagged Values sind Antras leichtgewichtige „Typen". Der Tag wirkt wie ein nominaler Typname, und über Protokolle lässt sich Verhalten daran hängen: Ein impl <Protokoll> for %Tag[..] gibt allen Werten mit diesem Tag Operatoren, Konvertierungen oder Sonder-Semantik. Genau darauf bauen die eingebauten Fälle auf — nichts davon ist im Kern hartverdrahtet:

  • %Ok[..]/%Err[..] und %Some[..]/%None[..] bekommen .^/.? über impl Propagate/impl Unwrap (lib/Antra/Propagate/*, lib/Antra/Unwrap/*).
  • Durations wie 10min, 5sec rechnen über impl Duration for %min[..] &co. (lib/Antra/Duration.ant) miteinander.

Dieselben Mittel stehen dir für eigene Tags offen: implementierst du ein Protokoll für %rgb[..], verhalten sich alle %rgb[...]-Werte entsprechend. Ein Tagged Value ohne solche Impls bleibt einfach ein benannter Datencontainer.

Tagged Maps#

Tagged Maps sind Maps mit zusätzlicher Forminformation:

my user = %User{name: "Ada", age: 36}

Das ist besonders nützlich, wenn unterschiedliche Datenformen ähnlich aussehen, aber semantisch etwas anderes bedeuten.

Beispiel:

my billing = %BillingAddress{city: "Berlin"}
my shipping = %ShippingAddress{city: "Berlin"}

Beide haben dieselben Felder, aber der Tag erlaubt später ein präzises Matching.

Tagged Arrays#

Tagged Arrays sind die positionsorientierte Schwester dazu:

my ok = %Ok[42]
my err = %Err["kaputt"]

Das eignet sich gut für Result-Typen, AST-ähnliche Werte oder kleine algebraische Datentypen.

Tagged Scalars#

Tagged Scalars sind die kompakte Schreibweise für einen skalaren Wert mit Tag:

10sec
3.5kg
10_000ms

Praktisch ist das vor allem dann, wenn nackte Zahlen am Call-Site zu unklar wären. 10sec ist lesbarer als ein bloßes 10, weil die Einheit direkt im Wert steckt.

Wenn dir die Zahlensyntax mit Trennern noch neu ist, steht die Grundlage dazu in 04-grundlagen.md im Abschnitt zu Zahlentrennern.

Aktuell gilt:

  • Tagged Scalars werden intern wie Tagged Arrays mit genau einem Element behandelt
  • Pattern Matching funktioniert direkt mit lowercase-Tags, im Pattern mit %-Präfix (%ms[v])
  • der Tag folgt dem Zahlteil ohne Leerzeichen
fn to_ms(%ms[v])  -> v
fn to_ms(%sec[v]) -> v * 1000

println(to_ms(250ms))   # => 250
println(to_ms(3sec))    # => 3000

Zifferntrenner funktionieren auch im Zahlteil von Tagged Scalars:

10_000sec
1_000.25kg

Bewusst nicht unterstützt ist aktuell eine allgemeine Schreibweise wie sec[10], weil name[index] bereits normaler Subscript-Zugriff ist.

Ein vollständiges, lauffähiges Beispiel findest du in ../examples/tagged_scalars.ant.

Tagged Strings#

Dieselbe Idee funktioniert auch für Strings: Der Tag steht direkt vor dem String-Literal, ohne Leerzeichen.

html"<b>ok</b>"
sql"SELECT 1"

Aktuell gilt für die erste Ausbaustufe:

  • Tagged Strings werden intern wie Tagged Arrays mit genau einem Element behandelt
  • %html[s] oder %sql[s] funktionieren direkt im Pattern Matching (Pattern mit %-Präfix)
  • Tagged Strings gibt es in allen drei String-Formen: tag"..." (Interpolation + Escapes), tag'...' (roh — keine Interpolation, keine Escapes) und tag`...` (Template — Interpolation aktiv, aber Backslashes bleiben literal). Für Regexes ist regex`...` bevorzugt, damit \d, \w usw. literal bleiben
fn unwrap_html(%html[s]) -> s
println(unwrap_html(html"<div>ok</div>"))

match sql"SELECT 1" {
  %sql[s] -> println(s)
}

Ein kleines, lauffaehiges Beispiel dazu findest du in ../examples/tagged_strings.ant.

Noch nicht Teil dieser ersten Stufe sind mehrzeilige Fenced-Block-Varianten wie fenced html- oder sql-Blöcke und auch kein spezielles Laufzeitverhalten pro Tag.

Orientierung#

Wann nehme ich was?#

Eine grobe Heuristik:

  • Array: wenn Reihenfolge wichtig ist
  • Map: wenn benannte Felder wichtig sind
  • Keyword Array: wenn du Optionen oder call-nahe Schlüssel-Wert-Paare modellierst
  • Tagged Map: wenn benannte Felder plus Typ/Tag wichtig sind
  • Tagged Array: wenn Form und Position wichtiger sind als Feldnamen
  • Tagged Scalar: wenn ein einzelner skalarer Wert fachlich ein klares Tag tragen soll
  • Tagged String: wenn ein String fachlich einen klaren Kontext oder Kanal tragen soll

Kleines Vergleichsbeispiel#

my person = %{name: "Ada", age: 36}
my user = %User{name: "Ada", age: 36}
my ok = %Ok[person]
  • person ist nur eine normale Map
  • user ist eine Map mit Tag User
  • ok ist ein Tagged Array mit Tag Ok und der person

Quelle dieser Seite