Paths, .field, hoisting, and (std hashset)

- (.field value), and get, try-get, set and update for reading and copying
  along a path of fields and keys, with ref-set in the generic operations.
- please-hoist-i-promise-i-am-not-naughty, in the macros section.
- (std set) is (std hashset), and the collector is hash-setting.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-4-5)

Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-03 15:03:56 +02:00
parent 1fe22a4dfd
commit 48771a225a
3 changed files with 85 additions and 11 deletions

View file

@ -559,6 +559,52 @@ Records are updated by copying, with @code{record-set}:
(record-ref ada tricks) ;; still 0 (record-ref ada tricks) ;; still 0
}| }|
A field can also be read with @code{(.field value)}, which is the same as @code{record-ref}. On its own, @code{.field} is a function, so it can be passed around:
@codeblock[#:lang "bjolang"]|{
(.tricks ada) ;; 0
(list-map .tricks (list ada (won-trick ada))) ;; '(0 1)
}|
@subsubsection{Reading and changing nested data}
Data is often a record holding a map holding a vec of records. Reading something deep inside it, or making a copy with one thing changed deep inside it, is a lot of @code{record-ref} and @code{record-set} by hand. The prelude has four macros for it instead. Each takes a value and a @em{path}: a list of steps, where a step written @code{.field} is a field, and any other step is a key into a map or an index into a vec.
@codeblock[#:lang "bjolang"]|{
(type
(: Player (Record (: name string) (: score int)))
(: Team (Record (: players (Vec Player)) (: tags (Map string int)))))
(def team (Team (players [(Player (name "Ada") (score 3))
(Player (name "Bo") (score 5))])
(tags #map(("fruit" 1)))))
(get team .players 1 .name) ;; "Bo"
(try-get team .tags "veg") ;; None
(set team .players 0 .name "Bea") ;; a new Team; team is unchanged
(update team .players 1 .score (fun (s) (+ s 10))) ;; Bo has 15 in the new one
}|
@read-table{
| Form | Does |
|---------------------------------------+------------------------------------------------------------------------------|
| @code{(get value step ...)} | reads along the path, and throws if a key is missing |
| @code{(try-get value step ...)} | the same as an @code{Option}: @code{None} at the first key that is missing |
| @code{(set value step ... new)} | a copy of @code{value} with @code{new} at the end of the path |
| @code{(update value step ... f)} | a copy of @code{value} with @code{(f old)} at the end of the path |
}
A key is read with @code{ref} and written with @code{ref-set}, so the path can go through anything that implements the traits behind them: @code{Refable} to read, and @code{Settable} to write, which @code{Vec} and @code{Map} do. A field step can read a .NET property too, as in @code{(get team .players 0 .name .-Length)}, but only a Bjolang record or struct can be copied with a field changed, so @code{set} and @code{update} refuse one.
Some details worth knowing:
@itemlist{
@item{@code{set} does not read the last key, so setting a key a map does not have adds it. A key that is missing earlier in the path throws, as @code{ref} does.}
@item{@code{try-get} gives @code{None} for a missing map key, but a vec index out of range is still an error, as it is for @code{try-ref}. The keys after a missing one are not evaluated.}
@item{@code{set} and @code{update} evaluate the value, then each key from left to right, then the new value or the function, each of them exactly once.}
@item{These are macros, so a step is a field or a key by how it is written, not by its type: @code{.name} is always a field, and anything else is always a key.}
}
@subsection{Pattern matching} @subsection{Pattern matching}
Pattern matching is how you take values apart in bjolang. There is no @code{car}/@code{cdr} style of programming on unions: you say what shape you expect and the compiler binds the parts for you, and checks that you did not forget a shape. Pattern matching is how you take values apart in bjolang. There is no @code{car}/@code{cdr} style of programming on unions: you say what shape you expect and the compiler binds the parts for you, and checks that you did not forget a shape.
@ -1043,7 +1089,7 @@ Bjolang's collections are immutable by default. Adding to a vec or setting a key
| @code{(Vec %a)} | @code{[1 2 3]} | an indexable vector (an RRB tree), cheap everywhere | | @code{(Vec %a)} | @code{[1 2 3]} | an indexable vector (an RRB tree), cheap everywhere |
| @code{(Array %a)} | @code{#[1 2 3]} | a mutable .NET array of fixed length | | @code{(Array %a)} | @code{#[1 2 3]} | a mutable .NET array of fixed length |
| @code{(Map %k %v)} | @code{#map(("a" 1) ("b" 2))} | a hash map (a CHAMP trie) | | @code{(Map %k %v)} | @code{#map(("a" 1) ("b" 2))} | a hash map (a CHAMP trie) |
| @code{(Set %a)} | | a hash set, in @code{(std set)} | | @code{(HashSet %a)} | | a hash set, in @code{(std hashset)} |
| @code{(OrderedMap %k %v)} | | a sorted map (a B-tree), in @code{(std orderedmap)} | | @code{(OrderedMap %k %v)} | | a sorted map (a B-tree), in @code{(std orderedmap)} |
| @code{(OrderedSet %a)} | | a sorted set, in @code{(std orderedset)} | | @code{(OrderedSet %a)} | | a sorted set, in @code{(std orderedset)} |
} }
@ -1146,13 +1192,13 @@ When a map is walked as a collection, for example in a @code{loop} or with @code
Sets and the sorted collections are libraries, so they have to be imported. Their functions are named like the map functions, with their own prefix: Sets and the sorted collections are libraries, so they have to be imported. Their functions are named like the map functions, with their own prefix:
@codeblock[#:lang "bjolang"]|{ @codeblock[#:lang "bjolang"]|{
(import (std set)) (import (std hashset))
(import (std orderedmap)) (import (std orderedmap))
(def s (list->set (list 3 1 4 1 5 9 2 6 5))) (def s (list->hashset (list 3 1 4 1 5 9 2 6 5)))
(set-length s) ;; 7 (hashset-length s) ;; 7
(set-contains? s 4) ;; #t (hashset-contains? s 4) ;; #t
(set-contains? (set-add s 7) 7) ;; #t (hashset-contains? (hashset-add s 7) 7) ;; #t
(def prices (orderedmap-set (def prices (orderedmap-set
(orderedmap-set (orderedmap-set
@ -1202,6 +1248,7 @@ Many operations are traits, so they work on every collection that implements the
| @code{(length c)} @code{(empty? c)} | lists, vecs, arrays, maps, strings | size, and whether it is empty | | @code{(length c)} @code{(empty? c)} | lists, vecs, arrays, maps, strings | size, and whether it is empty |
| @code{(ref c key)} | vecs, arrays, maps, strings | the element at an index or key; throws | | @code{(ref c key)} | vecs, arrays, maps, strings | the element at an index or key; throws |
| @code{(try-ref c key)} | the same | the element as an @code{Option} | | @code{(try-ref c key)} | the same | the element as an @code{Option} |
| @code{(ref-set c key x)} | vecs, maps | a copy with @code{x} at the key |
| @code{(add c x)} @code{(add-all c xs)} | vecs, maps, sets | a new collection with more in it | | @code{(add c x)} @code{(add-all c xs)} | vecs, maps, sets | a new collection with more in it |
| @code{(add! b x)} @code{(add-all! b xs)} | builders and transients | put things in, in place | | @code{(add! b x)} @code{(add-all! b xs)} | builders and transients | put things in, in place |
| @code{(fold f init c)} | anything you can loop over | a left fold | | @code{(fold f init c)} | anything you can loop over | a left fold |
@ -1214,7 +1261,7 @@ Many operations are traits, so they work on every collection that implements the
(ref [10 20 30] 1) ;; 20 (ref [10 20 30] 1) ;; 20
(try-ref #map((:a 1)) :b) ;; None (try-ref #map((:a 1)) :b) ;; None
(add #map(("a" 1)) ("b" . 2)) ;; for a map, the element is a pair (add #map(("a" 1)) ("b" . 2)) ;; for a map, the element is a pair
(add-all (set-empty) [1 1 2 2 3]) ;; a set of three (add-all (hashset-empty) [1 1 2 2 3]) ;; a set of three
(fold + 0 [1 2 3 4]) ;; 10 (fold + 0 [1 2 3 4]) ;; 10
(find #(> & 3) (list 1 5 7)) ;; (Some 5) (find #(> & 3) (list 1 5 7)) ;; (Some 5)
@ -1347,7 +1394,7 @@ An accumulator names a collector and what to feed it:
| @code{(listing x)} | a @code{List}, in order | | @code{(listing x)} | a @code{List}, in order |
| @code{(vecing x)} | a @code{Vec} | | @code{(vecing x)} | a @code{Vec} |
| @code{(mapping (k . v))} | a @code{Map}, from pairs | | @code{(mapping (k . v))} | a @code{Map}, from pairs |
| @code{(setting x)} | a @code{Set}, from @code{(std set)} | | @code{(hash-setting x)} | a @code{HashSet}, from @code{(std hashset)} |
| @code{(stringing c)} | a @code{string}, from characters | | @code{(stringing c)} | a @code{string}, from characters |
| @code{(summing x)} | the sum of @code{x} | | @code{(summing x)} | the sum of @code{x} |
| @code{(counting x)} | how many rounds reached it | | @code{(counting x)} | how many rounds reached it |

View file

@ -97,7 +97,7 @@ A list-shaped module path such as @code{(std random)} refers to a standard libra
| Module | What it has | | Module | What it has |
|---------------------------------+------------------------------------------------------| |---------------------------------+------------------------------------------------------|
| @code{(std random)} | random numbers, @code{shuffle-vec} | | @code{(std random)} | random numbers, @code{shuffle-vec} |
| @code{(std set)} | sets; @code{(std orderedset)}, @code{(std orderedmap)} | | @code{(std hashset)} | sets; @code{(std orderedset)}, @code{(std orderedmap)} |
| @code{(std ports)} | reading a whole port: @code{port->lines} and friends | | @code{(std ports)} | reading a whole port: @code{port->lines} and friends |
| @code{(std fmt)} | text layout | | @code{(std fmt)} | text layout |
| @code{(std rx)} | regular expressions | | @code{(std rx)} | regular expressions |
@ -178,7 +178,7 @@ Imports can be wrapped in modifiers to filter which definitions are brought into
0) 0)
}| }|
Modifiers can be nested and evaluate from the inside out. For example, @code{(prefix (except (std set) set-map) "s/")} excludes @code{set-map} and prefixes all remaining names with @code{s/}. Modifiers can be nested and evaluate from the inside out. For example, @code{(prefix (except (std hashset) hashset-map) "s/")} excludes @code{hashset-map} and prefixes all remaining names with @code{s/}.
Note that @code{only} and @code{except} apply exclusively to functions and macros, not types. A module's exported types are always imported, ensuring that signatures referencing those types remain valid and resolvable: Note that @code{only} and @code{except} apply exclusively to functions and macros, not types. A module's exported types are always imported, ensuring that signatures referencing those types remain valid and resolvable:
@ -1177,6 +1177,33 @@ carries a list, so this literal cannot be one. Its tags are play, say or pass.
This elaboration mechanism only applies in positions where the type checker already knows that a specific union type is expected (which is why @code{script} and @code{more} include explicit type signatures). Standard library modules like @code{(std run)} and @code{(std fmt)} use this exact technique for command pipelines and text layouts. This elaboration mechanism only applies in positions where the type checker already knows that a specific union type is expected (which is why @code{script} and @code{more} include explicit type signatures). Standard library modules like @code{(std run)} and @code{(std fmt)} use this exact technique for command pipelines and text layouts.
@subsubsection{Computing a value once}
A macro often builds a value out of constants, and that value is then built again every time the expansion is evaluated. When that is expensive, wrap it in @code{please-hoist-i-promise-i-am-not-naughty}: the form is evaluated the first time it is reached, and every later evaluation gets that same value. This is what @code{#rx(...)} does, so a regex is compiled once rather than once per match.
@codeblock[#:lang "bjolang"]|{
;; (tag-set "a" "b" ...) is a HashSet of the strings, built once.
(def/macro (tag-set form inject compare)
(syntax-match form
((_ tag ...)
#'(please-hoist-i-promise-i-am-not-naughty
(list->hashset (list ,@tag))))))
}|
If computing the value throws, nothing is kept, and the next evaluation tries again. The same form written in two places is one value.
The long name is a promise about what the compiler cannot check: that the form has no side effects worth repeating, and that it does not depend on things that change from call to call, such as a parameter or an effect handler. Everything it can check, it does, and a form that breaks a rule is a compile error that lists every reason. The form cannot:
@itemlist{
@item{refer to a local variable or a function parameter;}
@item{read a @code{def} of the module it is in, or any @code{def/mutable};}
@item{use a type variable of the function around it;}
@item{call a bjoroutine, @code{yield} to a sequence, or leave a @code{with-return};}
@item{be void, or produce something that can be changed in place, such as an array, a box or a mutable collection.}
}
It may use literals, functions, and the defs of other modules. A macro that puts its caller's code inside the form passes these rules on to its caller, so keep the form to parts the macro built itself.
@subsubsection{Macro limitations} @subsubsection{Macro limitations}
When designing macros, keep the following constraints in mind: When designing macros, keep the following constraints in mind:

View file

@ -288,7 +288,7 @@ Every compiled program links against a small set of optimized runtime assemblies
| @code{Collections} | Persistent @code{(Vec %a)} based on Relaxed Radix Balanced (RRB) trees | | @code{Collections} | Persistent @code{(Vec %a)} based on Relaxed Radix Balanced (RRB) trees |
| @code{SchemeList} | Persistent singly-linked @code{(List %a)} with @code{Cons} and @code{Nil} | | @code{SchemeList} | Persistent singly-linked @code{(List %a)} with @code{Cons} and @code{Nil} |
| @code{Map} | Persistent @code{(Map %k %v)} based on Compressed Hash-Array Mapped Prefix-trees (CHAMP) | | @code{Map} | Persistent @code{(Map %k %v)} based on Compressed Hash-Array Mapped Prefix-trees (CHAMP) |
| @code{BjoSet} | Persistent hash set @code{(Set %a)} | | @code{BjoSet} | Persistent hash set @code{(HashSet %a)} |
| @code{BjoOrderedMap} | Balanced ordered map @code{(OrderedMap %k %v)} | | @code{BjoOrderedMap} | Balanced ordered map @code{(OrderedMap %k %v)} |
| @code{BjoOrderedSet} | Balanced ordered set @code{(OrderedSet %a)} | | @code{BjoOrderedSet} | Balanced ordered set @code{(OrderedSet %a)} |
| @code{Bjoml} | Concurrency runtime: lightweight fibers, channels, promises, and CML scheduler | | @code{Bjoml} | Concurrency runtime: lightweight fibers, channels, promises, and CML scheduler |