2. Strings, honestly

"Skin and Bones."— Cage the Elephant

A wolf string is skin and bones: bytes, and a length. Everything in this chapter follows from refusing to pretend otherwise — including the one operation the language does not give you.

Chapter 1’s receipt guessed its column width. Here it measures:

fn comma(row: str) -> int {
    var i = 0
    while row[i..i + 1] != "," { i += 1 }
    i
}
fn main() -> !int {
    let rows = """
        espresso,340
        pastry,275
        tip,100
        """
    var width = 0
    for row in rows.lines() {
        let i = comma(row)
        if i > width { width = i }
    }
    for row in rows.lines() {
        let i = comma(row)
        print("{row[..i]}{" ".repeat(width - i + 2)}{row[i + 1..]:>5}")
    }
    0
}
$ lupin align.lu
espresso    340
pastry      275
tip         100

Two passes: one to learn the widest name, one to print. The columns come from the data instead of from a number you typed and will forget to change. By the end of this chapter every operation in it is one you can account for — including what the slices cost, which is §2.5’s business.

2.1 Literals, methods, interpolation

A string literal is written between double quotes and holds text in UTF-8. Braces inside it interpolate: the expression between the braces is evaluated and formatted into place.

fn main() -> !int {
    let item = "tip"
    print("[{item}] [{item:<8}] [{item:>8}] [{item:^8}]")
    print("[{item:.<8}] [{100:0>5}] [{2 + 2}]")
    print("[{{braces}}] [{item.upper()}]")
    0
}
$ lupin specs.lu
[tip] [tip     ] [     tip] [  tip   ]
[tip.....] [00100] [4]
[{braces}] [TIP]

The interpolation is not a printf-style call with the format string on one side and the arguments on the other. There is one place to look, the expression sits where its value lands, and the pair does not drift apart during a refactor. Any expression works, not only a name — {2 + 2} and {item.upper()} are as ordinary as {item}. To print a literal brace, double it.

After a colon comes the format spec: an optional fill character, an alignment (< left, > right, ^ centered), and a width. {item:.<8} means left-aligned in eight columns, padded with dots; {100:0>5} means right-aligned in five, padded with zeros. Interpolation belongs to the literal and not to print, so the same braces work in a multiline literal, in a string you never print, and anywhere else a literal is legal — with the one exception the next section names.

A dot after the width introduces a precision, which rounds a number and truncates a string:

$ lupin eval '"{3.14159:>8.2}"'
    3.14 : str

The full spec is [[fill]align][+][0][width][.precision][type], and the three remaining pieces are quickly named. + prints a sign on positive numbers ({340:+} is +340). 0 pads with zeros after the sign rather than before it, so {7:08} is 00000007. And a trailing type letter changes the base or the notation: x and X for hexadecimal, e and E for exponent form ({12345.6789:e} is 1.234568e+04). A float with no precision prints the shortest text that reads back as the same value, which is why a whole-valued f64 prints 12 and not 12.0.

Strings answer questions about themselves by method call:

wolf> "  espresso  ".trim()
espresso : str
wolf> "tip".upper()
TIP : str
wolf> "340".to_int()
340 : i64
wolf> "tip,100".starts_with("tip")
true : bool
wolf> "awoo".repeat(2)
awooawoo : str
wolf> "tip" + "," + "100"
tip,100 : str

upper and lower know Unicode ("é".upper() is "É"); trim removes whitespace from both ends; contains and starts_with answer yes or no; is_empty is the readable spelling of len == 0; + and += join. split cuts a string on a separator and hands back the pieces, find and rfind report where a separator is, and replace swaps one run of bytes for another. §2.4 sets split beside the byte scan that does the same work by hand, because the scan is worth writing once.

Exercise 2-2 (fingers · lupin) — Using format specs, print a two-column table: names left-aligned in 10 columns, numbers right-aligned in 4.

