Antra

Sprache

Mutationen und Updates: Deep-Set, Map-Update und mut

Dieses Kapitel bündelt die Stellen, an denen Antra Werte bewusst verändert — und die Alternativen, die stattdessen neue Werte bauen. Das Gegenstück zum Verändern, das Auseinanderziehen per Destructuring, steht jetzt bei den Patterns in Kapitel 07.

Deep-Set mit :=#

:= ist die bewusst tiefe Mutationsform:

my config = %{}
config.db.host := "localhost"
config.db.port := 5432

Regeln:

  • die Root-Bindung muss existieren
  • die Root-Bindung muss mutierbar sein
  • fehlende Zwischenstufen werden erzeugt
  • Array-Schritte und Map-Schritte können gemischt werden

Stilistisch gilt fuer User-Code jetzt die einfache Klammerregel:

  • obj.name fuer statische Properties
  • obj[key] fuer dynamische Keys und Index-Zugriffe
  • obj?.name, obj?.[key] und arr?[i] fuer sichere Varianten
  • obj[key] := value und arr[i] := value fuer Schreibzugriffe in Container
my m = %{}
m.items[0].name := "Ada"

String-Index-Zuweisung#

Ein einzelnes Zeichen eines Strings kannst du per Index setzen:

my s = "Felix"
s[0] = "K"
dd(s)          # "Kelix"

Weil Strings unveränderlich sind, ist das kein In-Place-Set, sondern ein Rebind der Variablen auf einen neuen String. Daraus folgen die Regeln:

  • die rechte Seite muss genau ein Zeichen sein (s[0] = "XY" ist ein Fehler)
  • der Index muss existieren; out-of-bounds ist ein Fehler (kein Auffüllen)
  • negative Indizes zählen von hinten (s[-1] = "!")
  • das Ziel muss mutierbar sein (my-Variable bzw. ein veränderbarer Container); val/const → Fehler
  • funktioniert auch für einen String in einem Container (obj.name[0] = …, xs[0][0] = …)

