Antra

Sprache

Grundlagen

Dieses Kapitel sammelt die Basissyntax: Literale, Variablen, Operatoren und rationale Zahlen.

Literale und Typen#

Antra kennt die üblichen Grundwerte:

  • Ganzzahlen wie 42 oder -7, beliebig groß und immer exakt
  • Dezimalzahlen auch mit Trennern wie 10_000 oder 1_000.25
  • Hex-, Oktal- und Binärliterale wie 0xFF, 0xDEAD_BEEF, 0o755 oder 0b1010_0101 (Oktal z. B. für Dateirechte: FS.chmod(path, 0o755))
  • Zahlen mit Exponent wie 1e300, 6.02e23 oder 1.5e-3
  • Strings mit doppelten oder einfachen Anführungszeichen (ein einzelnes Zeichen ist einfach ein String der Länge 1, z.B. 'a')
  • true, false und undef
  • Arrays, Maps, Sets und Tagged Values
42
10_000
1_000.25
0xFF
0xDEAD_BEEF
0o755
0b1010
0b1010_0101
6.02e23
1.5e-3
"hello"
'raw string'
`a`
true
false
undef
[1, 2, 3]
%{name: "Ada"}
%<1, 2, 3>
%Ok[42]
%User{name: "Ada"}

Zahlentrenner mit _#

Große Zahlen lassen sich mit _ besser lesbar schreiben:

10_000
1_000_000
1_000.25
0xDEAD_BEEF
0b1010_0101

Wichtig:

  • _ ist nur als Trenner zwischen Ziffern erlaubt
  • 1__000 ist ein Parse-Fehler
  • 1_ ist ein Parse-Fehler

Die Trenner verändern nur die Schreibweise, nicht den Wert.

Große Ganzzahlen#

Ganzzahlen haben keine Obergrenze. Passt ein Ergebnis nicht mehr in 64 Bit, rechnet Antra exakt weiter:

println(9223372036854775807 + 1)   # 9223372036854775808
println(2 ** 100)                  # 1267650600228229401496703205376

Wie Int, Rat und Float zusammenspielen, beschreibt 05-zahlen.md ausführlich.

Zahlen mit Exponent#

e oder E mit einem ganzzahligen Exponenten schreibt sehr große und sehr kleine Zahlen: 1e300, 6.02e23, 1.5e-3, 2E+10. Wie Dezimalzahlen sind sie exakt: Ein ganzer Wert ist eine Ganzzahl (1e3 ist 1000, 1e300 eine 1 mit 300 Nullen), jeder andere ein Bruch (1.5e-3 ist 3/2000, und 1.5e-3 * 2000 ist genau 3). Ein Literal darf höchstens 10 000 Stellen ergeben; 1e20000 ist ein Fehler, schreib dafür 10 ** 20000.

Folgt nach dem Exponenten noch ein Buchstabe, ist es keine Zahl, sondern ein Tagged Scalar: 5em ist %em[5] (CSS-Einheit), ebenso 1e ohne Ziffern.

Wenn du von nackten Zahlen zu getaggten Werten wie 10sec oder 10_000ms wechseln willst, lies in 06-collections.md den Abschnitt zu Tagged Scalars weiter.

Die drei String-Typen#

Antra kennt drei Anführungszeichen-Arten, die sich in genau zwei Fragen unterscheiden: interpoliert der String? und werden Escape-Sequenzen wie \n verarbeitet?

FormInterpolationEscapes (\n, \u{...})
"..." (doppelt)✓✓
'...' (einfach)✗✗ (roh)
`...` (Backtick)✓✗ (roh)

Doppelte Strings#

Doppelte Anführungszeichen erlauben Interpolation und Escape-Sequenzen:

my name = "Ada"
println("Hallo $name")
println("Zeile 1\nZeile 2")

Einfache Strings#

Einfache Anführungszeichen sind raw Strings. Das ist praktisch, wenn du möglichst wenig Sonderbehandlung willst:

'raw string'
println('Hallo $name')
println('Zeile 1\nZeile 2')

In einfachen Strings bleiben $name und \n also wörtlich im Text.

Backtick-Strings (Template)#

Backticks sind die Mischform: Interpolation ja, Escapes nein. $name und ${expr} werden ausgewertet, aber Backslashes bleiben wörtlich im Text — kein \n, kein \u{...}.

my name = "Ada"
println(`Hallo $name`)          # Hallo Ada
println(`Summe: ${1 + 2}`)      # Summe: 3
println(`Pfad C:\neu\tab`)      # C:\neu\tab  (Backslashes bleiben literal)

