Text · Lesson 14.5

String builder

Source
Build text a piece at a time in one growing buffer with StringBuilder, then hand it over as a String with IntoString.

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 addingLengthCapacityWhat happened
red316the first buffer is allocated
orange1116fits; only the new bytes copied
yellow1932out of room: the buffer doubles
green2632fits
blue3564doubles 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.

MethodAppends
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"]
MethodReturnsCopies?Builder afterwards
View()StringViewnounchanged
IntoString()Stringnoempty
ToString()String ! TextErroryesunchanged

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.

Src/Main.rux
// 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

Dropping an append's result.
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.
A builder declared with 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'.
Appending a character with 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

  1. Replace IntoString with ToString()? and see what the last line prints now.
  2. Make the builder with StringBuilder::WithCapacity(allocator, 64)? instead. How do the room for numbers change?
  3. Write Repeat(builder: &var StringBuilder, text: char8[..], count: int) -> ! TextError and use it to build a line of 40 dashes.
  4. After IntoString, build a second sentence in the same builder.

Learn more