String builder
A String has no way to add to its text — it can only be replaced whole. Building text a piece at a time out of Strings alone would mean making a new String at every step and copying everything built so far into it — the longer the text, the more each step costs. StringBuilder is the tool for that job. It keeps one growing buffer, appends into it, and asks the allocator for a larger one only when the room runs out. When the text is finished, it hands the buffer over as a String.
Length and capacity
A builder tracks two numbers: Length, the text so far, and Capacity, the room for it. A new builder has allocated nothing yet; its buffer appears with the first append.
var builder = StringBuilder(allocator);
Join appends the colours one at a time and reports both numbers after each:
func Join(builder: &var StringBuilder, words: char8[..][..]) -> ! TextError {
for i in 0..words.length {
if i > 0 {
let separator = i == words.length - 1 ? " and " : ", ";
builder.Append(separator)?;
}
builder.Append(words[i])?;
PrintLine("added {}: {} bytes, room for {}", words[i], builder.Length(),
builder.Capacity());
}
}
| After adding | Length | Capacity | What happened |
|---|---|---|---|
red | 3 | 16 | the first buffer is allocated |
orange | 11 | 16 | fits; only the new bytes copied |
yellow | 19 | 32 | out of room: the buffer doubles |
green | 26 | 32 | fits |
blue | 35 | 64 | doubles again |
The room doubles each time it runs out, so most appends copy just the bytes being added, and the rare move to a bigger buffer gets rarer as the text grows.
The builder is passed as &var StringBuilder — a mutable reference — so Join grows the caller's builder rather than a copy of it.
Every append can fail
Growing the buffer means asking for memory, and that can fail, so every append returns ! TextError — a unit fallible. Each one is followed by ?, which propagates a failure and otherwise carries on.
| Method | Appends |
|---|---|
Append(text) | a char8[..] or a StringView |
AppendScalar(c) | one character, any char32, encoded as UTF-8 |
AppendAscii(byte) | one ASCII char8 |
builder.Append(" ")?;
builder.AppendScalar('✓')?;
The check mark is one character but three UTF-8 bytes, which is why the finished text is 39 bytes, not 37.
Taking the text out
View looks at the text so far without taking it — a string view of the builder's buffer. IntoString hands the buffer itself to a String, without copying a byte, and leaves the builder empty, ready to build something else:
PrintLine("view {}", builder.View());
let sentence = builder.IntoString();
PrintLine("string {}", sentence);
PrintLine("builder now holds {} bytes", builder.Length());
flowchart LR
b["StringBuilder<br/>buffer: 39 bytes"] -- "View()<br/>borrow, no copy" --> v["StringView"]
b -- "IntoString()<br/>hand over the buffer;<br/>builder left empty" --> s["String<br/>owns the 39 bytes"]
b -- "ToString()<br/>copy, may fail" --> c["String<br/>a copy; builder keeps its text"]| Method | Returns | Copies? | Builder afterwards |
|---|---|---|---|
View() | StringView | no | unchanged |
IntoString() | String | no | empty |
ToString() | String ! TextError | yes | unchanged |
IntoString cannot fail — nothing is allocated — which is why it needs no ?.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// A `String` cannot grow: it can be cleared or replaced whole, but never appended to, so building
// text a piece at a time out of Strings alone would copy everything built so far at every step.
// `StringBuilder` is the tool for that job. It keeps one growing buffer, appends into it, and asks
// the allocator for a larger one only when the room runs out. `Length` is the text so far and
// `Capacity` the room for it. The room doubles each time it runs out, so most appends copy just
// the bytes being added, and the rare move to a bigger buffer gets rarer as the text grows.
//
// Each append can fail, because growing the buffer can, so each returns `! TextError`. When the
// text is finished, `IntoString` hands the buffer over as a String without copying it.
import Allocator::{ Allocator, SystemAllocator };
import Io::PrintLine;
import Text::{ StringBuilder, TextError };
// Joins words into a list a person would write: "a, b and c". The builder is borrowed `&var`,
// so the caller's builder is the one that grows. Watch the room: 16, then 32, then 64.
func Join(builder: &var StringBuilder, words: char8[..][..]) -> ! TextError {
for i in 0..words.length {
if i > 0 {
let separator = i == words.length - 1 ? " and " : ", ";
builder.Append(separator)?;
}
builder.Append(words[i])?;
PrintLine("added {}: {} bytes, room for {}", words[i], builder.Length(),
builder.Capacity());
}
}
func Main() -> ! TextError {
var system = SystemAllocator();
let allocator: Allocator = system;
// A new builder has allocated nothing yet. Its buffer appears with the first append.
var builder = StringBuilder(allocator);
let colours: char8[..][5] = ["red", "orange", "yellow", "green", "blue"];
Join(builder, colours)?;
// Single characters go in with `AppendScalar`, which encodes any character as UTF-8.
builder.Append(" ")?;
builder.AppendScalar('✓')?;
// `View` looks at the text so far without taking it.
PrintLine("view {}", builder.View());
// `IntoString` hands the buffer itself to a String. The builder is left empty, ready to
// build something else.
let sentence = builder.IntoString();
PrintLine("string {}", sentence);
PrintLine("builder now holds {} bytes", builder.Length());
}
Besides Io, its Rux.toml lists Allocator and Text under [Dependencies].
Run it
cd Examples/Text/StringBuilder
rux run
added red: 3 bytes, room for 16
added orange: 11 bytes, room for 16
added yellow: 19 bytes, room for 32
added green: 26 bytes, room for 32
added blue: 35 bytes, room for 64
view red, orange, yellow, green and blue ✓
string red, orange, yellow, green and blue ✓
builder now holds 0 bytes
Common mistakes
builder.Append(" "); on its own fails with error: fallible result of type '! TextError' is discarded, and the note says why: a failure that nothing handles is lost. Add ?, or handle it with catch.let.Appending changes the builder, so it must be a
var. With let builder, the call Join(builder, colours) fails with error: argument 1 to 'Join' cannot borrow immutable 'builder' as '&var StringBuilder', and each builder.Append with cannot call 'Append' on immutable 'builder'.Append.builder.Append('✓') fails with error: no matching overload for method 'Append' on type 'StringBuilder' with argument types (char32) — Append takes text. A single character goes in with AppendScalar.Try it yourself
- Replace
IntoStringwithToString()?and see what the last line prints now. - Make the builder with
StringBuilder::WithCapacity(allocator, 64)?instead. How do theroom fornumbers change? - Write
Repeat(builder: &var StringBuilder, text: char8[..], count: int) -> ! TextErrorand use it to build a line of 40 dashes. - After
IntoString, build a second sentence in the same builder.
Learn more
- Text in the API reference
- String — what
IntoStringproduces - Unit fallible and Propagate —
! TextErrorand? - Input — a builder that
ReadLinefills with each line typed