2.2 Multiline and raw

Text with newlines in it goes between triple quotes, and the common indentation of the block is removed, so a literal can sit where it belongs in the code rather than jammed against the left margin:

fn main() -> !int {
    let who = "reader"
    let note = """
        dear {who},
          this line keeps its two spaces
        and this one does not
        """
    print("[{note}]")
    print("{note.len} bytes, {note.lines().count()} lines")
    0
}
$ lupin note.lu
[dear reader,
  this line keeps its two spaces
and this one does not
]
68 bytes, 3 lines

Three rules are visible in that output. The newline after the opening """ is not part of the string. The indentation of the closing """ sets the margin, and every line is dedented by that much — relative indentation survives, which is why the middle line keeps its two spaces. And the last line ends with a newline, because there is one before the closing delimiter; that is why the ] lands on its own line. Braces interpolate here exactly as they do in a one-line literal.

When the text is full of backslashes, escape archaeology gets old. r"…" turns the escape table off:

fn main() -> !int {
    let path = r"C:\logs\{today}\wolf.log"
    print(path)
    print("{path.len} bytes")
    0
}
$ lupin raw.lu
C:\logs\{today}\wolf.log
24 bytes

In a raw literal nothing is special: \n is a backslash and an n, and the braces are two more characters rather than an interpolation. Count the bytes on the page and you have the length.

