Antra

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 pen

handle_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 { ... } bindet name für die Dauer des Blocks — auch für alles, was daraus aufgerufen wird.
  • requires name ganz oben im Funktionsrumpf holt den Wert ab.
  • Fehlt die Bindung, ist das ein Laufzeitfehler. requires name = default macht sie optional.
  • Beide binden wie val. const ist erlaubt, my und mut nicht.
  • Verschachtelte given überschreiben nur für ihren Unterbaum.
  • antra --check prü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 definiert

requires: 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 bound

Optionale 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 Kontext

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

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

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

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

  • my beim given hieß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 zweites given; das ist das Overrule-Idiom von oben.
  • my beim requires wü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 theme und darunter my t = theme.
  • mut ist ein Parameter-Modus mit Rückschreiben in ein Argument des Aufrufers. Ein given hat 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 Variable

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

Der 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#

CodeSchwereBedeutet
mut-dynamic-argFehlerEin requires-Name als Argument eines mut-Parameters — er ist keine Variable, in die zurückgeschrieben werden könnte
unsatisfied-dynamicWarnungEine Anforderung erreicht das Ende ihrer Kette, ohne dass ein given sie bindet
undeclared-public-requirementWarnungEine pub fn trägt eine Anforderung aus der Datei, ohne sie zu deklarieren
unused-givenWarnunggiven x = ..., aber weder der Block noch ein erreichbarer Aufgerufener fragt nach x
unused-requiresWarnungrequires x in einer nicht-öffentlichen Funktion, die x weder liest noch weitergibt
redundant-givenWarnungDerselbe 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 = log und dann f(...)),
  • 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 given schiebt, macht den Datenfluss unsichtbar.
  • Rückgabewerte. given reicht 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#

FormSichtbar fürLebensdauerVertrag
val x = ...den umschließenden Textbereichder Block, in dem es stehtdie Deklaration selbst
Parametergenau die aufgerufene Funktionder Aufrufdie Signatur
given/requiresalles, was aus dem Block heraus läuftdie Zeit des Blocksdas requires im Callee
Closure-Capturedie Closuresolange 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 Lambda

Verwandte Kapitel: Kapitel 08 zu Parametern und Binding-Modifiern, Kapitel 17 zur statischen Prüfung, Kapitel 10 zu const und eingefrorenen Werten.

Quelle dieser Seite