Раздел 22 · Go

Строки в Go: байты, руны, функции, билдер

junior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Перед этим уроком

Строки в Go: байты, руны, функции, билдер

Самая большая ловушка строк в Go: они выглядят как массив символов, но на самом деле это immutable view над байтами в UTF-8. Если прийти из Python, JS или Java, где s[0] это символ, в Go s[0] это байт. И от понимания этого зависит, не уйдёт ли твой код в Production с искажённой кириллицей.

После этого урока ты будешь знать, что лежит в string, как ходить по символам, какие восемь функций пакета strings нужны на 90%, и почему конкатенация плюсом в цикле, это худшее, что можно сделать.

1. string внутри это {pointer, len}

В Go string это маленькая структура из двух полей:

type stringHeader struct {
    Data unsafe.Pointer // указатель на массив байт
    Len  int            // длина в байтах
}

Размер на 64-bit, 16 байт. Сами байты лежат отдельно. Что это значит на практике:

  • строки immutable, поменять байт нельзя: s[0] = 'a' это ошибка компиляции;
  • len(s), длина в байтах, не в символах;
  • нарезка s[i:j] дёшева, не копирует байты, новая string ссылается на тот же backing-массив;
  • сравнение s1 == s2 проходит побайтно, поэтому “две одинаковые строки” всегда равны.
s := "ёж"            // Йоо плюс эж, 4 байта в UTF-8
fmt.Println(len(s))  // 4, не 2
fmt.Println(s[0])    // 209, первый байт UTF-8 для ё
fmt.Printf("%x\n", s) // d191d0b6

Если в Java String.length() это всегда количество кодпоинтов (с оговорками про surrogate pairs), в Go len это всегда байты. Чтобы получить количество “символов”, нужны руны.

2. Символы и руны

В Go тип одного символа называется rune. Это просто алиас для int32. То есть руна это число, и в одной руне лежит один Unicode code point.

var r rune = 'я'        // 0x044F, кириллическая я
fmt.Println(r)          // 1103
fmt.Printf("%c %U\n", r, r) // я U+044F

Разница между байтом и руной:

ТипРазмерЧто хранитКак итерировать строку
Байтbyte (= uint8)1один байт UTF-8-последовательностиfor i := 0; i < len(s); i++ { _ = s[i] }
Рунаrune (= int32)4один Unicode code pointfor i, r := range s { _ = r }

Главная пара итераций:

s := "ёж"

// 1. По байтам, индекс это байт
for i := 0; i < len(s); i++ {
    fmt.Printf("%d: %x\n", i, s[i])
}
// 0: d1
// 1: 91
// 2: d0
// 3: b6

// 2. По рунам, индекс, это начало рун в байтах
for i, r := range s {
    fmt.Printf("%d: %c (%U)\n", i, r, r)
}
// 0: ё (U+0451)
// 2: ж (U+0436)

Заметь: во втором цикле i это байтовый индекс начала руны, не номер руны по порядку. Если хочешь “0, 1, 2”, считай отдельно.

3. string и срез рун

Когда ты хочешь работать с символами как с массивом, нужно конвертировать в []rune:

s := "ёжик"
rs := []rune(s)             // [1105 1078 1080 1082], 16 байт (4 рун * 4 байт)
fmt.Println(len(rs))        // 4 рун
fmt.Println(string(rs[1]))  // "ж"

И обратно:

back := string(rs)          // "ёжик", опять 8 байт UTF-8

Что важно понимать про эту конвертацию:

  • []rune(s) копирует и аллоцирует. Это не дёшево: выделяется новый массив int32 размера RuneCount(s). Не делай в горячем цикле.
  • string(rs) тоже копирует, новая строка с новым backing-массивом.
  • Для одиночных операций (взять символ по индексу), часто проще использовать utf8.DecodeRuneInString:
import "unicode/utf8"

s := "ёжик"
r, size := utf8.DecodeRuneInString(s) // r='ё', size=2
fmt.Println(r, size)

Подсчёт без аллокации []rune:

n := utf8.RuneCountInString(s)
fmt.Println(n) // 4

Реверс строки с уважением к Unicode (классическая задачка с собесов):

func ReverseRunes(s string) string {
    rs := []rune(s)
    for i, j := 0, len(rs)-1; i < j; i, j = i+1, j-1 {
        rs[i], rs[j] = rs[j], rs[i]
    }
    return string(rs)
}