Exercise 2-4 (extension · lupin) — Extend the word counter to also report lines and bytes for a """ multiline block. Predict which of the three numbers is a byte count before running.

Exercise 2-5 (comprehension · lupin REPL) — Predict all three lengths before evaluating: "\n".len, r"\n".len, r"C:\temp".len.

2.3 Bytes, honestly

One honesty up front, because it will bite the Python refugee within the hour:

wolf> "é".len
2 : i64
wolf> let row = "tip,100"
wolf> row[..3]
tip : str
wolf> row[4..]
100 : str
wolf> row[3..4]
, : str

len counts bytes. Indexing takes a byte range, half-open: row[..3] is the first three bytes, row[4..] is everything from byte four to the end, and row[3..4] is the one byte between them. An omitted end fills in the boundary.

There is no s[i] that hands you the ith character. The subscript wants a range, always, and a program that asks for a single index does not run.

That absence is a design decision, not a missing feature. After thirty years of Unicode there is no cheap answer to what a character is — only answers that lie at different speeds. A byte is not a code point; a code point is not a grapheme; the letter your reader sees may be several of each. Wolf gives you byte offsets, checked slices, and iterators that spell their unit, and it declines to hand you an s[i] whose meaning changes with the alphabet.

Checked is the load-bearing word. A range that runs off the end is a fault, not a surprise value and not undefined behavior — you saw one in §1.5. A range that stops partway through a character is a fault too:

fn main() -> !int {
    let s = "héllo"
    print("{s.len}")
    print(s[..3])
    print(s[..2])
    0
}
$ lupin utf8.lu
6
hé
utf8.lu: trap(bounds): byte range 0..2 splits a UTF-8 code point [mem.ub.defined] at 90..96

Six bytes for five letters, [..3] is because é is two bytes wide, and [..2] stops in the middle of é — so the run stops instead of handing you half a letter. This is the trade wolf makes in the open: you count in bytes, and every slice you take is checked to be a string.

Coming from Python: len("é") is 1 in Python 3 and 2 here, and s[i] gives you a one-character string there and nothing at all here. Python bought that with a representation it chooses per string and a cost model you cannot see; wolf keeps one representation, tells you the unit, and refuses the operation whose answer depends on which definition of “character” you had in mind. Neither position is free. Python’s is friendlier to a first program; wolf’s is friendlier to a program whose costs you have to predict, and §2.5 is where those costs are on the page.

Exercise 2-1 (comprehension · lupin REPL) — Predict all three before evaluating: "wolf".len, "é".len, "🐺".len.

Exercise 2-3 (comprehension · lupin)"wolf" has four bytes. Predict the exact behavior of:

fn main() -> !int {
    let s = "wolf"
    let t = s[2..9]
    print(t)
    0
}

Exercise 2-6 (comprehension · lupin REPL)"wolf" has four bytes. Predict each of these, precisely — value or event: "wolf"[..2], "wolf"[2..], "wolf"[4..4], "wolf"[3..2].

2.4 Iterating meaning

When you want units rather than bytes, ask for the unit by name. lines() walks lines; words() walks whitespace-separated words. The method’s name is the definition of what you get, which is the point:

fn main() -> !int {
    let log = """
          espresso,340
        pastry,275

        tip,100
        """
    var rows = 0
    for line in log.lines() {
        let row = line.trim()
        if row.is_empty() { continue }
        rows += 1
        print("{rows}: [{row}] {row.words().count()} word(s)")
    }
    0
}
$ lupin iterate.lu
1: [espresso,340] 1 word(s)
2: [pastry,275] 1 word(s)
3: [tip,100] 1 word(s)

espresso,340 is one word: words() splits on whitespace and has no opinion about commas. The blank line was skipped by trim and is_empty, in the two places you can see, and by nothing else. count() counts what an iterator yields, so lines().count() and words().count() are line and word counts with no argument about which unit was meant.

Splitting on a separator you choose is row.split(","), and the comma function at the head of this chapter is the same job written by hand: walk forward a byte at a time, comparing single-byte slices, and stop where the separator is. Both look at every byte once, so the scan costs what the method costs; what the scan buys is that you can see the byte offsets, which is the arithmetic every checked slice in §2.5 is made of. Write it once for the understanding, then reach for split.

Exercise 2-7 (extension · lupin) — Write encode, a run-length encoder over bytes: "aaabcc" becomes "a3b1c2". Walk the string with byte slices and equality only. What does your encoder do with the empty string, and did you have to write a special case for it?

2.5 What the machine does

A slice is a view: two machine words, a pointer and a length, aimed at bytes that already exist. Taking one copies nothing.

fn main() -> !int {
    let row = "espresso,340"
    let name = row[..8]
    let cents = row[9..]
    print("{name} + {cents}")
    print("{row.len} {name.len} {cents.len}")
    0
}
$ lupin view.lu
espresso + 340
12 8 3

You can check the no-copy claim from the values alone. row is still twelve bytes after two slices were taken out of it, because nothing was taken out of it: name and cents are two pointers into the same twelve bytes, with lengths of 8 and 3.

wolf> let row = "espresso,340"
wolf> let name = row[..8]
wolf> name
espresso : str
wolf> name.len
8 : i64
wolf> row.len
12 : i64

The consequence is a cost model you can hold: row[..8] costs the same whether row is twelve bytes or twelve megabytes, so a parser that walks a large input in slices allocates once — at the input — and never again. That is why §2.4’s scan is not the slow way to split a row, and why nothing here needed a second string type: slicing does not build. Building is what += does, and += is the one string operation in this chapter that copies — which is worth knowing before you put one in a loop over a large file.

The second half of the cost model is the interpolation itself. A format string in C is data the program parses at runtime, matched against an argument list by convention and by hope. Wolf resolves the braces when it compiles the literal, into the formatting calls they name: there is no format string left to parse, no argument list to walk, and no way for the two to disagree about how many %s you wrote. The machinery is the compile-time evaluator chapter 18 takes apart, where f-strings turn out to be its most-used customer and the one nobody notices. Whether the result beats C’s printf on your machine is a measurement, and this book does not make measured claims without the measurement: chapter 21 runs the comparison, prints the numbers CI produced, and names the cases where C still wins.

Exercise 2-8 (comprehension · lupin REPL)s is "wolfpack". Predict all four values, then say what slicing s cost — did any of these lines copy eight bytes?