Antra

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 + b

Wichtig 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:

  1. Passen die Parameter-Patterns? Wenn nein, weiter zur naechsten Klausel.
  2. 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 erreicht

Zwei 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 false

Das 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))    # 12

Drei 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))    # 120

Sie 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))    # 13

Fuer 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/11

Die 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     # FelixFELIX

Wann der Default ausgewertet wird#

Zwei Punkte, die zusammen die meisten Ueberraschungen erklaeren:

  1. Nur wenn das Argument fehlt. Wird der Parameter uebergeben, wird der Default-Ausdruck gar nicht erst ausgefuehrt.
  2. 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 erneut

Fuer 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 ab

  • const-Bindungen und Modulkonstanten:

    const LIMIT = 10
    fn take_n(n = LIMIT) -> n
  • Funktionen, 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 weglassen

test_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 11

Dasselbe 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 Default

Die 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 + y

Weitere 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)      # true

Umgekehrt 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))     # 6

Zwei Feinheiten:

  • Bei einem Keyword-Array-Parameter zeigt _params den uebergebenen Wert, nicht die materialisierten Optionen. Die Defaults landen im as-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 names

Das 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 error

Ausserdem 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:

ModifierBindungWert
(kein) / valfestmutierbar (Deep-Set ok)
myneu zuweisbarmutierbar
constfesteingefroren
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 := 31

Damit 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 eine val-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 eine val/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ückschreiben

Das 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ückschreiben

Auch 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 fuer mut erlaubt.
  • const-Bindings bleiben fuer mut verboten.
fn touch(mut user) -> user.age := 31

val user1 = %{ age: 30 }
touch(user1)     # erlaubt

const user2 = %{ age: 30 }
touch(user2)     # Fehler

Das 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)   # Fehler

Besonders interessant wird das mit Patterns:

fn enrich(%User{..} as mut user) -> user.id := 42

Die 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_use ist die explizite Ausnahme
  • ein on_use-Ausdruck kann bei mehrfacher Nutzung auch mehrfach ausgewertet werden
  • on_use und mut duerfen 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 = || -> 42

Sie eignen sich gut für:

  • map
  • filter
  • 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))       # 8

Das 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 Zaehler

Beachte 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())   # 42

Genau 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:

&str

ist 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:

  • add ist die Funktion selbst
  • my f = add speichert den echten Funktionswert
  • &add ist 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 + _2

Fuer direkte Pipe-Ziele brauchst du keine dieser Formen — dort genuegt der Name:

value |> println
items |> foreach &print_item

Pipes 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)
|> println

Das 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(...) oder value |> fn_name(...)
  • einfacher Identifier wie value |> f, wenn f zur 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))    # 11

Konzeptionell 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 weiter f(left, a, b)
  • ist die erste Zielposition on_use, bleibt left bis zur Nutzung im Funktionskoerper unevaluiert
  • ist on_use erst 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 |> c heute 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      # -> 42

Richtungen 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   # eindeutig

Ein |> 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 werden

Bei 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 @tailrec muessen 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, in match, im letzten Statement eines Blocks und im try- sowie im ausgewaehlten catch-Body
  • nicht tailig sind weiter Formen wie 1 + loop(...), ein frueher rekursiver Call vor spaeterer Arbeit im selben Block oder ein rekursiver Call innerhalb eines catch-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:

  1. Welche Parameter-Patterns werden erwartet?
  2. Gibt es mehrere Klauseln?
  3. Gibt es Guards?
  4. 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.

Quelle dieser Seite