11. Maps and strings

What this is for

You print a map for a log line. The test asserts on the output. It passes on your machine, passes in review, and fails in CI — not always, just often enough that somebody adds a retry.

The cause is that in most languages iteration order is whatever the hash function did. Go randomises it deliberately, so nobody can depend on it. Rust randomises its hash seed per process, so the order differs between runs. Both of those are admissions: hash order leaked into programs and then broke them, and the only fix left was to make the leak loud.

Burxt takes the other end. Iteration order is insertion order. Not unspecified, not arbitrary, not do not rely on it — defined.

That is not taste. The thesis of this language is that the same inputs produce the same bytes — it is what the byte-identical self-hosting fixpoint exists to prove — and a container whose iteration order depends on a hash function’s internals is a determinism hazard sitting in the middle of it. Printing a map, serialising one, or hashing a structure containing one would all become run-dependent, and none of those should be.

Think of a cloakroom

You hand over a coat and get a numbered ticket. Later you hand back the ticket and get the coat.

And the coats hang on the rail in the order they arrived. Not in an order the cloakroom attendant finds convenient, and certainly not in a different order each evening — because when you walk past the rail looking for yours, an order you can predict is the entire point.

The rail, in arrival order pear 1st apple 2nd fig 3rd ticket: "apple" A coat handed in twice keeps its original hook — apple is still 2nd. Not this hash order, reshuffled whenever the table grows Go randomises iteration deliberately; Rust randomises its hash seed per process.
Iteration is insertion order, always. Not "unspecified", not "arbitrary" — a language whose thesis is reproducibility should not ship a container whose order depends on a hash function's internals. That Go and Rust both randomise theirs deliberately is an admission that hash order leaked into programs and then broke them.

A step closer

The rail is two arrays rather than buckets of lists.

entries holds the coats in arrival order, and that is what iteration walks. slots is the ticket table — open addressed, linear probing, holding an index into entries plus one, so that a zero can mean empty without a sentinel constant. A removed entry keeps its place as a tombstone so everything after it holds its position, and count() answers the ones that are still coats.

That shape is right here for a reason particular to Burxt: there is no per-entry allocation at all. Every allocation lands in a region, and a region is a bump pointer — so a container that allocated once per insertion would make the region grow for no reason.

In code

Using one

use "lib/map.bx";

let mutable counts: Map<String, Int> = map_new();
let added: Int = counts.set("apples", 3);
let again: Int = counts.set("pears", 7);

print(counts.count());              // 2
print(counts.get("apples", 0));     // 3
print(counts.get("plums", 0));      // 0 — the fallback
print(counts.has("pears"));         // true

let keys: [String] = counts.keys();     // in the order they went in
for k in keys {
    print(k);
}

let removed: Bool = counts.remove("apples");   // true; false if it was not there
print(removed);

lib/map.bx is a library file, like lib/option.bx and lib/result.bx. Ordinary Burxt written with the generics, and the only compiler support it needs is one builtin, hash. If a map had needed a keyword, those generics would not be real.

Reading: get or find

Two ways, and the difference is whether a default is the right answer.

use "lib/map.bx";

let mutable counts: Map<String, Int> = map_new();
let added: Int = counts.set("pears", 7);

print(counts.get("plums", 0));          // the value, or the fallback you gave

match counts.find("pears") {            // the value, or None
    None => { print("no pears"); }
    Some(n) => { print(n); }
}

get is for when a default is genuinely right — a missing count is zero. find answers an Option<V>, the one type that cannot be read without saying what happens when there is nothing there.

find could not be written until v0.0.118, and the reason shows how these pieces lean on each other: a variant payload had to be a scalar, so Option<Point> was refused, so an Option<V> return would have restricted map values to scalars. get with a fallback was the honest answer while that was true. When the payload rule lifted, find was three lines.

Keys are Equatable

Int, Bool, String, Decimal — exactly the types == works on, which is the bound the generics page already describes. A key needs equality and a hash, and the set of types that have equality is the set that can have one. So there is no Hashable bound: it would name the same four types twice.

use "lib/map.bx";

let mutable by_number: Map<Int, String> = map_new();
let one: Int = by_number.set(1, "one");
print(by_number.get(1, "?"));           // one

A class as a key is refused. It would need structural hashing, which needs a per-type walk, which needs either a derive mechanism or an interface with a method — both larger than this container. For a compound key, build a String from the parts.

Values have no such restriction:

use "lib/map.bx";

class Point { x: Int, y: Int }

let mutable places: Map<String, Point> = map_new();
let put: Int = places.set("origin", Point { x: 1, y: 2 });
let here: Point = places.get("origin", Point { x: 0, y: 0 });
print(here.y);

Everything is a method except map_new

function map_new<K: Equatable, V>() -> Map<K, V>                     // an empty map
function (mutable self: Map<K, V>) set(key: K, value: V) -> Int       // 1 if new, 0 if it replaced
function (self: Map<K, V>) get(key: K, fallback: V) -> V
function (self: Map<K, V>) find(key: K) -> Option<V>
function (self: Map<K, V>) has(key: K) -> Bool
function (mutable self: Map<K, V>) remove(key: K) -> Bool
function (self: Map<K, V>) count() -> Int
function (self: Map<K, V>) keys() -> [K]

Methods rather than free functions, and that was forced rather than chosen: Burxt has no writable parameters, so a container that changes has to change through mutable self. The API is better for it — counts.set("k", 1) reads better than map_set(counts, "k", 1) — which is the usual way a real constraint turns out to have been pointing at the nicer design all along.

