Строки в 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 point | for 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) | байтовый индекс первого вхождения, или -1 | Index("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) | убрать с обоих концов байты из cut | Trim("--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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.