Strings, bytes & runes

Station 1.5 14 min Interchange: strlen & mb_strlen & str_* → string

Every PHP developer has a scar from this. A user called Zoë signs up, something calls substr($name, 0, 3), and the welcome email says hello to Zo�. You learned to reach for mb_strlen and mb_substr, and to squint at any function without the mb_ prefix.

Go has the same underlying model, so you'll recognise the problem. It just gives you a cleaner way out.

SignupName: Zoë
Welcome emailsubstr($name, 0, 3) says "Welcome, Zo�!"
Support ticket"Why is my welcome email haunted?"
One byte short of a character.

A string is bytes

A Go string is a read-only sequence of bytes. By convention, and because Go source files are always UTF-8, those bytes are UTF-8 text. That's exactly what a PHP string holds, too. So the byte-level operations behave like PHP's byte-level ones:

dest := "Köln"
len(dest)  // 5, not 4: ö is two bytes
dest[0]    // 75, a byte (the K)
dest[0:2]  // "K\xc3": half an ö

Three things to notice:

  • len is strlen. It counts bytes.
  • Indexing gives a byte, not a string. A byte is a small number (it's another name for uint8), so fmt.Println(dest[0]) prints 75. Wrap it, string(dest[0]), to get "K".
  • Slicing is s[start:end], end exclusive. That's not substr's start and length: dest[1:3] means bytes 1 and 2. Leave either side out to run to the edge, as in dest[:2] or dest[2:].

Strings are also immutable. dest[0] = 'k' is a compile error. You build a new string instead, with +, with += (Go's spelling of .=), or with the strings functions below.

Runes are characters

A rune is one Unicode code point, which for most text means one character. You've met rune literals already: single quotes, 'ö', back in 0.2. Under the hood a rune is an int32 holding the code point.

To work in characters, convert the string to a slice of runes. A slice is Go's list type, which you glimpsed with variadics in 1.2. Unit 2 covers it properly, and for now indexing and len work as you'd expect:

runes := []rune("Köln")
len(runes)        // 4
string(runes[1])  // "ö"
string(runes[:2]) // "Kö"

Here's how the two views line up for "Köln": the five bytes s[0] to s[4] on top, the four runes underneath. The ö is two bytes but one rune:

%%{init: {"flowchart": {"nodeSpacing": 18}}}%%
flowchart TB
  accTitle: The string Köln as bytes and as runes
  accDescr: The top row is the five bytes of Köln: 75 for K, then 195 and 182, which together make ö, then 108 for l and 110 for n. The bottom row is the four runes, K, ö, l and n. Both of ö's bytes point at the single ö rune.
  b0["75"] --> r0["K"]
  b1["195"] --> r1["ö"]
  b2["182"] --> r1
  b3["108"] --> r2["l"]
  b4["110"] --> r3["n"]

[]rune(s) replaces the whole mb_ family. Count, index or slice the runes, then convert back with string(...). If you only need the count, utf8.RuneCountInString(s) from the unicode/utf8 package does it without building the slice.

Looping over a string with range, which you'll meet properly in 1.9, also walks it rune by rune. It gives you each rune and the byte position where it starts.

Mind the gap

len and slicing count bytes, so they cut characters in half. s[:10] to "shorten to 10 characters" works on every test string you type, because your test strings are ASCII. Then a Zürich or a Zoë arrives, and you either keep fewer characters than you meant to or slice through the middle of one. When the limit is about what a human sees, convert to []rune first.

The strings package

PHP's str_* and friends are global functions. Go's equivalents live in the strings package: strings.Contains, strings.HasPrefix, strings.Split, strings.Join, strings.ReplaceAll, strings.TrimSpace and plenty more. The Interchange tab has them next to their PHP names.

These are already UTF-8 aware, so there's no second set to remember. One that's quietly better than PHP's: since PHP 8.2, strtoupper('Genève') gives GENèVE, because it only touches ASCII. Go's strings.ToUpper gives GENÈVE. You'd need mb_strtoupper for that in PHP.

Laravel corner

Str::limit($name, 10) is Laravel's answer to the Zoë problem. It uses the mb_ functions underneath, so it never cuts a character in half. Go's strings package has no limit. You write it yourself from []rune, and that's this lesson's exercise.

Tip

Need a string with backslashes or several lines? Use backticks: `C:\trains\eurostar` is taken literally, like a nowdoc. Raw strings are great for SQL and regular expressions. No Go string interpolates variables, raw or not. Lesson 1.6 shows what to use instead.

Recap

  • A string is read-only UTF-8 bytes. len, s[i] and s[a:b] all work in bytes.
  • s[i] is a byte (a number). s[a:b] is start to end, not start and length.
  • A rune is a character. []rune(s) lets you count, index and slice by character. Then string(...) converts back.
  • The strings package has the str_* functions, already UTF-8 safe.
  • Backticks make raw strings. Nothing interpolates.

1Bytes vs characters

PHPlength.php
<?php
$dest = 'Köln';

strlen($dest);          // 5 (bytes)
mb_strlen($dest);       // 4 (characters)
$dest[1];               // "\xC3", half an ö
mb_substr($dest, 0, 2); // "Kö"
GOlength.go
dest := "Köln"

len(dest)                    // 5 (bytes)
utf8.RuneCountInString(dest) // 4 (runes)
dest[1]                      // 195, a byte
string([]rune(dest)[:2])     // "Kö"

Mind the gap

Both languages store UTF-8 bytes, and both count bytes by default. Go has no mb_ twin for every function. Instead, you convert to []rune when you need characters.

2The strings package

PHPfuncs.php
<?php
str_contains($s, 'Hbf');
str_starts_with($s, 'Gare');
mb_strtoupper($s);
trim($s);
str_replace('Hbf', 'Hauptbahnhof', $s);
explode(', ', $s);
implode(' / ', $parts);
GOfuncs.go
strings.Contains(s, "Hbf")
strings.HasPrefix(s, "Gare")
strings.ToUpper(s) // Unicode-aware
strings.TrimSpace(s)
strings.ReplaceAll(s, "Hbf", "Hauptbahnhof")
strings.Split(s, ", ")
strings.Join(parts, " / ")

Mind the gap

PHP's str_* functions are global. Go's live in the strings package, so you import it. They're UTF-8 safe already: there's no separate mb_ set to remember.

3Raw strings

PHPraw.php
<?php
$path = 'C:\trains\eurostar';

$sql = <<<'SQL'
    SELECT * FROM departures
    WHERE dest = 'Köln Hbf'
    SQL;
GOraw.go
path := `C:\trains\eurostar`

sql := `
SELECT * FROM departures
WHERE dest = 'Köln Hbf'`

Mind the gap

Backticks are Go's nowdoc: no escapes, and they can span lines. There's no interpolation in any Go string, raw or not. Lesson 1.6 covers what to use instead.