Struktur und Typen
Dynamisches Scoping mit given und requires
Manche Werte gehören nicht wirklich zu einer Funktion, sondern zu der Situation, in der sie läuft: die Trace-ID des gerade bearbeiteten Requests, das Theme beim Rendern, der Logger im Test. Solche Werte reicht man normalerweise durch jede Zwischenebene durch — auch durch die, die sie gar nicht benutzen.
given und requires sind Antras Antwort darauf. given bindet einen Wert für
die Dauer eines Blocks, requires holt ihn im Aufgerufenen wieder ab. Dazwischen
liegt beliebig viel Code, der von alledem nichts wissen muss.
Der Einstieg#
Das Problem: Durchreichen#
Ein Webserver bearbeitet einen Request. Jede Log-Zeile soll die Trace-ID tragen, damit man später zusammensuchen kann, was zu welchem Request gehörte. Die Log-Funktion sitzt aber ganz unten im Aufrufstapel:
fn log(trace_id, message) -> println("[" <> trace_id <> "] " <> message)
fn store_item(trace_id, item) -> log(trace_id, "stored " <> item)
fn process_order(trace_id, items) -> {
for item <- items {
store_item(trace_id, item)
}
}
fn handle_request(id, items) -> process_order(id, items)
handle_request("req-1", ["book", "pen"])process_order und store_item benutzen die trace_id nicht — sie schleppen
sie nur weiter. Kommt morgen der angemeldete Benutzer dazu, ändern sich wieder
alle Signaturen. Das ist Parameter-Drilling, und es wird mit jeder Ebene
schlimmer.
Die Lösung in fünf Zeilen#
fn log(message) -> {
requires trace_id
println("[" <> trace_id <> "] " <> message)
}
fn store_item(item) -> log("stored " <> item)
fn process_order(items) -> {
for item <- items {
store_item(item)
}
}
fn handle_request(id, items) -> {
given trace_id = id {
process_order(items)
}
}
handle_request("req-1", ["book", "pen"])Die Ausgabe ist dieselbe, aber die Signaturen sind sauber:
[req-1] stored book
[req-1] stored penhandle_request stellt den Wert bereit, log fordert ihn an. process_order
und store_item wissen nichts davon — und müssen auch nichts ändern, wenn
später ein zweiter Kontextwert dazukommt.
Die Kurzversion#
given name = wert { ... }bindetnamefür die Dauer des Blocks — auch für alles, was daraus aufgerufen wird.requires nameganz oben im Funktionsrumpf holt den Wert ab.- Fehlt die Bindung, ist das ein Laufzeitfehler.
requires name = defaultmacht sie optional. - Beide binden wie
val.constist erlaubt,myundmutnicht. - Verschachtelte
givenüberschreiben nur für ihren Unterbaum. antra --checkprüft, ob jede Anforderung auch bedient wird.
Die beiden Schlüsselwörter#
given: einen Wert bereitstellen#
given theme = "dark" {
println(theme)
}Mehrere Bindungen auf einmal, durch Komma getrennt. Spätere sehen die früheren:
given width = 80, half = width / 2 {
println(half)
}given ist immer eine Anweisung mit Block — es gibt keine then-Kurzform
und keine Ausdrucksform. Das ist Absicht: eine Konstruktion, deren Wirkung so
weit reicht, soll sichtbar eine Klammer aufmachen.
Innerhalb seines eigenen Blocks ist der Name auch ganz normal lesbar (wie oben im ersten Beispiel). Außerhalb existiert er nicht:
given theme = "dark" {
println(theme) # "dark"
}
# println(theme) # Fehler: theme ist hier nicht definiertrequires: einen Wert anfordern#
requires steht im Prolog einer benannten Funktion — vor der ersten anderen
Anweisung:
fn render_card(title) -> {
requires theme
println("<" <> theme <> "> " <> title)
}
given theme = "dark" {
render_card("Willkommen")
}Warum nur ganz oben? Weil requires Teil des Vertrags ist, so wie die
Parameterliste. Stünde es mitten im Rumpf, wäre die Anforderung an eine
Bedingung geknüpft, und weder Leser noch Prüfung könnten sie sinnvoll
weiterverfolgen. Aus demselben Grund geht es nicht in Lambdas: die haben keinen
Namen, über den man den Vertrag weiterreichen könnte.
Der Wert wird einmal beim Betreten der Funktion aufgelöst. Innerhalb des
Rumpfes ist der Name danach ein ganz gewöhnlicher, stabiler Wert — ein given,
das tiefer im Rumpf geöffnet wird, wirkt nur noch auf die Aufrufe von dort.
Fehlt die Bindung, gibt es einen klaren Laufzeitfehler:
Error: dynamic binding 'theme' is required by 'render_card' but not boundOptionale Anforderungen mit Default#
Ein Default macht die Anforderung optional. Der Ausdruck wird nur ausgewertet, wenn wirklich nichts gebunden ist:
fn log(message) -> {
requires level = "info"
println("[" <> level <> "] " <> message)
}
log("ohne Kontext")
given level = "debug" {
log("mit Kontext")
}[info] ohne Kontext
[debug] mit KontextDas ist der richtige Weg für alles, was einen vernünftigen Normalfall hat: Log-Level, Einrücktiefe, Farbe an/aus. Für alles, was ohne Kontext schlicht falsch wäre — eine Trace-ID, eine Datenbank-Transaktion — lässt man den Default weg und nimmt den harten Fehler in Kauf.
Wie lange gilt eine Bindung?#
Dynamische Ausdehnung statt Textbereich#
Das ist der eine Punkt, der given von allem anderen in Antra unterscheidet.
Eine gewöhnliche Bindung gilt in dem Textbereich, in dem sie steht — man sieht
ihre Reichweite. Eine given-Bindung gilt für die Zeit, in der der Block
läuft, also auch in Funktionen, die woanders im Programm stehen:
fn tief() -> {
requires marke
println("tief drin sehe ich: " <> marke)
}
fn mitte() -> tief()
given marke = "von ganz oben" {
mitte()
}tief steht nirgends im Text unterhalb des given-Blocks — trotzdem sieht es
den Wert, weil es während des Blocks läuft.
Verschachteln und überschreiben#
Ein inneres given überschreibt einen Namen nur für seinen eigenen Unterbaum.
Alle anderen Namen bleiben, wie sie waren:
fn zeile(text) -> {
requires theme
requires breite
println("<" <> theme <> "/" <> str(breite) <> "> " <> text)
}
given theme = "dark", breite = 80 {
zeile("oben")
given theme = "light" {
zeile("hervorgehoben") # theme neu, breite geerbt
}
zeile("unten") # wieder dark
}<dark/80> oben
<light/80> hervorgehoben
<dark/80> untenGenau dafür ist die Konstruktion gemacht: eine Einstellung für einen begrenzten Bereich ändern und garantiert wieder auf dem alten Stand landen.
Aufgeräumt wird auf jedem Weg#
Die Bindung verschwindet, sobald der Block verlassen wird — egal wie. Normales
Blockende, return, break, ein geworfener Fehler, Fehler-Propagation mit ?
oder !:
fn braucht_kontext() -> {
requires ctx
ctx
}
fn mit_fehler() -> {
given ctx = "innen" {
throw "etwas ging schief"
}
}
try {
mit_fehler()
} catch {
e -> println("gefangen: " <> e)
}
try {
println(braucht_kontext())
} catch {
e -> println("danach ist ctx wieder ungebunden")
}gefangen: etwas ging schief
danach ist ctx wieder ungebundenDas ist der handfeste Vorteil gegenüber einer selbstgebauten Lösung mit einer veränderlichen Modulvariablen: dort muss man das Zurücksetzen selbst schreiben, und spätestens beim ersten Fehlerpfad vergisst man es.
Bindung und Veränderbarkeit#
val ist der Default#
Beide Seiten binden wie val: der Name ist fest, der Wert selbst nicht
eingefroren. Ein Reassign ist ein Fehler.
fn f() -> {
requires theme
# theme = "andere" # Fehler: Cannot reassign immutable variable 'theme'
theme
}
given theme = "dark" {
println(f())
}const für geteilte Werte#
Ist der Wert ein Array, eine Map oder ein Objekt, teilen sich alle Leser
dieselbe Referenz. Ein settings[:x] = 1 in irgendeiner Funktion weiter unten
wäre also für alle sichtbar — fachlich fast immer ein Fehler. given const
friert den Wert ein und macht daraus einen Laufzeitfehler:
fn lies(key) -> {
requires settings
settings[key]
}
fn schreib_versuch() -> {
requires settings
try {
settings[:retries] = 99
println("das sollte nicht klappen")
} catch {
e -> println("abgelehnt: der Kontext ist eingefroren")
}
}
given const settings = %{retries: 3, timeout: 30} {
println(str(lies(:retries)))
schreib_versuch()
}3
abgelehnt: der Kontext ist eingefrorenFaustregel: Skalare ohne const, Container mit.
requires const name friert den Wert von der Empfängerseite aus ein — nützlich,
wenn eine einzelne Funktion sichergehen will, dass sie selbst nichts kaputt
macht.
Warum my und mut verboten sind#
given my x = 1 und requires my x sind Parse-Fehler, ebenso mit mut. Die
Gründe sind verschieden:
mybeimgivenhieße: der Wert ließe sich mitten im Block ändern. Dann hinge das, was ein Aufgerufener sieht, davon ab, wie weit der Block gerade gelaufen ist — genau die unangenehmste Eigenschaft von globalem Zustand. Wer wechseln will, öffnet ein zweitesgiven; das ist das Overrule-Idiom von oben.mybeimrequireswürde suggerieren, man verändere den Kontext. Tatsächlich wäre die Wirkung rein lokal und für den nächsten Leser irreführend. Wer eine veränderliche Kopie braucht, schreibt sie hin:requires themeund daruntermy t = theme.mutist ein Parameter-Modus mit Rückschreiben in ein Argument des Aufrufers. Eingivenhat keinen Aufrufer und kein Ziel zum Zurückschreiben.
Aus demselben Grund kann ein requires-Name nicht als Argument für einen
mut-Parameter dienen — die Prüfung meldet das als mut-dynamic-arg.
Ein eigener Namensraum#
Dynamische Namen leben getrennt von den gewöhnlichen. Ein given theme
überschreibt also kein lexikalisches theme, und wer given/requires nicht
benutzt, merkt von alledem nichts:
fn zeigt_dynamisch() -> {
requires theme
theme
}
my theme = "das ist meine lokale Variable"
given theme = "das ist der dynamische Wert" {
println(theme) # der dynamische - im eigenen Block sichtbar
println(zeigt_dynamisch()) # ebenfalls der dynamische
}
println(theme) # wieder die lokale VariableEin Zugriff auf einen dynamischen Namen ohne requires ist ein ganz normales
„nicht definiert" — es gibt keinen stillen Durchgriff.
Die Namen sind flach und global: theme bezeichnet überall dasselbe, egal
aus welchem Paket. Das ist der Zweck der Sache — zwei weit entfernte Stellen
sollen sich ohne gemeinsame Signatur treffen. Für Kontext, den eine Bibliothek
anbietet, empfiehlt sich deshalb ein Präfix (logger_sink statt sink), damit
sich zwei Bibliotheken nicht versehentlich denselben Namen greifen.
Die Grenzen der Ausdehnung#
Closures entkommen dem Block#
Die Bindung gilt für die Zeit des Blocks. Eine Lambda, die im Block erzeugt, aber später aufgerufen wird, läuft außerhalb dieser Zeit:
fn braucht() -> {
requires theme
theme
}
my f = undef
given theme = "dark" {
f = || -> braucht() # nur erzeugt, nicht aufgerufen
}
try {
println(f())
} catch {
e -> println("zu spät: die Bindung ist längst weg")
}Das ist bei jedem dynamischen Scoping so und trotzdem überraschend. Der Ausweg ist ein bewusster Schnappschuss — Wert einfangen, beim Aufruf neu binden:
fn braucht() -> {
requires theme
theme
}
my f = undef
given theme = "dark" {
val t = theme
f = || -> {
given theme = t {
braucht()
}
}
}
println(f()) # "dark"Generatoren nehmen ihren Kontext mit#
Ein yield gibt die Kontrolle aus dem Block heraus an den Konsumenten zurück.
Damit sich beide Seiten nicht gegenseitig ihren Kontext unterschieben, gilt: der
Generator nimmt seinen eigenen Kontext mit über das yield hinweg, und der
Konsument bleibt unbeteiligt.
fn braucht() -> {
requires theme
theme
}
gen fn stuecke() -> {
given theme = "generator" {
yield braucht()
yield braucht()
}
}
given theme = "konsument" {
for x <- stuecke() {
println(x)
}
println(braucht())
}generator
generator
konsumentDer Generator verhält sich also so, als wäre er nie unterbrochen worden.
fork erbt eine Kopie#
Ein mit System.fork(...) gestarteter Kindprozess erbt den Kontext seines
Elternprozesses — als Kopie, wie alles andere beim fork auch. Was das Kind
danach bindet, sieht der Elternprozess nicht.
Was die statische Prüfung leistet#
requires existiert nicht nur für den Leser. Es ist der Vertrag, den
antra --check überprüft — ohne ihn wäre given nur ein hübsch verpackter
globaler Zustand.
Still nach oben, gemeldet am Ende#
Die Prüfung rechnet für jede Funktion aus, was sie und alles unter ihr an
Kontext braucht, und zieht ab, was ein umschließendes given bereitstellt.
Entscheidend ist, wo sie meldet. Nicht an jeder Aufrufstelle — sonst müsste jede Zwischenschicht doch wieder eine Zeile tragen, die sie nie benutzt, und man hätte das Durchreichen nur umbenannt. Stattdessen wandert die Anforderung still nach oben, und gemeldet wird dort, wo die Kette endet:
fn log(m) -> {
requires trace_id
println("[" <> trace_id <> "] " <> m)
}
fn store(i) -> log("stored " <> str(i))
given trace_id = "req-1" {
store(1)
}Das ist sauber. Fehlt das given, meldet antra --check:
warning: 'trace_id' is required here but no enclosing 'given' binds it
(required by 'log', reached through 'store')
8 | store(1)
^Die Meldung nennt den Weg, weil die Fundstelle bewusst weit von der Ursache
liegt. Kettenenden sind Aufrufe auf oberster Ebene und die mit @main markierte
Einstiegsfunktion.
Die Grenze zum Paket#
An einer pub fn hört die stille Weitergabe auf. Was eine öffentliche Funktion
an Kontext nach draußen trägt, muss sie deklarieren:
package Orders {
fn log(m) -> {
requires trace_id
println("[" <> trace_id <> "] " <> m)
}
pub fn handle(x) -> {
requires trace_id # Pflicht: die Anforderung verlässt die Datei
log(str(x))
}
}Ohne diese Zeile meldet die Prüfung undeclared-public-requirement. Der Grund
ist handfest: beim Prüfen einer fremden Datei liest Antra nur die Signaturen
des importierten Pakets, nicht dessen Aufrufketten. Nur was deklariert ist, kann
also über die Dateigrenze mitgeteilt werden — und nur dann kann die Prüfung beim
Aufrufer greifen. Eine andere Datei, die das Paket benutzt:
import Ui::Card as Card
Card.render("boom")warning: 'theme' is required here but no enclosing 'given' binds it
(required by 'Card.render')
3 | Card.render("boom")
^Kurz: innen abgeleitet, nach außen deklariert. Die Zeile steht genau dort, wo sie API ist, und nirgends sonst.
Die Meldungen im Überblick#
| Code | Schwere | Bedeutet |
|---|---|---|
mut-dynamic-arg | Fehler | Ein requires-Name als Argument eines mut-Parameters — er ist keine Variable, in die zurückgeschrieben werden könnte |
unsatisfied-dynamic | Warnung | Eine Anforderung erreicht das Ende ihrer Kette, ohne dass ein given sie bindet |
undeclared-public-requirement | Warnung | Eine pub fn trägt eine Anforderung aus der Datei, ohne sie zu deklarieren |
unused-given | Warnung | given x = ..., aber weder der Block noch ein erreichbarer Aufgerufener fragt nach x |
unused-requires | Warnung | requires x in einer nicht-öffentlichen Funktion, die x weder liest noch weitergibt |
redundant-given | Warnung | Derselbe Wert, den ein umschließendes given schon bindet |
Wie alle Diagnosen lassen sie sich mit # ignore: <code> oder
@ignore_warning(<code>) gezielt stummschalten — siehe
Kapitel 17.
unused-given ist übrigens die Meldung, die einen Tippfehler im Namen fängt:
benennt man ein requires um und vergisst das zugehörige given, steht dort
plötzlich eine Bindung, die niemand mehr abholt.
Was die Prüfung nicht sieht#
Die Analyse ist bewusst zurückhaltend: im Zweifel schweigt sie, statt zu raten. Kein Ergebnis liefern
- Aufrufe über eine Variable (
my f = logund dannf(...)), - Lambda-Rümpfe, weil ihr Aufrufzeitpunkt offen ist,
- Protokoll-Dispatch und rein native Pakete (
Antra::Native::*), - Funktionen, die in ihrer Datei nirgends aufgerufen werden — sie könnten über einen Callback erreicht werden.
Deshalb ist unsatisfied-dynamic eine Warnung und kein Fehler. Die letzte
Instanz bleibt die Laufzeit, und die meldet zuverlässig.
Wann given — und wann nicht#
Die Faustregel#
Ein Wert gehört in einen Parameter, wenn die Funktion ohne ihn fachlich
sinnlos ist: render_user(user). Ein Wert gehört in ein given, wenn er den
Modus der Ausführung beschreibt und die Zwischenschichten ihn nur
weiterreichen würden.
Anders gefragt: Würde ich diesen Parameter in einer Zwischenfunktion als Rauschen empfinden? Wenn ja, ist es Kontext.
Gute Fälle#
- Request-Kontext: Trace-ID, angemeldeter Benutzer, Mandant.
- Darstellung: Theme, Sprache, Einrücktiefe eines Pretty-Printers, Farbe an oder aus für die Terminalausgabe.
- Austauschbare Effekte: Logger, Ausgabe-Handle, Zeitquelle. Besonders im
Test:
given clock = feste_zeit() { ... }macht eine ganze Aufrufkette deterministisch, ohne dass eine einzige Signatur eine Uhr trägt. - Temporäres Umschalten: für einen begrenzten Bereich lauter loggen, strenger validieren, anders rendern.
Schlechte Fälle#
- Fachliche Daten. Die Bestellung, der Benutzer, das Dokument — das sind
Parameter. Wer sie über
givenschiebt, macht den Datenfluss unsichtbar. - Rückgabewerte.
givenreicht nur nach unten. Ergebnisse kommen als Rückgabewert zurück. - Als Ersatz für einen fehlenden Entwurf. Wenn drei Ebenen dieselben fünf Werte brauchen, ist die Frage vielleicht eher, ob dort nicht ein Objekt fehlt.
Abgrenzung zu den Nachbarn#
| Form | Sichtbar für | Lebensdauer | Vertrag |
|---|---|---|---|
val x = ... | den umschließenden Textbereich | der Block, in dem es steht | die Deklaration selbst |
| Parameter | genau die aufgerufene Funktion | der Aufruf | die Signatur |
given/requires | alles, was aus dem Block heraus läuft | die Zeit des Blocks | das requires im Callee |
| Closure-Capture | die Closure | solange die Closure lebt | — |
Kurzreferenz#
given name = wert { ... } # bindet für die Dauer des Blocks
given a = 1, b = a + 1 { ... } # mehrere; spätere sehen frühere
given const cfg = %{a: 1} { ... } # Wert zusätzlich einfrieren
fn f() -> {
requires name # Pflicht - sonst Laufzeitfehler
requires level = "info" # optional, mit Fallback
requires const cfg # zusätzlich einfrieren
...
}Nicht erlaubt (Parse-Fehler):
given my x = 1 { } given mut x = 1 { } given val x = 1 { }
requires my x requires mut x requires x: String
given x = 1 then f() requires mitten im Rumpf requires in einer LambdaVerwandte Kapitel: Kapitel 08 zu Parametern und
Binding-Modifiern, Kapitel 17 zur
statischen Prüfung, Kapitel 10 zu const und
eingefrorenen Werten.