In Kombination mit einem mut-Parameter wirkt die Änderung zum Aufrufer zurück (siehe Kapitel 8, „mut bei Parametern").

Was := genau erzeugt#

:= darf entlang des Pfads fehlende Knoten anlegen. Das gilt nicht nur fuer fehlende Map-Felder, sondern auch fuer fehlende Array-Pfade und das eigentliche Ziel an der letzten Stelle.

Die praktische Heuristik ist einfach:

  • wenn der naechste Schritt wie ein Property- oder Key-Zugriff aussieht, wird eine Map angelegt
  • wenn der naechste Schritt wie ein Index-Zugriff aussieht, wird ein Array angelegt

Beispiele:

my data = %{}

data.user.name := "Ada"
data.users[0].name := "Bob"
data.users[0].tags[1] := :active
data.settings[:theme] := "light"

Dabei passiert sinngemaess Folgendes:

  • data.user wird als Map angelegt
  • data.users wird als Array angelegt
  • data.users[0] wird als Map angelegt
  • data.users[0].tags wird als Array angelegt
  • data.settings wird als Map angelegt

Entscheidend ist: du musst diese Zwischenstrukturen nicht vorher manuell bauen, solange die Root-Bindung selbst existiert und mutierbar ist.

Das Ziel am Ende darf ebenfalls vorher fehlen. Sowohl ein neuer Key als auch ein bisher nicht gesetzter Index koennen also durch := erstmals befuellt werden.

my cfg = %{}
cfg["host"] := "localhost"

my xs = []
xs[0] := 10

Die Root-Bindung selbst wird dabei nicht implizit neu deklariert. := braucht also weiterhin einen vorhandenen Namen links vom Pfad.

Auf const-Bindings ist := nicht erlaubt:

const cfg = %{ port: 3000 }
cfg.port := 8080   # Fehler

Unterschied zwischen = und :=#

user.name = "Ada"
user.profile.city := "Berlin"

Merke dir:

  • = schreibt nur in ein Ziel, das schon da ist
  • := navigiert einen Pfad und darf fehlende Map- oder Array-Knoten unterwegs erzeugen

Mehrere Namen auf einmal: [a, b] = ...#

Steht links von = ein Array aus Namen, bekommen alle zugleich einen neuen Wert. Die rechte Seite wird vorher vollständig ausgewertet — so tauscht man zwei Werte ohne Hilfsvariable:

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

my x = 0
my y = 1
for _i <- 1..10 { [x, y] = [y, x + y] }
println(x)                    # 55
  • Die Namen müssen schon gebunden und veränderlich sein (my); val/const bleiben unveränderlich, neue Namen legt die Form nicht an.
  • Rechts muss ein Array mit genau so vielen Werten stehen.
  • _ überspringt eine Position, [a, [b, _]] geht auch verschachtelt.
  • Nur Namen als Ziele: Properties und Indizes ([p.x, xs[0]] = ...) gehen einzeln mit =.

Map-Update ohne Mutation#

Wenn du keine in-place Mutation willst, nimm Map-Updates mit |:

my updated = %{user | age: 37}

Das ist näher an funktionalem Stil:

  • alter Wert bleibt erhalten
  • neuer Wert wird erzeugt

Auch Tagged Maps werden dabei sauber weitergetragen:

my updated = %{existing_user | age: 37}
my explicit = %User{raw_map | age: 37}

Warum gibt es beides?#

Weil beide Formen legitim sind, aber unterschiedliche Absichten ausdrücken:

  • Map-Update: "ich baue einen neuen Wert"
  • :=: "ich mutiere gezielt einen vorhandenen Wert"

Parameter binden per Default wie val#

Ein Funktionsparameter ist standardmäßig nicht reassignbar. Ein plainer Parameter verhält sich also wie val: du kannst ihn nicht neu zuweisen, den darunterliegenden Wert aber sehr wohl per Deep-Set verändern.

fn f(x) -> { x = 9 }        # Fehler: Parameter bindet wie val
fn f(xs) -> { xs[0] = 9 }   # ok: Deep-Set ist erlaubt (nur die Bindung ist fix)

Wenn du den Parameter im Körper wirklich reassignen willst, ist my der explizite Opt-out:

fn f(my x) -> { x = x + 1; x }   # ok: my macht die Bindung reassignbar

Das gilt auch für destrukturierende Parameter — my steht dann vor dem Pattern:

fn kw(my [a: c, b: ]) -> { c = 10 }   # c ist reassignbar

Kurz die vier Formen im Vergleich:

FormReassign (x = …)Deep-Set (x[..] = …)
x (Default) / val xFehlererlaubt
my xerlaubterlaubt
const xFehlerFehler (Wert eingefroren)
mut xerlaubt, wirkt auf den Aufrufer zurückerlaubt, wirkt zurück

Warum dieser Default? Er passt zur Grundhaltung dieses Kapitels — standardmäßig lieber neue Werte bauen, Veränderung nur dort, wo sie ausdrücklich gewollt ist. my, mut und := sind die sichtbaren Signale dafür.

mut in Funktionsparametern#

Wenn eine Funktion ihr Composite-Argument verändern soll, wird das explizit markiert:

fn touch(mut cfg) -> cfg.port := 8080

Das ist absichtlich sichtbarer als in vielen Mainstream-Sprachen.

val und const unterscheiden sich hier bewusst:

  • val darf an mut-Parameter uebergeben werden (Bindung ist fix, Wert aber mutierbar).
  • const darf nicht an mut-Parameter uebergeben werden (Wert ist eingefroren).
fn touch(mut cfg) -> cfg.port := 8080

val cfg1 = %{ port: 3000 }
touch(cfg1)     # erlaubt

const cfg2 = %{ port: 3000 }
touch(cfg2)     # Fehler

Wichtig: mut macht aus einem const-Argument kein mutierbares Argument. Das hier ist also ebenfalls ein Fehler:

fn touch(mut cfg) -> cfg.port := 8080

const cfg = %{ port: 3000 }
touch(cfg)   # Fehler

Als kurze Abgrenzung: mut ist nicht derselbe Modus wie on_use.

  • mut sagt: diese Funktion darf den uebergebenen Composite-Wert veraendern
  • on_use sagt: dieser Ausdruck soll erst spaeter im Funktionskoerper ausgewertet werden

Beispiel fuer on_use:

fn assert_error(on_use expr, needle) -> ...

on_use ist also kein Mutationsmarker, sondern ein Evaluationsmarker fuer Parameter. Beide Modi duerfen in v1 nicht auf demselben Parameter kombiniert werden.

as mut mit Patterns#

Oft willst du nicht nur mutieren, sondern gleichzeitig eine Form verlangen:

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

Das heißt:

  • der Parameter muss zu %User{..} passen
  • zusätzlich wird er als user gebunden
  • über diesen Alias darf in-place mutiert werden

Auch hier gilt: ein const-Wert darf nicht ueber as mut in einen mutierbaren Alias umgebogen werden.

Das funktioniert auch für Tagged Arrays und Tagged Maps.

Ist mut schneller als einen neuen Wert zurueckgeben?#

Meistens nicht. Die naheliegende Annahme ist, dass mut das Kopieren spart -- aber Antra kopiert Argumente ohnehin nie. Composites werden immer als Referenz uebergeben, mit oder ohne mut. Immutability wird nicht durch Kopieren erzwungen, sondern durch eine Registrierung eingefrorener Werte, die bei jeder Mutation befragt wird.

mut spart also nichts beim Uebergeben. Es kostet sogar etwas extra: einen Frozen-Check beim Aufruf und einen Copy-out-Durchlauf beim Verlassen der Funktion, der reassignte Parameter in die Aufrufer-Variable zurueckschreibt.

Was mut wirklich aendert, ist die Komplexitaet beim Akkumulieren. Ein xs ++ [v] baut jedes Mal ein frisches Array -- in einer Schleife wird daraus O(n^2). Array.push auf einem mut-Parameter haengt in-place an und bleibt O(n).

Gemessen an einer Schleife, die n Elemente sammelt:

nmut + push++ und neu zurueckgeben
2.0000,44 s0,28 s
8.0001,49 s1,38 s
16.0002,90 s3,72 s
32.0005,77 s11,40 s

Bis rund 10.000 Elementen gewinnt die immutable Variante, weil das Neubauen eines Arrays ein einziger schneller Kopiervorgang ist, waehrend jeder mut-Aufruf Frozen-Check, Dispatch und Copy-out bezahlt. Erst darueber schlaegt die Asymptotik durch.

Praktische Regel: nimm die immutable Variante, ausser du akkumulierst wirklich grosse Collections in einer Schleife. Und wenn du dich fuer mut entscheidest, dann wegen der Komplexitaet, nicht wegen ersparter Kopien -- die gab es nie.

Anfängerregel für Mutation#

Wenn du unsicher bist, fang mit dem einfacheren mentalen Modell an:

  • standardmäßig lieber neue Werte bauen
  • Mutation nur dort einsetzen, wo sie die API wirklich klarer macht
  • mut und := als Signal lesen: hier wird absichtlich verändert

So bleibt der Code leichter zu verstehen.

Referenz: := und Merge-Varianten#

Weitere Deep-Set-Fälle mit dynamischen Keys:

my k = "host"
config.db[k] := "staging.example.com"

my section = "db"
config[section].port := 3306

Und ebenso fuer gemischte Ketten:

my i = 0
config.users[i].profile["city"] := "Wien"

Hier gilt dieselbe Regel wie oben: users wird bei Bedarf als Array erzeugt, users[i] als Map, profile wieder als Map und das Feld "city" am Ende direkt gesetzt.

Map-Update ergänzt sich mit flachem und tiefem Merge über Spread:

my ext    = %{ ..base, z: 3 }
my merged = %{ ..base, ..ext }
my deep   = %{ ...nested1, ...nested2 }

Beide Formen tun Ähnliches, meinen aber Verschiedenes:

  • %{base | key: value} aktualisiert einen Wert. Genau eine Basis links vom | — %{a | b} ist ein Parse-Fehler — und der Tag der Basis bleibt erhalten. %{self | age: 31} in einer Package-Methode ist damit weiter ein %Person{…}. Die Basis darf ein Ausdruck sein: %{state.player | x: 0}.
  • %{..a, ..b} baut ein neues Literal aus beliebig vielen Quellen. Den Tag bestimmt hier die äußere Form: %Point{..a} ist getaggt, %{..a} nicht.

Als Spread-Quelle sind Maps und getaggte Maps erlaubt (bei letzteren werden die Daten gespreadet). Alles andere meldet sich deutlich:

Error: spread in a map literal requires a map or tagged map; got Array

Ausführlicher steht das im Collections-Kapitel unter Spread und Deep Merge.

Quelle dieser Seite