Sprache
Funktionen, Lambdas und Pipes
In Antra sind Funktionen der zentrale Organisationspunkt. Pattern Matching sitzt direkt in Signaturen, und der Pipe-Stil macht Datenfluss oft sehr lesbar.
Funktionen definieren#
Benannte Funktionen#
Die Standardform ist eine oder mehrere fn-Klauseln:
fn add(a, b) -> a + bWichtig fuer den aktuellen Sprachstand: Benannte fn- und gen fn-
Definitionen brauchen immer explizit -> zwischen Signatur und Body.
Das gilt auch fuer Block-Bodies:
fn main() -> {
println("hi")
}Mehrere Klauseln sind normal, nicht exotisch:
fn fibo(0) -> 0
fn fibo(1) -> 1
fn fibo(n) when n > 0 -> fibo(n - 1) + fibo(n - 2)Guards mit when#
Ein Guard ist eine zusaetzliche Bedingung an einer Klausel. Er steht zwischen
Signatur und ->:
fn classify(n) when n < 0 -> "negativ"
fn classify(0) -> "null"
fn classify(n) when n < 10 -> "klein"
fn classify(n) -> "gross"Die Reihenfolge ist fn name(params), dann optional when <bedingung>, dann
das verpflichtende ->. Ein nachgestelltes if gibt es nicht — Guards stehen
immer vor dem Pfeil.
Wie ausgewaehlt wird#
Klauseln werden in Definitionsreihenfolge geprueft. Fuer jede Klausel passiert zweierlei, in dieser Reihenfolge:
- Passen die Parameter-Patterns? Wenn nein, weiter zur naechsten Klausel.
- Ist der Guard wahr? Wenn nein, weiter zur naechsten Klausel.
Die erste Klausel, die beides besteht, gewinnt. Der Guard sieht dabei die bereits gebundenen Parameter, denn er wird im Scope der erfolgreichen Pattern-Bindung ausgewertet:
fn sum_pos([x, ..rest]) when x > 0 -> x + sum_pos(rest)Eine Klausel ohne Guard besteht Schritt 2 immer. Sie ist damit ein Fallback — aber nur durch ihre Position. Nichts erzwingt, dass sie am Ende steht; steht sie vorne, sind alle spaeteren Klauseln mit gleichem Pattern unerreichbar:
fn size(n) -> "gross" # faengt alles ab
fn size(n) when n < 10 -> "klein" # wird nie erreichtZwei Klauseln ohne Guard mit derselben Signatur sind dagegen ein Fehler:
RUNTIME ERROR (line 2): Error: duplicate unguarded clause for 'size' with signature (n)
New clause: line 2 in beispiel.ant
Previous clause: line 1 in beispiel.ant
Hint: add a guard or change the pattern so the clauses are distinguishable.Guards duerfen sich in der Signatur beliebig wiederholen — genau dafuer sind sie da.
Wenn keine Klausel passt#
Dann ist das ein Laufzeitfehler. Antra sagt dabei nicht nur, dass nichts gepasst hat, sondern auch welche Klauseln zwar von den Argumenten her passten, aber am Guard gescheitert sind:
RUNTIME ERROR (line 1): Error: No matching clause for classify with 1 args: 5
Closest guard mismatch:
clause 1 (n) matched arguments, but guard 'when n < 0' was falseDas ist meistens der schnellste Weg zur Ursache: entweder fehlt ein Fallback, oder ein Guard ist enger als gedacht.
Was im Guard stehen darf#
Der Guard ist ein normaler Ausdruck. Funktionsaufrufe sind erlaubt und werden in der Stdlib auch genutzt:
fn format(time, template, [zone: = :utc, ..]) when string?(template) -> ...Zwei Dinge, die man dabei im Kopf behalten sollte:
- Guards laufen einmal pro Klauselversuch. Scheitert der Guard von Klausel 1, wurde er trotzdem ausgewertet, bevor Klausel 2 drankommt.
- Seiteneffekte im Guard passieren also wirklich, und moeglicherweise oefter als erwartet. Guards sollten Praedikate sein, keine Aktionen.
Guards kombinieren sich frei mit Destructuring, Default-Parametern und den
Binding-Modifiern (my / val / const / mut).
Wo Guards sonst noch gehen#
Nicht nur an fn. Auch gen fn, match-Arme und catch-Arme nehmen when:
match value {
n when n < 0 -> "negativ"
0 -> "null"
1 | 2 | 3 -> "klein"
_ -> "rest"
}Lambdas nehmen keine Guards. Nach den Parametern muss direkt -> folgen,
sonst gibt es einen Parse-Fehler.
Verschachtelte Funktionen#
fn-Definitionen sind nicht auf die oberste Ebene beschraenkt: Innerhalb
eines Funktions-Bodys kannst du weitere benannte Funktionen definieren. Das
ist der natuerliche Ort fuer Helfer, die nur eine einzige Funktion braucht —
statt sie auf Modulebene zu heben oder in ein Lambda zu quetschen:
fn outer(x) -> {
fn double(n) -> n * 2
fn quad(n) -> double(double(n))
quad(x)
}
println(outer(3)) # 12Drei Eigenschaften machen verschachtelte Funktionen praktisch:
Sie sind lokal. Eine innere Funktion existiert nur im Block, in dem sie
definiert wurde. Von aussen ist sie unsichtbar — inner() ausserhalb von
outer ist ein RUNTIME ERROR: Function 'inner' not defined. Das haelt den
Modul-Namespace sauber: Helfer, die nur eine Funktion betreffen, tauchen
nirgendwo sonst auf.
Sie koennen alles, was fn sonst auch kann. Multi-Clause-Dispatch,
Pattern Matching, Guards und Rekursion funktionieren auch verschachtelt:
fn outer(n) -> {
fn fact(0) -> 1
fn fact(k) -> k * fact(k - 1)
fact(n)
}
println(outer(5)) # 120Sie sind Closures. Eine innere Funktion sieht die Parameter und Bindungen der umgebenden Funktion — und behaelt sie auch, wenn sie als Wert zurueckgegeben wird:
fn make_adder(x) -> {
fn add(n) -> n + x
add
}
my plus3 = make_adder(3)
println(plus3(10)) # 13Fuer den einzeiligen Fall ohne eigenen Namen bleibt das Lambda die kompaktere
Form (siehe Lambdas). Die verschachtelte fn lohnt sich, sobald
der Helfer einen sprechenden Namen verdient, mehrere Klauseln braucht oder
rekursiv ist.
Parameter#
Default-Parameter#
fn connect(host = "localhost", port = 80) -> host <> ":" <> str(port)Das ist besonders praktisch, wenn du eine API mit guten Defaults anbieten willst, ohne viele Hilfsfunktionen zu bauen.
Was im Default stehen darf#
Ein Default ist kein Literal-Slot, sondern ein vollwertiger Ausdruck. Er darf rechnen, Funktionen aufrufen und dabei auf frueher gebundene Parameter zugreifen:
fn foo(x, y = x * 2, z = y + x) -> str(x) <> "/" <> str(y) <> "/" <> str(z)
println(foo(1)) # 1/2/3
println(foo(1, 10)) # 1/10/11Die Reihenfolge zaehlt: Ein Default sieht nur die Parameter links von sich,
nicht sich selbst und nicht die danach. fn f(y = y) ist ein Fehler, und
fn f(a = b, b = 1) findet b nicht.
Auch Funktionsaufrufe sind erlaubt, inklusive importierter Pakete:
import Antra::String
fn shout(x, y = String.upper(x)) -> x <> y
shout("Felix") |> println # FelixFELIXWann der Default ausgewertet wird#
Zwei Punkte, die zusammen die meisten Ueberraschungen erklaeren:
- Nur wenn das Argument fehlt. Wird der Parameter uebergeben, wird der Default-Ausdruck gar nicht erst ausgefuehrt.
- Bei jedem Aufruf neu. Es gibt keinen einmalig berechneten Wert, der danach wiederverwendet wird.
fn side() -> {
println(" ausgewertet")
42
}
fn f(a = side()) -> a
println(f(1)) # 1 — side() laeuft nicht
println(f()) # 42 — side() laeuft
println(f()) # 42 — side() laeuft erneutFuer reine Berechnungen ist das unproblematisch. Bei Seiteneffekten solltest du wissen, dass sie pro Aufruf ohne Argument einmal passieren.
Defaults und der aeussere Scope#
Weil ein Default bei jedem Aufruf neu ausgewertet wird, waere ein Default auf eine veraenderliche aeussere Bindung zeitabhaengig — derselbe Aufruf lieferte je nach Programmzustand andere Ergebnisse. Antra verbietet das darum:
my u = "A"
fn foo(z = u) -> z # RUNTIME ERROR beim Aufruf foo():
# Default parameter value references mutable
# outer binding 'u'.Erlaubt sind in Default-Ausdruecken nur Referenzen, die sich zwischen zwei Aufrufen nicht aendern koennen:
frueher gebundene Parameter —
fn f(a, b = a * 2); alles Noetige steht in derselben Zeile und haengt nur von den Argumenten dieses Aufrufs abconst-Bindungen und Modulkonstanten:const LIMIT = 10 fn take_n(n = LIMIT) -> nFunktionen, Protokolle und Import-Aliase —
fn f(x = String.upper("a"))
Verboten sind mutable my-Bindungen, pub our-Variablen — und auch val:
val verhindert nur die Neuzuweisung, nicht die In-Place-Mutation des Werts.
Ein val xs = [1, 2] koennte sich also trotzdem zwischen zwei Aufrufen
aendern, und bei Composites teilte sich der Default die Referenz mit der
aeusseren Bindung.
Der Fehler kommt zur Laufzeit, sobald der Default tatsaechlich ausgewertet
wird (also nur bei Aufrufen ohne dieses Argument) — und schon vorher statisch
per antra --check als default-mutable-outer.
Wenn der Wert wirklich von aussen kommen soll, gib ihn explizit am Aufrufort
mit oder mach die Bindung zu einem const. Das haelt die Signatur ehrlich:
was eine Funktion beeinflusst, steht entweder in ihren Argumenten oder ist
eine Konstante.
Keyword-Array-Parameter und die Kurzform#
Dasselbe Prinzip funktioniert auch fuer einen letzten Keyword-Array-Parameter, wenn alle dortigen Keys Defaults haben:
fn test_opts([x: =1, y: =2] as opts) -> dd(opts)
test_opts()
test_opts([])
test_opts([y: 9])
test_opts(y: 9) # man kann auch die Eckklammern weglassentest_opts() wird dabei so behandelt, als waere fuer den letzten Parameter
implizit ein leeres Keyword Array [] uebergeben worden. Die Defaults werden
dann wie gewohnt materialisiert.
Wenn danach noch ein Callback folgt, kannst du das leere Keyword Array ebenfalls weglassen:
fn render([pretty: = true, indent: = 8] as opts, cb) -> cb(opts)
render() { |opts|
dd(opts)
}Wenn kein Positionsargument nötig ist, kannst du sogar die leeren Klammern selbst weglassen und den Block direkt an den Namen haengen:
render { |opts|
dd(opts)
}render { ... } ist dabei nur Kurzschreibweise fuer render() { ... } — der
Block wird als letztes (Callback-)Argument uebergeben. Das geht ausschliesslich,
wenn davor kein Positionsargument steht; sobald du eines brauchst, gehoeren die
Klammern wieder dazu.
Mit einem Positionsargument davor sieht das so aus:
fn render(page, [pretty: = true, indent: = 8] as opts, cb) -> cb(page, opts)
render(page) { |page, opts|
dd(page, opts)
}Der Block (oder ein |x| -> ...-Lambda) muss dabei auf derselben Zeile
wie die schliessende ) bzw. der Name beginnen. Ein Lambda, das erst auf der
naechsten Zeile anfaengt, ist ein eigenes Statement — so kann eine Funktion
nach einem Aufruf ein Lambda zurueckgeben:
fn adder(n) -> {
println("adder $n")
|x| -> x + n # Rueckgabewert, kein Argument von println
}
println(adder(1)(10)) # adder 1, dann 11Dasselbe gilt hinter einem Pipe-Ziel: xs |> foreach |x| -> ... haengt das
Lambda an, ein Lambda auf der Zeile nach xs |> println dagegen nicht.
Diese Kurzform ist bewusst eng begrenzt:
- nur Keyword-Array-Patterns profitieren davon
- nur wenn alle Keys einen Default haben
- nur an drei Positionen: als letzter Parameter, direkt vor einem
Callback-Parameter (siehe oben) oder direkt vor einem
..rest
Beim ..rest-Fall entscheidet die Form des Arguments: Ein Keyword Array
(auch []) an der Position bindet den Options-Parameter, alles andere rutscht
in den Rest.
fn mid(a, [x: = 2] as opts, ..rest) -> ...
mid("a", x: 9, 1, 2) # opts = [x: 9], rest = [1, 2]
mid("a", 1, 2) # implizites []: opts = [x: 2], rest = [1, 2]
mid("a", [], [1, 2]) # explizit: opts = [x: 2], rest = [[1, 2]]Sobald ein Pflicht-Key ohne Default uebrig bleibt, gibt es weiter keinen Match:
fn test_opts([x: =1, y:] as opts) -> dd(opts)
test_opts() # Fehler: :y hat keinen DefaultDie Laufzeitfehlermeldung weist in diesem Fall jetzt gezielt darauf hin, dass der letzte Keyword-Array-Parameter nicht implizit entfallen darf, weil noch Pflicht-Keys ohne Default vorhanden sind.
Weitere Parameter-Formen#
Defaults dürfen sich auf frühere Parameter beziehen:
fn foo(x, y = x * 2) -> x + yWeitere Pattern-Formen in Parametern:
fn sum([]) -> 0
fn sum([x, ..xs]) -> x + sum(xs)
fn connect([:host h = "localhost", :port p = 80, ..]) -> h <> ":" <> str(p)
fn render(%{host: h = "localhost", port: p = 80, ..}) -> h <> ":" <> str(p)_args und _params#
In fn, gen fn und expliziten Lambdas stehen zwei implizite Bindungen zur
Verfuegung. Sie beantworten unterschiedliche Fragen:
_args— was hat der Aufrufer tatsaechlich uebergeben? Roh, unveraendert, ohne Defaults und ohne Rest-Sammlung._params— womit arbeitet die Funktion? Ein Eintrag pro deklariertem Parameter, mit eingesetzten Defaults und gesammeltem Rest.
fn demo(a = 10, b = 20, ..rest) -> {
dd(_args)
dd(_params)
}
demo()
# _args: []
# _params: [10, 20, []]
demo(1)
# _args: [1]
# _params: [1, 20, []]
demo(1, 2, 3, 4)
# _args: [1, 2, 3, 4]
# _params: [1, 2, [3, 4]]Gut zu sehen: _args hat bei demo() gar keine Elemente, _params immer
genau drei — eines pro Parameter. Der Rest-Parameter ist in _params ein
Array, in _args liegen seine Werte flach daneben.
Hat eine Funktion weder Defaults noch Rest, sind beide identisch:
fn f(a, b) -> dd(_args == _params)
f(1, 2) # trueUmgekehrt verteilt .. beim Aufruf ein Array oder eine Range auf einzelne
Argumente:
fn show(a, b, c, d) -> println([a, b, c, d])
val xs = [2, 3]
show(1, ..xs, 4) # [1, 2, 3, 4]
show(..1..4) # [1, 2, 3, 4]_args eignet sich damit zum unveraenderten Weiterreichen:
fn target(x, y, z) -> x + y + z
fn proxy(..all) -> target(.._args)
println(proxy(1, 2, 3)) # 6Zwei Feinheiten:
Bei einem Keyword-Array-Parameter zeigt
_paramsden uebergebenen Wert, nicht die materialisierten Optionen. Die Defaults landen imas-Alias und in den einzelnen Key-Bindings, nicht in_params.fn g([host: = "lh", port: = 80] as o) -> { dd(_params) # [[[:port, 99]]] — nur das Uebergebene dd(o) # [[:port, 99], [:host, "lh"]] — mit Default } g(port: 99)Beide Namen sind reserviert und koennen nicht als Parametername dienen:
fn f(_args)ist ein Parse-Fehler.
Oder-Patterns in Parametern#
Die Oder-Form aus match-Armen funktioniert genauso in Parametern von fn und
gen fn. Eine Klausel deckt damit mehrere Faelle ab, ohne sie mehrfach zu
schreiben:
fn small(1 | 2 | 3) -> "klein"
fn small(n) -> "gross"
fn kind(:a | :b) -> "buchstabe"
fn kind(x) -> "rest"
fn unwrap(%Ok[v] | %Some[v]) -> v
fn length_class([] | [_]) -> "kurz"
fn length_class(xs) -> "lang"Das geht an jeder Parameterposition, nicht nur der ersten, und laesst sich mit
as und mit Guards kombinieren:
fn m(x, :a | :b) -> "zweiter Parameter"
fn bit(0 | 1 as n) -> "bit " <> str(n)
fn k(1 | 2 | 3 as n) when n > 1 -> "gross genug"Es gelten zwei Einschraenkungen.
Alle Alternativen muessen dieselben Bindungsnamen sichtbar machen — dieselbe
Regel wie bei match. Sonst waere im Body nicht klar, was gebunden ist:
fn q([x] | [y, _]) -> "hm"
# Parse error: OR-pattern alternatives in parameter pattern alternation
# must expose the same visible binding namesDas trifft auch den Fall, dass ein Zweig gar nichts bindet: fn r([x] | []) -> x
ist aus demselben Grund ein Fehler.
Die Alternation steht nur auf oberster Ebene eines Parameters, nicht verschachtelt in einem Array-, Map-, Tagged- oder Keyword-Array-Pattern:
fn s([1 | 2, rest]) -> ... # Parse error
fn u(%Ok[1 | 2]) -> ... # Parse error
fn v(%{a: 1 | 2}) -> ... # Parse errorAusserdem gilt die Form heute nur fuer fn- und gen fn-Parameter. Lambdas
sind noch nicht dabei — |1 | 2| -> ... ist ein Parse-Fehler, weil | dort
bereits Anfang und Ende der Parameterliste markiert und die doppelte Bedeutung
eine zusaetzliche Syntaxentscheidung braeuchte. Ebenfalls noch offen sind
Alternativen in for-Bindings, in my/val/const-Bindings und in catch
(siehe specs/impl/spec_or_patterns.md).
Binding-Modifier: my, val, const#
Standardmäßig ist ein Parameter eine feste Bindung — er verhält sich wie
val. Ein Neuzuweisen im Funktionskörper ist ein Fehler, ein Deep-Set auf dem
Wert dagegen erlaubt:
fn inc(x) -> {
x = x + 1 # Fehler: Parameter binden per Default wie `val`
}
fn scale(xs) -> {
xs[0] = xs[0] * 2 # ok: nur das Neuzuweisen ist verboten, nicht das Mutieren
xs
}Wenn du den Parameter wirklich neu zuweisen willst, ist my der explizite
Opt-out — es kehrt die Bindung auf „veränderlich" um:
fn inc(my x) -> {
x = x + 1 # ok
x
}Die drei Stufen, dieselben wie bei lokalen Deklarationen:
| Modifier | Bindung | Wert |
|---|---|---|
(kein) / val | fest | mutierbar (Deep-Set ok) |
my | neu zuweisbar | mutierbar |
const | fest | eingefroren |
fn peek(val x) -> {
x = 9 # Fehler: 'val'-Bindung ist nicht neu zuweisbar
}
fn scale(val xs) -> {
xs[0] = xs[0] * 2 # ok: 'val' verbietet nur das Neuzuweisen, nicht das Mutieren
xs
}
fn freeze(const xs) -> {
xs[0] = 9 # Fehler: 'const' friert den Wert ein
}my ist der Opt-out aus dem val-Default: schreib es hin, wenn du den
Parameter im Körper neu zuweisen willst.
Der Modifier gilt für jede Parameterform, auch destrukturierende — dann sind alle daraus gebundenen Namen betroffen:
fn kw(val [a: c, b: d]) -> {
c = 10 # Fehler: c stammt aus einem 'val'-Parameter
}Ein const-Parameter friert — wie const überall in Antra — das übergebene
Wertobjekt ein. Da der Aufrufer dieselbe Referenz hält, ist der Wert danach auch
dort eingefroren; das ist dieselbe Semantik wie bei const b = a.
Der Static-Checker meldet einen verbotenen Zugriff bereits vor der Ausführung
(assign-immutable fürs Neuzuweisen, mutate-immutable fürs Deep-Set bei
const).
Nicht kombinierbar mit mut: mut bedeutet „Wert des Aufrufers verändern", ein
Binding-Modifier das Gegenteil — mut val x bzw. val mut x ist ein
Parse-Fehler.
mut bei Parametern#
Antra erlaubt Mutation, aber sichtbar markiert:
fn touch(mut user) -> user.age := 31Damit ist klar: Diese Funktion darf ihr Argument absichtlich verändern.
In/Out für alle Typen#
mut ist ein echter Ein-/Ausgabe-Parameter und funktioniert für jeden
Typ — auch Skalare wie Strings und Zahlen. Der Parameter ist im Körper eine
mutierbare lokale Bindung; beim Return wird sein Endwert in die übergebene
Variable zurückgeschrieben:
fn bump(mut n) -> n = n + 1
my x = 41
bump(x)
dd(x) # 42 — die Zuweisung im Körper wirkt zurück
fn shout(mut s) -> s = upper(s)
my greet = "hi"
shout(greet)
dd(greet) # "HI"Zurückgeschrieben wird nur, wenn sich der Wert tatsächlich ändert:
- Bei Composites (Array/Map/Objekt) wirkt eine In-Place-Mutation
(
a[0] = …,user.age := …) ohnehin sofort über die geteilte Referenz — hier ist kein Rückschreiben nötig, deshalb bleibt das auch für eineval-Bindung erlaubt. - Ein Rebind (
n = …,a = […]) erzeugt einen neuen Wert und schreibt ihn zurück. Das Ziel muss dafür veränderbar sein — ein Rebind in eineval/const-Variable ist ein Fehler.
fn repl(mut a) -> a = [99]
my xs = [1, 2]
repl(xs)
dd(xs) # [99] — Rebind wirkt zurück (my-Bindung)
val ys = [1, 2]
repl(ys) # Fehler: kann nicht in eine val-Bindung zurückschreibenDas Argument muss ein zuweisbares Ziel sein, in das zurückgeschrieben werden
kann — eine Variable oder ein Index-/Feld-Ziel (f(xs[0]), f(obj.feld)). Ein
verändertes Literal/Ausdrucks-Argument ist ein klarer Fehler:
my xs = [10, 20]
bump(xs[0]) # ok — schreibt in xs[0] zurück
dd(xs[0]) # 11
bump(41) # Fehler: kein lvalue zum ZurückschreibenAuch String-Index-Zuweisung wirkt zurück (s[0] = "K" ersetzt ein Zeichen), da
sie intern ein Rebind ist:
fn cap(mut s) -> s[0] = "K"
my name = "felix"
cap(name)
dd(name) # "Kelix"Grenzen: In Generatoren (gen fn) wird nicht zurückgeschrieben.
Mit der val-Stufe gilt dabei:
val-Bindings sind fuermuterlaubt.const-Bindings bleiben fuermutverboten.
fn touch(mut user) -> user.age := 31
val user1 = %{ age: 30 }
touch(user1) # erlaubt
const user2 = %{ age: 30 }
touch(user2) # FehlerDas gilt aber nur fuer mutierbare Argumente. Ein const-Composite darf nicht an mut uebergeben werden:
fn touch(mut user) -> user.age := 31
const user = %{ age: 30 }
touch(user) # FehlerBesonders interessant wird das mit Patterns:
fn enrich(%User{..} as mut user) -> user.id := 42Die Funktion sagt damit sowohl etwas über die Form als auch über die beabsichtigte Mutation.
Dasselbe gilt fuer as mut: Form-Match plus mutierbarer Alias ja, aber nicht auf Basis einer const-Bindung.
on_use bei Parametern#
Neben mut gibt es mit on_use noch einen zweiten sichtbaren Parametermodus:
fn assert_error(on_use expr, needle) -> ...on_use bedeutet: Das zugehoerige Argument wird beim Aufruf nicht sofort
ausgewertet, sondern erst dann, wenn der Parameter im Funktionskoerper benutzt
wird.
Das ist vor allem fuer APIs nuetzlich, die einen Ausdruck bewusst spaeter oder kontrolliert auswerten wollen, zum Beispiel Test- oder Fallback-Helfer.
Beispiel:
fn observe(on_use expr) -> try {
expr
} catch {
_ -> "caught"
}
observe(parse("???").!)Ohne on_use wuerde parse("???").! schon vor dem Funktionsaufruf werfen.
Mit on_use darf observe(...) selbst entscheiden, wann der Ausdruck
ausgefuehrt wird.
Ein gutes Denkmodell ist ein theoretisch selbst gebautes if:
fn if_else(cond, on_use then_branch, on_use else_branch) ->
if (cond) then then_branch else else_branch
println(if_else(true, "gross", "klein"))
println(if_else(true, 42, 1 / 0))Ohne on_use wuerde 1 / 0 schon vor if_else(...) ausgewertet und sofort
scheitern. Mit on_use wird nur der wirklich benoetigte Branch ausgefuehrt.
Ein lauffaehiges Beispiel dazu findest du auch in examples/on_use_if.ant.
Praktisch wichtig sind diese Regeln:
- normale Parameter bleiben eager;
on_useist die explizite Ausnahme - ein
on_use-Ausdruck kann bei mehrfacher Nutzung auch mehrfach ausgewertet werden on_useundmutduerfen nicht auf demselben Parameter kombiniert werden
Wenn du |> dazu verwendest, gilt weiter die normale Pipe-Regel: der linke
Ausdruck landet auf der ersten Argumentposition. Darum passt |> nur dann gut,
wenn die erste Zielposition auch wirklich der on_use-Parameter ist.
Lambdas und Funktionswerte#
Lambdas#
Lambdas sind anonyme Funktionen.
my double = |x| -> x * 2
my constant = || -> 42Sie eignen sich gut für:
mapfilter- kleine lokale Transformationen
- Weitergabe an andere Funktionen
Lambdas mit Block-Body#
my format_user = |user| -> {
my base = user.name
base <> " (" <> str(user.age) <> ")"
}Wenn ein Ausdruck allein reicht, ist die Einzeiler-Form angenehm. Wenn mehrere Schritte nötig sind, nimm einen Block.
Fuer Block-Lambdas gibt es zwei Stile:
items |> map { _ * 2 }
items |> foreach { |item, index|
println(str(index) <> ":" <> item)
}Die Kurzform mit _, _1, _2 ist praktisch fuer sehr kleine Bodies. Sobald
du mehrere benannte Werte brauchst oder ein Block lesbarer werden soll, ist
{ |...| ... } oft die bessere Form.
Im Ueberblick sind das diese vier Formen:
{ _ * 2 }
{ _1 + _2 }
{ |x| x * 2 }
{ |x, i|
println(str(i) <> ":" <> x)
}Explizite Lambda-Parameter unterstuetzen dieselben Formen wie fn, inklusive
Array-, Map- und Keyword-Array-Patterns. Das gilt sowohl fuer |...| -> expr
als auch fuer die Block-Form { |...| ... }.
Closures#
Ein Lambda sieht die Bindungen aus seiner Umgebung und haelt sie fest, auch wenn die umgebende Funktion laengst zurueckgekehrt ist:
fn adder(x) -> |y| -> x + y
my add5 = adder(5)
println(add5(3)) # 8Das funktioniert auch mit Block-Body, und jeder Aufruf der aeusseren Funktion erzeugt einen eigenen, unabhaengigen Zustand:
fn make_counter() -> {
my n = 0
my inc = || -> {
n = n + 1
n
}
inc
}
my a = make_counter()
my b = make_counter()
println(a()) # 1
println(a()) # 2
println(b()) # 1 — eigener ZaehlerBeachte n = n + 1 im Lambda: n stammt aus einer my-Bindung und ist
deshalb neu zuweisbar. Waere es val, wuerde das Hochzaehlen scheitern.
Aufrufe brauchen Klammern#
Ein Bezeichner wertet immer zu seinem Wert aus — auch dann, wenn dieser Wert
aufrufbar ist. inc ist das Lambda, inc() ist der Aufruf:
my inc = || -> 42
inc # der Lambda-Wert (als Statement ohne Wirkung)
println(inc) # <lambda>
println(inc()) # 42Genau deshalb kann make_counter oben sein inc als letztes Statement
zurueckgeben, ohne es aus Versehen aufzurufen.
Die Ausnahmen sind die Formen mit eigener Syntax: Pipes (value |> println),
Callback-Wrapper (&name) und die Trailing-Block-Kurzform (render { ... }).
Funktionswerte und &name#
Eine benannte Funktion ist selbst schon ein Wert.
fn add(a, b) -> a + b
my f = add
println(f(10, 10))Wenn du eine Funktion weiterreichen oder in einer Variable speichern willst, ist also der normale Name die richtige Form.
&name ist etwas anderes: kein normaler Funktionswert, sondern ein kurzer
Callback-Wrapper.
Die Kurzform:
&strist Zucker fuer:
|_| -> str(_)Daneben gibt es eine allgemeine Form mit Placeholdern:
&add(_, _)
&Math.add(_1, _2)
&replace(_1, ":", _2)
&pair(_2, _1)Gedanklich ist zum Beispiel:
&add(_, _)ungefaehr dasselbe wie:
|a, b| -> add(a, b)Darum passt &name vor allem fuer kurze Callback-Situationen:
[1, 2, 3] |> map(&str)
[1, 2, 3, 4] |> filter(&even)
reduce([1, 2, 3], 0, &add(_, _))
sort_pairs |> map(&Demo.pair(_2, _1))Wichtig ist die Unterscheidung:
addist die Funktion selbstmy f = addspeichert den echten Funktionswert&addist die unary Kurzform fuer einen Callback-Wrapper&add(_, _)ist die allgemeine Callback-Form mit zwei Placeholdern- qualifizierte Ziele wie
&Math.add(...)oder&Demo.pair(...)sind erlaubt
Als Faustregel:
- Funktion als Wert weiterreichen oder speichern:
name - kurzer unary Callback in
map,filter,foreach:&name - mehrstelliger oder umsortierter Callback:
&name(...)
Die Anzahl der Placeholder bestimmt dabei auch die spaetere Arity des gebauten Callbacks. Fuer einen direkten Aufruf wuerdest du bei Pipes oft einfach die Funktion selbst nehmen:
my r = 1..*
println(r |> take(10))Ein Callback-Wrapper wird interessant, wenn du Argumente fest einbauen oder umordnen willst:
my r = 1..*
my first10 = &take(_, 10)
println(r |> first10)&take(_, 10) ist unary: Der Pipe-Aufruf liefert den ersten Wert, die 10
steckt schon fest im Wrapper.
Wenn du dagegen spaeter beide Argumente noch uebergeben willst, brauchst du die
zweistellige Form &take(_, _). &take(_) passt nicht zu r |> cb(10), weil
der Pipe-Aufruf links bereits einen Wert einschiebt und 10 als zweites
Argument dazukommt.
Wenn du die Funktion selbst als Wert willst, bleibt name die richtige Form:
fn add(a, b) -> a + b
my f1 = add
my f2 = &add(_, _)Hier ist f1 der normale Funktionswert. f2 ist dagegen ein explizit gebauter
Callback-Wrapper.
Daneben gibt es mit \expr noch eine dritte Form: sie baut ein implizites
Lambda, bei dem _, _1, _2 die Parameter sind.
println(10 |> \_ + 2)
[2, 3] ,|> \_1 + _2Fuer direkte Pipe-Ziele brauchst du keine dieser Formen — dort genuegt der Name:
value |> println
items |> foreach &print_itemPipes und Datenfluss#
Der Pipe-Operator#
Der Pipe-Operator |> leitet den Wert links als Eingabe an den Aufruf rechts weiter.
[1, 2, 3]
|> map(|x| -> x * 2)
|> printlnDas liest sich von oben nach unten oft natürlicher als ineinander verschachtelte Calls.
Wenn du aus Elixir oder F# kommst, wirkt das vertraut. Wenn du aus JavaScript kommst, kannst du es dir wie gut lesbare Method-Chains ohne Methodenbindung vorstellen.
Rechts von |> darf inzwischen mehr stehen als nur ein benannter
Funktionsaufruf. Heute funktionieren diese Formen:
- benannter Call wie
value |> Mod.fn(...)odervalue |> fn_name(...) - einfacher Identifier wie
value |> f, wennfzur Laufzeit ein Callable ist - direktes Lambda-Ziel wie
|x| -> ...,\...oder{ ... }
Beispiele:
println(10 |> |x| -> x + 1)
println(10 |> \_ + 2)
println(10 |> {
_1 + 3
})Dazu kommt ein Callback-Wrapper mit Argumentliste direkt als Ziel. Der
gepipte Wert landet dabei im Platzhalter _, nicht als zusaetzliches erstes
Argument:
fn add(a, b) -> a + b
println(10 |> &add(_, 5)) # add(10, 5) -> 15
3 |> &println("sum = ", _) # println("sum = ", 3)Qualifizierte Ziele (|> &Mod.fn(_, x)) und eine zusaetzliche Klammer
(|> (&add(_, 5))) sind ebenfalls erlaubt. Genauso ein geklammerter
Lambda-Ausdruck:
println(10 |> (|x| -> x + 1)) # 11Konzeptionell ist das jeweils Zucker fuer einen normalen unary Aufruf des rechten Callable-Werts.
Ein blankes &fn ohne Argumentliste bleibt dagegen identisch zum Ziel fn
— es wird nicht als Wrapper gelesen. Das ist wichtig fuer die Splice-Pipe
weiter unten, wo [a, b] ,|> &add die beiden Werte als Argumente an add
gibt.
Das bleibt auch dann wichtig, wenn die Ziel-Funktion on_use-Parameter hat:
left |> f(a, b)entspricht weiterf(left, a, b)- ist die erste Zielposition
on_use, bleibtleftbis zur Nutzung im Funktionskoerper unevaluiert - ist
on_useerst spaeter in der Signatur, ist|>dafuer meist nicht die richtige Form
Beispiel:
fn observe(on_use expr, label) -> {
println(label)
try {
expr
} catch {
_ -> println("caught")
}
}
boom() |> observe("before")Das funktioniert, weil |> den linken Ausdruck auf die erste Position setzt
und diese hier on_use ist.
Wenn die Signatur stattdessen so aussieht:
fn f(prefix, on_use expr) -> ...dann ist boom() |> f("x") nicht dasselbe wie f("x", boom()), sondern
entspricht f(boom(), "x").
Splice-Pipe ,|>#
Mit ,|> wird der linke Wert nicht als erstes Argument eingesetzt, sondern als
gespreizter Input verwendet — nuetzlich, wenn links bereits eine Argumentliste
vorliegt:
[2, 3] ,|> |a, b| -> a + b
[2, 3] ,|> \_1 + _2
[10, 20] ,|> add()Lookup-basierte Ziele wie |> callbacks[i] oder |> obj.method_ref sind noch
nicht als Pipe-Ziel erlaubt; dafuer bindest du das Callable vorher an einen
Namen.
Bei ,|> gilt dieselbe Grundidee nicht mehr eins zu eins: hier wird links
zuerst ein Array ausgewertet und in mehrere Argumente gespreizt. Fuer einen
einzelnen on_use-Parameter ist das daher normalerweise nicht die passende
Form.
Praktisch heisst das:
- wenn du rechts einfach nur einen vorhandenen Callable-Wert benutzen willst,
ist
|> cheute okay - wenn du rechts eine kleine Inline-Weiterverarbeitung bauen willst, sind
|> |x| -> ...,|> \...und|> { ... }die offiziellen Formen - willst du ein Argument fest einbauen oder umordnen, ist der Callback-Wrapper
|> &fn(_, x)die passende Form
Der Apply-Operator#
<| ist das Spiegelbild von |>: waehrend |> seinen linken Wert vorne in
die Argumentliste einfuegt, haengt <| seinen rechten Wert hinten an.
fn sep(a, b) -> a <> "|" <> b
"x" |> sep("y") # -> sep("x", "y") -> "x|y"
sep("y") <| "x" # -> sep("y", "x") -> "y|x"
println <| "Hello" # -> println("Hello")Der Zweck ist, Klammern um einen langen rechten Ausdruck zu sparen. <| bindet
schwaecher als alle anderen Operatoren, rechts darf also ein kompletter Ausdruck
stehen:
println <| if cond then "full" else "empty"Der Operator ist rechtsassoziativ, f <| g <| x ist also f(g(x)):
println <| str <| 40 + 2 # -> 42Richtungen nicht mischen#
|> und <| im selben Ausdruck sind ein Parse-Fehler — die Leserichtung waere
sonst nicht mehr eindeutig:
xs |> f <| y # Parse error: cannot mix '|>' and '<|' in one expression
(xs |> f) <| y # eindeutigEin |> innerhalb des rechten Operanden ist dagegen geschachtelt und
erlaubt: println <| str <| (21 |> dbl()).
<| oder Klammern?#
<| ist reine Kosmetik — println(...) bleibt immer korrekt und ist bei kurzen
Argumenten meist klarer. Der Operator lohnt sich, wenn der rechte Ausdruck lang
ist oder selbst Klammern enthaelt. Fuer partielle Anwendung ist er nicht
gedacht; dafuer gibt es &f(_1, 10).
Wann Pipe, wann normaler Call?#
Nimm Pipes vor allem dann, wenn:
- der Datenfluss wichtiger ist als der Funktionsname
- mehrere Transformationen hintereinander kommen
- du eine lineare Verarbeitung zeigen willst
Nimm normale Calls, wenn:
- es nur ein kurzer Aufruf ist
- die Funktion semantisch im Vordergrund steht
- Pipes die Lesbarkeit eher verschlechtern würden
Typische Kombination: Daten verarbeiten#
my result = [1, 2, 3, 4]
|> filter(|x| -> x % 2 == 0)
|> map(|x| -> x * 10)Das beschreibt eine Pipeline:
- erst filtern
- dann transformieren
- Ergebnis weiterverwenden
Vertiefung#
Tail-Call-Optimierung#
Was ist das ueberhaupt? Jeder Funktionsaufruf legt einen Eintrag auf den Aufruf-Stack: "hier geht es nachher weiter". Bei Rekursion stapeln sich diese Eintraege — eine Funktion, die sich 100.000-mal selbst aufruft, haelt 100.000 offene Eintraege gleichzeitig. Irgendwann ist der Stack voll und das Programm stirbt (Stack Overflow), lange bevor die Rekursion fachlich fertig waere.
Ein Tail Call ist ein Aufruf in Endposition: der rekursive Aufruf ist das Allerletzte, was die Funktion tut — nach seiner Rueckkehr gibt es nichts mehr zu erledigen, sein Ergebnis wird einfach durchgereicht. Dann muss sich die Sprache den alten Stack-Eintrag gar nicht merken: sie kann ihn wiederverwenden und den rekursiven Aufruf intern wie einen Schleifensprung ausfuehren. Das ist die Tail-Call-Optimierung (TCO): aus beliebig tiefer Rekursion wird konstanter Speicherverbrauch.
Der Unterschied ist am Vergleich gut sichtbar:
fn bad(n) -> 1 + bad(n - 1) # KEIN Tail Call: nach dem Aufruf
# muss noch "1 +" gerechnet werden -
# jeder Eintrag bleibt offen
fn good(n, acc) -> good(n - 1, acc + 1) # Tail Call: der Aufruf IST das
# Ergebnis - der Eintrag kann
# wiederverwendet werdenBei bad muss sich jede Ebene merken "wenn das Ergebnis kommt, addiere noch 1
drauf" — deshalb kann der Stack-Eintrag nicht weg. Bei good wandert der
Zwischenstand stattdessen in den Akkumulator-Parameter acc mit; nach dem
Aufruf bleibt nichts zu tun. Dieses Umbauen ("erst rechnen, dann aufrufen"
statt "erst aufrufen, dann rechnen") ist der uebliche Weg, eine Rekursion
TCO-faehig zu machen.
Warum ist das nuetzlich? In einer funktionalen Sprache wie Antra ist
Rekursion das natuerliche Gegenstueck zur Schleife — gerade zusammen mit
Klausel-Patterns wie fn length([], acc) / fn length([_, ..xs], acc). Ohne
TCO waere dieser Stil auf kleine Eingaben beschraenkt; mit TCO laeuft er ueber
Listen beliebiger Laenge genauso sicher wie ein while.
Antra hat dafuer einen kleinen, aber inzwischen deutlich nuetzlicheren TCO-Pfad fuer rekursive Self-Calls. Fuer den Alltag reichen diese Faustregeln:
- unmarkierte Funktionen behalten den historischen Direktfall: ein nackter rekursiver Ruecksprung als Clause-Body kann weiterhin opportunistisch optimiert werden
- fuer garantierte Tail-Rekursion markierst du die Funktion mit
@tailrec - in
@tailrecmuessen rekursive Self-Calls in echter Tail-Position stehen; sonst ist das ein Fehler statt einer stillen Deoptimierung - Tail-Positionen erkennt Antra heute im direkten Funktionsbody, in
if/unless, inmatch, im letzten Statement eines Blocks und imtry- sowie im ausgewaehltencatch-Body - nicht tailig sind weiter Formen wie
1 + loop(...), ein frueher rekursiver Call vor spaeterer Arbeit im selben Block oder ein rekursiver Call innerhalb einescatch-Guards
@tailrec
fn length([], acc) -> acc
fn length([_, ..xs], acc) -> length(xs, acc + 1)
println(length([1, 2, 3], 0))Ein Block bleibt ebenfalls TCO-faehig, solange nur sein letztes Statement den rekursiven Ruecksprung enthaelt:
@tailrec
fn count(n, acc) -> {
if (n <= 0) then acc else count(n - 1, acc + 1)
}Und das hier ist bewusst ein Fehler, weil nach dem Self-Call noch Arbeit anfaellt:
@tailrec
fn bad(n) -> 1 + bad(n - 1)Der genauere Implementierungsstand fuer @tailrec, Blocks und try/catch
steht in specs/spec_tco_tail_positions.md; dort sind Phase A und der aktuelle
Phase-B-Schnitt dokumentiert.
Praktisches Lesemuster#
Wenn du eine Antra-Funktion liest, schau in dieser Reihenfolge:
- Welche Parameter-Patterns werden erwartet?
- Gibt es mehrere Klauseln?
- Gibt es Guards?
- Wird normal transformiert oder bewusst mutiert?
Das erklärt meist schon den größten Teil der Funktion, bevor du den Body überhaupt im Detail liest.