ReverseRunes("привет") // "тевирп"
ReverseRunes("hi🙂")    // "🙂ih", не "??ih"

Если делать через []byte, эмодзи и кириллица “развалятся” по байтам.

4. Функции строк, часть 1: поиск и нарезка

Пакет strings это около 50 функций, но за всю карьеру ты будешь использовать примерно 15. Вот первая половина, поиск и нарезка:

ФункцияЧто делаетПример
strings.Contains(s, sub)есть ли подстрокаContains("hello", "ell") // true
strings.HasPrefix(s, p)начинается ли сHasPrefix("go.mod", "go") // true
strings.HasSuffix(s, p)заканчивается лиHasSuffix("file.go", ".go") // true
strings.Index(s, sub)байтовый индекс первого вхождения, или -1Index("hello", "l") // 2
strings.LastIndex(s, sub)байтовый индекс последнего вхожденияLastIndex("a/b/c", "/") // 3
strings.Count(s, sub)сколько непересекающихся вхожденийCount("banana", "a") // 3
strings.Split(s, sep)нарезать на подстроки по разделителюSplit("a,b,c", ",") // ["a","b","c"]
strings.SplitN(s, sep, n)то же, но не больше n частейSplitN("a,b,c,d", ",", 2) // ["a","b,c,d"]
strings.Join(parts, sep)склеить срез строк через разделительJoin([]string{"a","b"}, ",") // "a,b"
strings.Fields(s)разбить по любому пробельному рядуFields(" a b\tc ") // ["a","b","c"]

Минимальная демо-программа:

package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "hello, world, hello"

    fmt.Println(strings.Contains(s, "world")) // true
    fmt.Println(strings.Index(s, "hello"))    // 0
    fmt.Println(strings.LastIndex(s, "hello"))// 14
    fmt.Println(strings.Count(s, "hello"))    // 2
    fmt.Println(strings.Split(s, ", "))       // [hello world hello]
    fmt.Println(strings.Join([]string{"a","b","c"}, "-")) // a-b-c
}

Все они работают на байтовом уровне. То есть Contains("ёж", "ё") ожидаемо true, потому что “ё” в UTF-8 это два конкретных байта, которые точно так же идут и в “ёж”. Главное, не путать с “первой буквой”, s[0] это не символ.

5. Функции строк, часть 2: трансформации

Вторая половина, операции, которые возвращают новую строку (помни, оригинал immutable):

ФункцияЧто делаетПример
strings.ToLower(s)в нижний регистрToLower("Hello") // "hello"
strings.ToUpper(s)в верхнийToUpper("Hello") // "HELLO"
strings.Title(s)каждое слово с большой (deprecated, см. ниже)Title("hello world") // "Hello World"
strings.Replace(s, old, new, n)заменить первые n вхождений (-1, все)Replace("aaa", "a", "b", 2) // "bba"
strings.ReplaceAll(s, old, new)заменить всеReplaceAll("aaa", "a", "b") // "bbb"
strings.Trim(s, cut)убрать с обоих концов байты из cutTrim("--x--", "-") // "x"
strings.TrimSpace(s)убрать пробелы с обоих концовTrimSpace(" x\n") // "x"
strings.TrimPrefix(s, p)убрать префикс если естьTrimPrefix("file.go", "file.") // "go"
strings.TrimSuffix(s, p)убрать суффикс если естьTrimSuffix("file.go", ".go") // "file"
strings.Repeat(s, n)повторить строку n разRepeat("ab", 3) // "ababab"
strings.Map(f, s)применить функцию к каждой рунеMap(unicode.ToUpper, "go") // "GO"

Про Title. Со времён Go 1.18 функция помечена deprecated, потому что она не понимает Unicode word-boundaries (например, апостроф в “O’Brien”). Используй golang.org/x/text/cases:

import "golang.org/x/text/cases"
import "golang.org/x/text/language"

caser := cases.Title(language.Russian)
fmt.Println(caser.String("привет, мир")) // "Привет, Мир"

Для джуновских задач сойдёт и старый strings.Title, но в проде лучше нормально.

Replacer. Если хочешь сделать много замен за один проход:

r := strings.NewReplacer("a", "1", "b", "2", "c", "3")
fmt.Println(r.Replace("abc")) // "123"

Это быстрее цепочки ReplaceAll, потому что один проход по строке.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.