Die Hauptmotivation sind Regexes: dort willst du Backslashes (\d, \w) und $-Anker unverändert lassen, aber trotzdem Werte einsetzen. Ein $, dem kein Bezeichner oder { folgt, bleibt dabei literal — der Regex-Anker $ überlebt:

my n = 4
my code = regex`\d{$n}$`        # matcht genau 4 Ziffern am Zeilenende

Getaggte Strings gibt es in allen drei Formen: regex"..." (escaped), regex'...' (raw) und regex`...` (template). Der Tag vor dem String bestimmt, wie der Inhalt interpretiert wird — die Quote-Art nur, ob interpoliert und ob Escapes verarbeitet werden.

Einzelne Zeichen#

Antra hat keinen eigenen Zeichen-Typ und keine Backtick-Literale. Ein einzelnes Zeichen ist einfach ein String der Länge 1:

'a'
'Z'
"\u{3b1}"   # α per Unicode-Codepoint (nur in doppelten Strings)

Wichtig:

  • die Hauptanwendung ist die Zeichen-Range, zum Beispiel 'a'..'z'
  • ein Range-Endpunkt muss ein String der Länge 1 sein — 'ab'..'z' ist ein Laufzeitfehler
  • Unicode-Zeichen kannst du direkt schreiben ('α'..'ω') oder in doppelten Strings per Codepoint mit \u{HHHH}

Variablen mit my, val und const#

my count = 1
count = count + 1

val user = %{name: "Ada"}
user.name = "Ada Lovelace"

const answer = 42

Merke dir die Grundregel als 3-Stufen-Modell:

  1. my: Bindung ist rebindbar.
  2. val: Bindung ist nicht rebindbar, aber der Wert bleibt mutierbar.
  3. const: Bindung ist nicht rebindbar und Composite-Werte werden transitiv eingefroren.

Kurzer Vergleich:

DeklarationRebinding (x = ...)In-place Mutation (x.a := ..., x[i] = ...)
myerlaubterlaubt
valverbotenerlaubt
constverbotenverboten

Beispiel fuer val:

val cfg = %{ db: %{ port: 5432 } }

cfg.db.port = 6000   # erlaubt
cfg = %{}            # Fehler

Antra ist lexikalisch gescoped. Ein Name gilt also dort, wo er gebunden wurde, plus in inneren Blöcken. Im gleichen Scope darf ein Name nur einmal deklariert werden. In Kind-Scopes darf derselbe Name erneut gebunden werden; der innere Name shadowt dann den aeusseren nur lokal.

Weil das in der Praxis leicht missverstanden wird, hier die konkretere Faustregel:

  • do { ... } erzeugt einen neuen lokalen Scope.
  • Funktions- und Lambda-Bodies erzeugen einen neuen lokalen Scope.
  • while (...) { ... } erzeugt pro Iteration frische lokale Bindungen.
  • Zusaetzlich sind jetzt zwei lesenahe Kurzformen moeglich: while name <- expr { ... } fuer den engen undef-Stop-Fall und while pattern <- expr { ... } fuer Pattern-getriebene Schleifen wie while %Some[line] <- File.read_line(fh).! { ... }.
  • for x <- seq { ... } ebenfalls, weil for pro Durchlauf frische lokale Bindungen anlegt, egal ob die Schleife intern ueber den direkten for_statement-Pfad oder den foreach(...)-Fallback laeuft.
  • Ein if (...) { ... } else { ... }-Block ist dagegen in erster Linie ein Kontrollfluss-Block, nicht automatisch ein neuer allgemeiner Shadowing-Container.

Das heisst: Ein inneres const x in einem do-Block ist erlaubt, eine zweite Deklaration danach im aeusseren Scope aber nicht.

const x = 10

do {
    const x = 20
    println(x)   # 20
}

println(x)       # 10

const x = 30     # Fehler: wieder derselbe aeussere Scope

Dasselbe gilt auch fuer Imports: import Antra::String as String oder import Antra::Array.* fuehren Namen im aktuellen Scope ein. Eine spaetere Deklaration mit demselben Namen im selben Scope ist deshalb ebenfalls ein Redeclaration-Fehler.

Bei Schleifen ist wichtig, die zwei Ebenen nicht zu vermischen:

  • Innerhalb einer while- oder for-Iteration duerfen lokale my-Bindings des Schleifenbodys pro Durchlauf neu deklariert werden.
  • Namen aus dem aeusseren Scope bleiben dabei sichtbar und koennen, falls sie mit my gebunden sind, per = aktualisiert werden.
my total = 0

while (total < 3) {
    my label = "step-" <> str(total)
    println(label)
    total = total + 1
}

Namen mit ? und !#

Ein Name darf auf ? oder ! enden - Funktionen, my/val/const, Parameter und Lambda-Parameter. Üblich ist ? für Wahrheitswerte und Prüfungen, ! für Funktionen, die bei einem Fehler werfen:

fn even?(n) -> n % 2 == 0

val ready? = len([1, 2]) > 1
println(filter([1, 2, 3, 4], even?))   # [2, 4] - even? ohne Klammern ist der Funktionswert
println(map([true, false], |on?| -> !on?))

x?.name und x?[i] bleiben die sicheren Zugriffe; dort gehört das ? nicht zum Namen. defined? und undef? sind reservierte Operatoren und keine Namen. Das frühere Postfix-? gibt es nicht mehr: steht x?, wo nur x existiert, nennt die Fehlermeldung x.^ (Fehler weiterreichen) und x.? (weich auspacken).

Warum sagt die Doku "Binding" statt "Variable"?#

Weil in funktional geprägten Sprachen oft betont wird, dass ein Name an einen Wert gebunden wird. Für den Alltag kannst du aber ruhig weiter "Variable" denken.

Relevant ist eher der Unterschied:

  • eine Bindung kann unveränderlich sein
  • ein Wert kann trotzdem zusammengesetzt sein
  • bei zusammengesetzten Werten kann man bewusst in-place mutieren, aber nur wenn die Root-Bindung mutierbar ist

Wichtig fuer zusammengesetzte Werte:

  • const user = %{...} bedeutet auch: user.name = ... und user.profile.city := ... sind nicht erlaubt
  • const xs = [...] bedeutet auch: xs[0] := ... und xs[1] := ... sind nicht erlaubt
  • const s = %<...> bedeutet auch: s \\= other und Set.add(s, x) sind nicht erlaubt
  • const friert bei Arrays, Maps und Sets den erreichbaren Wertgraphen transitiv ein: wenn const outer = [inner] gilt, ist danach auch inner[0] = ... nicht mehr erlaubt
  • ein const-Composite darf ausserdem nicht an einen mut-Parameter uebergeben werden
  • ein val-Composite darf dagegen an mut-Parameter uebergeben werden, weil val nur Rebinding sperrt, aber nicht einfriert

Operatoren#

Arithmetik#

1 + 2
10 - 3
4 * 5
20 / 4
2 ** 8
1 <=> 2
2 <=> 2
"file2" <~> "file10"
"ab" <=> "aa"

Kurze Compound-Assignments gibt es ebenfalls:

x += 1
x &= 3
x |= 4
x <<= 1
x >>= 1
remaining \\= blocked
s <>= "!"
out ++= [value]

Bit-Operatoren#

&, |, ^ (XOR, nicht Potenz!), <<, >> und das präfixe ~ (bitweises NOT) rechnen auf vorzeichenbehafteten 64-Bit-Ganzzahlen, wie in C oder Python. Brüche und Floats werden vorher auf ganze Zahlen abgeschnitten; eine ganze Zahl außerhalb von 64 Bit ist ein Fehler:

println(12 & 10)        # 8
println(12 | 3)         # 15
println(12 ^ 10)        # 6
println(1 << 4)         # 16
println(-6 >> 1)        # -3   (arithmetischer Shift)
println(~5)             # -6   (~n ist -n - 1)
println(0b1111 & ~0b10) # 13   (Bit 1 löschen)

~ bindet wie das unäre Minus (~1 + 1 ist (~1) + 1).

String-Konkatenation#

Strings werden mit <> verbunden:

"Hallo" <> " " <> "Welt"

Das ist bewusst nicht +, damit Zahlen- und String-Operationen klar getrennt bleiben.

Gleichheit#

Antra unterscheidet mehrere Arten von Gleichheit:

  • == für tiefen Wertvergleich
  • === für typstrikten tiefen Vergleich
  • =:= für Referenzidentität

Für Einsteiger ist == meistens der richtige Start.

Logische Operatoren &&, ||, !#

&& und || verhalten sich wie in Perl (oder JavaScript/Python): sie kurzschliessen und geben den Operanden selbst zurueck, nicht true/false.

println(0 || "fallback")   # "fallback"  - erster wahrer Operand
println("a" && "b")        # "b"          - letzter, wenn alle wahr
println(0 && teuer())      # 0            - teuer() wird nie ausgewertet

Damit funktioniert der klassische Kurzschluss-Stil direkt:

val name = eingabe || "anonym"      # Default, wenn eingabe falsy
do_something() && println("Erfolg") # println nur bei Erfolg

Eigene Wort-Operatoren and/or/not gibt es bewusst nicht: &&/|| binden bereits lockerer als Vergleiche (a == b && c == d braucht keine Klammern), und die Statement-Kurzschluss-Faelle deckt Antra ohne Perls Praezedenz-Fallen ab.

Was gilt als wahr?#

if, &&, || und ! benutzen dieselbe Wahrheits-Definition. Falsy sind die "leeren" Werte - inklusive leerer Collections (Python-Stil):

falsytruthy
falsetrue
0alle anderen Zahlen (auch negative)
"" (leerer String)jeder nicht-leere String
undefnicht-leere Collection
[], %{}, %<> (leere Collections)jedes Objekt / jede Referenz

Damit liest sich if items { … } als "wenn es Items gibt", und val name = eingabe || "anonym" greift auch bei einer leeren Liste.

Zwei Details fuer Perl-Umsteiger:

  • Der String "0" ist in Antra wahr. In Perl ist er falsch - eine bekannte Stolperfalle ("0" aus einer Eingabe wird ploetzlich falsy). Antra macht es einheitlich: jeder nicht-leere String ist wahr, egal was drinsteht.
  • Leere Collections sind falsy, nicht wie in Perl truthy (dort ist eine Array-Referenz immer wahr). [], %{} und der leere Set %<> verhalten sich wie in Python. Willst du "existiert vs. leer" trennen, pruefe explizit gegen undef (defined? x) - denn ein leeres, aber vorhandenes [] ist falsy.

Tagged-Varianten (%Ok[…], %Err[…]) sind keine Collections und bleiben truthy, auch ohne Nutzlast - %Ok[] ist wahr.

Regex-Match mit =~#

=~ prueft, ob ein String auf ein Pattern passt, und liefert immer true oder false.

In normalen Ausdruecken bleibt das auch so. Zusaetzlich gibt es jetzt fuer den kleinen lokalen Erfolgsfall branch-lokale Capture-Bindings im ausgefuehrten Match-Branch:

if "2026-05-19" =~ regex"(?<year>\\d{4})-(\\d{2})-(\\d{2})" {
    println(year)
    println(_1)
    println(_2)
    println(_3)
}

Dabei gilt:

  • benannte Gruppen wie year sind nur im erfolgreichen then-Branch sichtbar
  • nummerierte Gruppen werden als _1, _2, _3, ... gebunden
  • ausserhalb des Branches existieren diese Namen nicht
  • wenn das Pattern keine Gruppen enthaelt, gibt es auch keine zusaetzlichen Bindings
  • optionale, nicht belegte Gruppen werden trotzdem als _n = undef gebunden
  • sichtbare Namen wie year oder _1 duerfen dabei nicht still ueberschrieben werden

Mit unless sitzt derselbe Match-Fall entsprechend im else-Branch:

unless "world" =~ regex"(?<prefix>^wor)(ld)" {
    println("fallback")
} else {
    println(prefix)
    println(_1)
    println(_2)
}
println("hello" =~ regex"^hel")
println("world" =~ regex"^hel")

Rechts darf entweder ein Regex-Literal oder ein bereits kompiliertes Regex stehen:

import Antra::Regex as Regex

val rx = Regex.compile("^item-[0-9]+$").!
println("item-42" =~ rx)

Die ueblichen String-Operationen binden staerker als =~. Du kannst also vorher zusammensetzen und danach matchen:

println("file" <> str(2) =~ regex"^file2$")

Wenn du statt eines booleschen Ergebnisses lieber optional die Match-Daten willst, gibt es ~?:

match "2026-05-19" ~? regex`(?<year>\d{4})-(\d{2})-(\d{2})` {
    %Some[parts] -> {
        println(parts[0])     # ganzer Treffer -> "2026-05-19"
        println(parts.year)   # benannte Gruppe -> .name
        println(parts[2])     # unbenannte Gruppe -> [nr]
    }
    %None[]      -> println("kein Treffer")
}

~? liefert bei einem Treffer %Some[...], sonst %None[]. Die Form haengt davon ab, ob das Muster Capture-Gruppen hat:

  • Ohne Gruppen -> %Some[string]: einfach der getroffene Text (z.B. %Some["hel"]).
  • Mit Gruppen -> %Some[map]: Key 0 ist der ganze Treffer (wie Perl $&), benannte Gruppen (?<name>...) liegen unter ihrem Namen (parts.name), unbenannte unter ihrer Gruppennummer (parts[2]).

Tipp: Für Regex-Muster ist der Backtick-String regex`...` am angenehmsten — Backslashes bleiben literal, du sparst dir das doppelte Escapen von \d, \w usw. (in regex"..." müsstest du \\d schreiben).

Faustregel:

  • if text =~ rx { ... } fuer kleine lokale Branches mit Capture-Bindings
  • unless text =~ rx { ... } else { ... } wenn der Nicht-Match-Fall der Hauptpfad ist
  • text =~ rx fuer reine Bool-Pruefungen
  • text ~? rx oder Regex.match(...) wenn du Match-Daten als normalen Wert weiterreichen willst

defined?#

Mit dem benannten Praefix-Operator defined? pruefst du, ob ein Wert nicht undef ist:

if defined? user.name then println("hat Namen")

Das ist etwas anderes als allgemeine Truthiness. false und 0 sind nicht undef.

Rationale Zahlen#

Ein besonderes Sprachmerkmal von Antra ist exakte Rational-Arithmetik.

1/3
1/2 + 1/4

Die Sprache speichert solche Werte nicht als ungenaue Floating-Point-Zahlen, sondern als rationale Werte. Das ist vor allem bei Rechenlogik angenehmer, weil sich typische Rundungsfehler vermeiden lassen. Auch Dezimalzahlen wie 0.1 sind solche Brüche, deshalb ist 0.1 + 0.2 == 0.3 wahr.

Wenn du aus JavaScript kommst, ist das ein echter Unterschied. Mehr dazu, auch wann float(...) die bessere Wahl ist, steht in 05-zahlen.md.

Konvertierungen#

Es gibt einige eingebaute Konvertierungsfunktionen:

str(42)
num("12")
int(3.9)
float(1/3)
rat("1/3")

Praktische Faustregel:

  • str(...) für Darstellung
  • num(...) wenn ein String als Zahl gelesen werden soll
  • int(...) wenn du eine Ganzzahl brauchst
  • float(...) wenn exakte Bruchrechnung enden soll, z.B. vor v * 0.95 in jedem Frame (Dezimal-Literale sind exakte Brueche, deren Nenner bei langen Rechenketten wachsen)
  • rat(...) wenn du bewusst einen exakten Rational-Wert haben willst

Ein kleines Gesamtbeispiel#

my subtotal = 19.99
my tax = 1/5
my total = subtotal + subtotal * tax

println("Gesamt: " <> str(total))

Hier sieht man schon gut, wie Antra gedacht ist:

  • Werte werden direkt kombiniert
  • Ausdrücke bleiben klein und lesbar
  • Konvertierung in String passiert erst beim Ausgeben

Referenz: Vergleich, Membership und Ranges#

Für den Alltag reicht oft ==, aber die alte Referenz unterschied diese Fälle ausdrücklich:

  • == für tiefen Wertvergleich
  • === für typstrikten tiefen Vergleich
  • =:= für Referenzidentität bei zusammengesetzten Werten
5 == 5.0      # true
5 === 5.0     # false

my a = [1, 2]
my b = [1, 2]
my c = a

a == b        # true
a =:= b       # false
a =:= c       # true

== wandelt nie um: Werte verschiedener Typen sind einfach ungleich ("1" == 1 ist false, :ok == "ok" ebenso). Nur die Ordnung wirft dann einen Fehler ("1" < 1); umgewandelt wird explizit mit num(...) oder str(...).

Mit in prüfst du je nach Datentyp Mitgliedschaft oder Key-Existenz:

2 in %<1, 2, 3>
"b" in ["a", "b", "c"]
:name in %{name: "Alice"}

Ranges sind ein eigener Datentyp und tauchen sowohl in Iteration als auch in Literalen auf:

1..=10
1..<10
1..*
0..100 by 5     # 0, 5, 10, ..., 100
10..0 by -3     # 10, 7, 4, 1: ein negativer Schritt zaehlt abwaerts

by legt die Schrittweite fest (wie range(0, 100, 5)); es ist nur direkt hinter einer Range auf derselben Zeile ein Schluesselwort, by bleibt als Name frei.

Quelle dieser Seite