Most languages have several string stories that grew together: C’s bytes with
a terminator, Java’s immutable objects plus a builder bolted on later,
Python’s str-versus-bytes schism. Mica has one story with three deliberate
shapes, each earning its place: string the immutable value,
stringbuffer the builder, stringpart the borrowed window. This
tutorial walks all three plus the strings unit’s verbs, in
one compiled example.
It assumes values — a string is a value under the same rule as everything else — and pairs with UtfSources, which owns the encoding story.
The value
A string is a two-field value: a pointer to frozen data, and a length
counted in runes, not bytes. Nothing in the language ever mutates a
string’s content — every operation answers a new string — so a string you
hold can never change behind your back, and passing one costs two machine
words, not a copy of the text.
line := "disk" + " " + "full";
WriteLn(" built: %ls", line);
WriteLn(" runes: %llu", Length(line));
if line = "disk full" then
WriteLn(" comparison reads content, not identity");The value
built: disk full
runes: 9
comparison reads content, not identityImmutability is compiler-enforced, and the refusal names the escape hatch:
analyzer error 5225: a string element cannot be assigned: a string value is
immutable, so 'line[...]' is read-only — build changing text in a
stringbuffer and materialize it with ToStringThe walk: runes, not bytes
for ch in s decodes one code point per step; the control variable is a
unicode, printed with %lc. Indexing s[i] answers the same type. The
umlaut counts once, the emoji counts once:
The walk: runes, not bytes
'größe 😀' walks 7 runes, Length says 7
first g, last 😀Whether those runes are stored four bytes apiece or in varying widths is the build’s business, not the program’s — UtfSources makes that choice visible.
The unit: verbs over values
The everyday verbs are not ambient; they live in the strings unit and
arrive by import like everything in Mica:
imp
Trim, UpperCase, StartsWith, Pos, Copy, Split, Join, Parts : strings;trimmed := Trim(line);
upper := UpperCase(Copy(trimmed, 0, 5));
fields := Split("a,b,,c", ",");
joined := Join(fields, " | ");The unit: verbs over values
trimmed: [error: disk full]
shouted head: ERROR
starts with 'error' at position 0
split and rejoined: a | b | | cEvery verb answers a new string; the pipeline refines copies and the
originals stand. Split keeps empty pieces (the b | | c above), and
Join is its exact inverse over the same separator. The full surface —
Contains, EndsWith, Replace, Str/Val and their float twins,
case mapping — follows the same shape: value in, value out.
The builder
+ inside a loop copies everything built so far on every round — quadratic,
however fast each copy is. A stringbuffer appends in amortized constant
time; Reserve pre-sizes it when the total is known; ToString
materializes an exact-sized immutable string; Clear empties for reuse
without releasing the backing:
Reserve(address buf, 32);
for n := 1 to 5 do
Append(address buf, "ab");
line := ToString(buf);
Append(address buf, "!!!");The builder
materialized: ababababab (still 10 runes after the builder grew)
after Clear: freshTwo details carry the value rule through. The builder is passed by
address — it is mutable state, so every touch is consented to at the call
site, as always. And ToString is a real copy:
the !!! appended afterwards never reaches the materialized string.
The window: tokenizing without allocating
Split allocates its pieces. A parser’s inner loop must not — so the same
walk exists as a cursor of three integers and a stringpart: a borrowed
window into the subject, no copy, no allocation, one pass over the subject’s
bytes for the whole walk:
cursor := SplitStart();
while Splitting(address cursor) do
begin
piece := NextPart(line, ",", address cursor);
n := n + 1;
WriteLn(" piece %lld: [%ls] (%llu runes)", n, piece, Length(piece));
end;The window: tokenizing without allocating
piece 1: [eins] (4 runes)
piece 2: [zwei] (4 runes)
piece 3: [] (0 runes)
piece 4: [vier] (4 runes)A window prints, measures and compares like a string — but it borrows from
its subject, and the borrow rules bound each window’s life to the subject’s,
the same discipline alias taught on the heap. A piece
that must outlive the walk is materialized with ToString — the visible
exception, exactly where the allocation belongs.
What the compiler refused
| The program tried to | The compiler said |
|---|---|
| assign into a string element | 5225: … a string value is immutable, so 'line[...]' is read-only — build changing text in a stringbuffer and materialize it with ToString |
print a string with %d | 5184: format specifier '%d' at position 1 incompatible with type: string |
What this does not do
- No in-place mutation, anywhere. There is no “small write” exception; changing text is what the builder is for, and 5225 will keep saying so.
- Indexing is by rune position. Under the utf-8 build a positional
s[i]in a hand-written loop walks encoding boundaries — the taught idiom for traversal isfor ch in s, which is one pass under either encoding. - The subject of a cursor walk must not change mid-walk. The cursor is honest only over the walk it was started for; re-tie it after replacing the subject.
- No locales here. Case mapping is per code point (
UpperCasePointand friends); locale-aware collation is a library concern for a later unit, not a hidden behavior of=.
Try it
git clone https://gitlab.com/mica-lang/mica-container.git
make -C mica-container/examples/Text runReplace the builder loop’s Append with line := line + "ab" and both
versions still print the same text — then imagine the loop at a million
rounds, and the builder’s reason is the arithmetic. Change a piece’s %ls
to %d and the format checker refuses before the build.
Next
UtfSources — the other half of the text story: a source file where identifiers come from any script, and the encoding as a build-time choice the program’s meaning never depends on.