set answers 1 when the key is new and 0 when it replaced a value, so counting distinct keys needs no second lookup.

Strings

A String is bytes. Not a rope, not a UTF-16 array, not an object with a hidden encoding field.

print(len("hello"));                 // 5 — bytes, not characters
print(byte_at("hello", 0));          // 104
print(substring("hello", 1, 3));     // ell — a start and a LENGTH
print("total: " + to_string(3));     // joining builds a new String

Interpolation is a join written differently — "total: {amount}" — and + on two Strings builds a new one, which means it allocates, which means Memory applies.

lib/string.bx is where the rest lives, and it is ordinary Burxt too:

   
string_find(text, needle) the byte offset, or -1
string_contains / string_starts_with / string_ends_with Bool
string_trim(text) leading and trailing whitespace removed
string_split(text, separator) the separator is a String, so ", " and "\r\n" both work
string_lines(text) split on newlines
string_join(pieces, separator) the separator here is a String
string_to_int(text, fallback) the number, or the fallback you named
string_parse_int(text) Option<Int> — for when garbage is not zero
string_repeat(text, times)  

Those last two are a pair on purpose. string_to_int used to be the only one and it answered 0 for garbage — which is the silent-wrong-answer shape this whole language is against, and it took a real bug to notice. Now the fallback is either named by the caller or handed back as an Option.

One honest gap: there is no case conversion yet. The split separator was a single byte until v0.0.189 — so ", " and "\r\n" could not be split on at all — and it is a String now.

Why it is built this way

Because a hash order that leaks into a program is a bug you find later. Go randomises map iteration deliberately and Rust randomises its hash seed per process, and both are admissions that programs came to depend on an order nobody promised. A language whose whole argument is that a wrong answer must not be plausible cannot ship a container whose output order changes between runs.

Because it needs no keyword. Map<K, V> is one file of ordinary Burxt, and the only compiler support it asks for is hash(x). If a map had needed a keyword, the generics were not real.

The shape, and why it is that shape

There is no per-entry allocation: a map is the two arrays in the diagram and nothing else. That matters more here than in most languages, because every allocation lands in a region and a region is a bump pointer — a container that allocated once per insertion would make the region grow for nothing.

Growing the table re-places the cards and drops the tombstones. It never reorders the pegs.

What it costs

A tombstone stays. Removing an entry keeps its place so everything after it holds its insertion position. count() answers the live entries; the storage does not shrink until the region goes.

Keys are EquatableInt, Bool, String, Decimal. Not a class of yours, because == does not work on one.

A String is bytes. len counts bytes and byte_at reads one, so anything beyond ASCII is a byte-by-byte question you have to answer yourself. There is no .chars() yet.

What is deliberately absent

No unwrap. Same reason as lib/option.bx: it is a decision disguised as a convenience.

No map, filter or each. They need a function as a value, and a closure needs an owner for its captured state — a memory question, not a syntax one. Iterate keys() with a for loop, which cannot capture anything by accident.

No HashDoS protection. hash is deterministic and unseeded, because a seeded hash cannot iterate in a defined order, and that is the whole point of the container. A caller feeding attacker-chosen keys can force collisions and turn O(1) into O(n). If you need that guarded the answer will be a second constructor — map_seeded(seed) — so the program that needs it says it needs it, rather than every program paying for it. A security property should be visible in the code that has it.

When you reach for it

You want Write
a fresh map let mutable m: Map<String, Int> = map_new(); — the type comes from the annotation
to insert or overwrite m.set(key, value) — answers 1 if the key was new
a value, with a default m.get(key, fallback)
a value, and to know whether it was there m.find(key) — answers Option<V>
just to ask m.has(key)
to walk everything, in order let names = m.keys(); then for k in names
how many live entries m.count()

for iterates a named array, so bind keys() first: a method call in the for header would be recomputed on every pass, and the compiler refuses it rather than doing that quietly.

Examples

Insertion order, including an overwrite. apple is set twice and keeps its original place:

use "lib/map.bx";

let mutable counts: Map<String, Int> = map_new();
let a: Int = counts.set("pear", 2);
let b: Int = counts.set("apple", 5);
let c: Int = counts.set("fig", 1);
let d: Int = counts.set("apple", 6);

print(counts.count());
let names: [String] = counts.keys();
for key in names {
    print(key + " " + to_string(counts.get(key, 0)));
}
3
pear 2
apple 6
fig 1

Three live entries, not four. apple holds its second position with its new value — which is the behaviour you would have assumed, and the one most languages do not give you.

And the refusal that keeps a loop honest, if you skip the binding:

use "lib/map.bx";

let mutable counts: Map<String, Int> = map_new();
let a: Int = counts.set("pear", 2);
for key in counts.keys() {
    print(key);
}
error: `for` iterates a named array, and this is a method call: its result would be recomputed on every pass. Bind it first — `let items = ...;` — and iterate that.
 --> counts.bx:5:26
  |
5 | for key in counts.keys() {
  |                          ^

Next

Tools and agentsburxt mcp-schema, which derives an agent’s tool schema from the preconditions so the two cannot drift, and burxt review, which answers what a change did to what a program promises.

Or the reference for every keyword, builtin, command and standard-library function — generated by reading the compiler, with a search box.

The design record carries the reasoning behind every refusal above: spec/1.0/M11-MAPS.md.