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 hé 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, ands[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?