Срезы и строки изнутри
Перед началом: сложность и измерения (модуль 1). Инструменты: Go 1.27,
slices,strings,strconv,unicode/utf8. Трудоёмкость: ~5 ч (теория 2 ч · практика 3 ч).
🔥 Инцидент: «Модерация портила эмодзи, а „исходные“ данные менялись сами»
Сервис модерации обрезал подписи «до 120 символов» через text[:120].
Для латиницы работало, а у постов с эмодзи и кириллицей фронтенд
показывал кракозябры: len считает байты, и обрезка попадала в середину
многобайтового символа. QA назвал это «кириллица съедает больше лимита» —
симптом вместо диагноза.
Вторая половина пайплайна фильтровала сообщения в буфер от исходного среза («не аллоцировать лишнего»). Функция возвращала новый срез и выглядела чистой — аргумент в теле не переприсваивался, — но результат писался в тот же массив, что и вход: страница «входящие как есть» показывала отфильтрованный дайджест поверх живых данных. В базе всё было в порядке, поэтому расхождение не ловилось.
Оба дефекта — про устройство двух самых частых типов Go. В модуле: заголовок среза, алиасинг, рост ёмкости, байты против рун и Builder.
📖 Теория
2.1 Заголовок среза: указатель, длина, ёмкость
Срез — три слова: указатель на массив, длина и ёмкость. Передача в функцию копирует заголовок, но массив остаётся общим.
gos := []int{1, 2, 3, 4, 5} part := s[1:3] // len=2, cap=4: ёмкость тянется до конца массива fmt.Println(len(part), cap(part)) // 2 4
Ёмкость подсреза тянется до конца массива — отсюда беды §2.3;
трёхиндексная форма s[1:3:3] задаёт ёмкость явно.
2.2 append и рост ёмкости
append возвращает новый заголовок — присваивать обязательно. Пока
ёмкости хватает, запись идёт в тот же массив; когда нет — runtime
выделяет новый, копирует элементы и добавляет. Выделение идёт с запасом:
в Go 1.27 первый append к nil-срезу []int сразу даёт cap=4, дальше
рост примерно удваивается (4 → 8 → 16) — амортизация из модуля 1 вживую.
govar s []int for i := 1; i <= 8; i++ { s = append(s, i) fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) } // len=1 cap=4 … len=5 cap=8 … len=9 → cap=16
Если размер известен заранее, make([]T, 0, n) убирает перевыделения
совсем; если нет — суммарная цена роста всё равно линейна.
2.3 Алиасинг: подсрез и append поверх общего массива
Классический баг: append к подсрезу, у которого осталась свободная
ёмкость, пишет в общий массив и затирает «соседа».
gobase := []int{10, 20, 30, 40, 50} part := base[:2] part = append(part, 99) // ёмкости хватило — base[2] стал 99 fmt.Println(base) // [10 20 99 40 50] safe := append(slices.Clone(base[:2]), 99) // копия живёт в своём массиве
Защита по возрастанию строгости: slices.Clone/copy — явная копия;
base[:2:2] — append обязан аллоцировать; make([]T, 0, n) — для
срезов, собираемых с нуля.
Отдельно — паттерн «переиспользовать буфер»: out := s[:0] — это не
пустой срез, а тот же массив. Он законен, когда исходные данные больше
не нужны (фильтрация на месте), и фатален, когда нужны: так сломался
фильтр из инцидента. Поведение закреплено спецификацией — на него нужен
тест.
2.4 Вставка и удаление в середине
Порядок сохраняем сдвигом хвоста через copy — O(n) по времени, ноль
аллокаций. Stdlib делает то же самое:
goa := []int{1, 2, 3, 4, 5} a = slices.Insert(a, 2, 99) // [1 2 99 3 4 5] — хвост сдвинут вправо a = slices.Delete(a, 2, 3) // [1 2 3 4 5] — хвост подтянут влево
Если порядок не важен, работает swap-remove — O(1) вместо O(n):
gofunc swapRemove(a []int, i int) []int { a[i] = a[len(a)-1] // на место удаляемого встаёт последний return a[:len(a)-1] }
Выбор диктует контракт данных: история событий порядок требует, «вытащить один флаг из набора» — нет.
2.5 Строки: байты, руны, Builder
Строка — неизменяемая последовательность байтов (обычно UTF-8). Отсюда
два правила: len(s) и s[i] — про байты (кириллица — 2 байта на
символ, эмодзи — до 4), а for range идёт по рунам, с индексами
в байтах.
gos := "Go – это 😀" fmt.Println(len(s), utf8.RuneCountInString(s)) // 18 байт, 10 рун cut := s[:len(s)-1] // эмодзи разорван fmt.Println(utf8.ValidString(cut)) // false — ровно баг инцидента
[]rune(s) даёт индексацию по символам, но это копия и лишняя память.
Разворот строки делайте по рунам; для составных эмодзи (ZWJ-семьи,
флаги) и рун мало — нужны графемные кластеры, которых в stdlib нет.
Сборка строк — только strings.Builder: + в цикле каждый раз копирует
всё накопленное, это O(n²) по объёму. Разбор коротких строк — Cut,
SplitN, Fields, TrimSpace и strconv:
govar b strings.Builder for _, line := range []string{"timeout=30", "retries=5"} { key, val, _ := strings.Cut(line, "=") // Cut — вместо Split + индекс if n, err := strconv.Atoi(val); err == nil { fmt.Fprintf(&b, "%s=%d;", key, n) } } fmt.Println(b.String()) // timeout=30;retries=5;
2.6 Компромиссы
| Решение | За | Против |
|---|---|---|
[]byte для изменяемых данных | Правки на месте, срезы без копий | Конверсия в string копирует |
string по умолчанию | Неизменяемость, дешёвый ключ map | Любая правка — новая аллокация |
strings.Builder | O(n) по объёму, мало аллокаций | Многословнее; для 2–3 кусков + короче |
[]rune для текста | Индексация по символам, корректный reverse | Копия строки и лишняя память |
| Delete со сдвигом | Порядок сохранён, ноль аллокаций | O(n) на сдвиг хвоста |
| swap-remove | O(1) | Порядок разрушен безвозвратно |
make([]T, 0, n) | Нет перевыделений | Завышенная ёмкость держит память |
Правило: сначала ответьте, важен ли порядок и останутся ли исходные данные нужны после операции. Первое выбирает между delete и swap-remove, второе — между копией и переиспользованием буфера.
💻 Практика
- Reverse по рунам. Напишите
reverseRunes(s string) stringи проверьте на"","abc","привет","a😀b". Объясните, почему после разворота"👨👩👧👦"перестаёт быть семьёй и что понадобилось бы вместо stdlib. - Баг алиасинга. Реализуйте фильтр запрещённых слов над
[]stringс буферомout := src[:0], напишите тест, ловящий порчу входа, и почините одним из способов:make,slices.Clone,slices.DeleteFunc. - Вставка и удаление. Через
copy(безslices.Insert/Delete) напишитеinsertAtиremoveAtс сохранением порядка плюсswapRemove. Табличные тесты: начало, середина, конец, пустой срез,iза границами; сверьте с поведениемslices. - Builder против
+=. Соберите строку из 10 000 фрагментовstrconv.Itoa(i)через+=и черезstrings.BuilderсGrow. Бенчмарк с-benchmem: сравнитеns/opиallocs/op, объясните результат копированием всей строки на каждой итерации+=. - Разбор строки конфига. Из
"name= vasya ;role=admin;timeout=30"соберитеmap[string]stringчерезstrings.CutиTrimSpace, отбросив пары с пустым ключом или значением; число проверьтеstrconv.Atoi. Ответьте: почемуCutдешевлеSplitна таком входе?
🧩 Quiz
len("Go – это 😀") в Go?s[:n:n]?append(base[:2], 99) может изменить элемент base[2]?s[1:3], если s — срез из 5 элементов?✅ Итоги модуля
- Срез — заголовок (ptr, len, cap): копируется заголовок, не данные, а ёмкость подсреза тянется до конца исходного массива.
appendпишет в общий массив, пока есть ёмкость; защита —Clone,copyи трёхиндексный срез.s[:0]законен, только когда исходные данные больше не нужны.- Удаление из середины: сдвиг хвоста — O(n) с порядком, swap-remove — O(1) без порядка.
- Строка:
len— байты,for range— руны, сборка —Builder, разбор —Cut/SplitN/strconv.
🔗 Мостик к Модулю 3
Теперь вы видите цену каждого индекса и копии. Дальше — приёмы, живущие на этих срезах: два указателя и скользящее окно. Подстроки там не копируются — границы окна двигаются, а строка остаётся одной.