Срезы и строки изнутри

Перед началом: сложность и измерения (модуль 1). Инструменты: Go 1.27, slices, strings, strconv, unicode/utf8. Трудоёмкость: ~5 ч (теория 2 ч · практика 3 ч).

🔥 Инцидент: «Модерация портила эмодзи, а „исходные“ данные менялись сами»

Сервис модерации обрезал подписи «до 120 символов» через text[:120]. Для латиницы работало, а у постов с эмодзи и кириллицей фронтенд показывал кракозябры: len считает байты, и обрезка попадала в середину многобайтового символа. QA назвал это «кириллица съедает больше лимита» — симптом вместо диагноза.

Вторая половина пайплайна фильтровала сообщения в буфер от исходного среза («не аллоцировать лишнего»). Функция возвращала новый срез и выглядела чистой — аргумент в теле не переприсваивался, — но результат писался в тот же массив, что и вход: страница «входящие как есть» показывала отфильтрованный дайджест поверх живых данных. В базе всё было в порядке, поэтому расхождение не ловилось.

Оба дефекта — про устройство двух самых частых типов Go. В модуле: заголовок среза, алиасинг, рост ёмкости, байты против рун и Builder.

📖 Теория

2.1 Заголовок среза: указатель, длина, ёмкость

Срез — три слова: указатель на массив, длина и ёмкость. Передача в функцию копирует заголовок, но массив остаётся общим.

go
s := []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 вживую.

go
var 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 к подсрезу, у которого осталась свободная ёмкость, пишет в общий массив и затирает «соседа».

go
base := []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 делает то же самое:

go
a := []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):

go
func 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 идёт по рунам, с индексами в байтах.

go
s := "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:

go
var 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.BuilderO(n) по объёму, мало аллокацийМногословнее; для 2–3 кусков + короче
[]rune для текстаИндексация по символам, корректный reverseКопия строки и лишняя память
Delete со сдвигомПорядок сохранён, ноль аллокацийO(n) на сдвиг хвоста
swap-removeO(1)Порядок разрушен безвозвратно
make([]T, 0, n)Нет перевыделенийЗавышенная ёмкость держит память

Правило: сначала ответьте, важен ли порядок и останутся ли исходные данные нужны после операции. Первое выбирает между delete и swap-remove, второе — между копией и переиспользованием буфера.

💻 Практика

  1. Reverse по рунам. Напишите reverseRunes(s string) string и проверьте на "", "abc", "привет", "a😀b". Объясните, почему после разворота "👨‍👩‍👧‍👦" перестаёт быть семьёй и что понадобилось бы вместо stdlib.
  2. Баг алиасинга. Реализуйте фильтр запрещённых слов над []string с буфером out := src[:0], напишите тест, ловящий порчу входа, и почините одним из способов: make, slices.Clone, slices.DeleteFunc.
  3. Вставка и удаление. Через copy (без slices.Insert/Delete) напишите insertAt и removeAt с сохранением порядка плюс swapRemove. Табличные тесты: начало, середина, конец, пустой срез, i за границами; сверьте с поведением slices.
  4. Builder против +=. Соберите строку из 10 000 фрагментов strconv.Itoa(i) через += и через strings.Builder с Grow. Бенчмарк с -benchmem: сравните ns/op и allocs/op, объясните результат копированием всей строки на каждой итерации +=.
  5. Разбор строки конфига. Из "name= vasya ;role=admin;timeout=30" соберите map[string]string через strings.Cut и TrimSpace, отбросив пары с пустым ключом или значением; число проверьте strconv.Atoi. Ответьте: почему Cut дешевле Split на таком входе?

🧩 Quiz

1. Чему равен len("Go – это 😀") в Go?
2. Когда swap-remove предпочтительнее удаления со сдвигом хвоста?
3. Зачем нужна трёхиндексная форма s[:n:n]?
4. Почему append(base[:2], 99) может изменить элемент base[2]?
5. Чему равны len и cap подсреза 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

Теперь вы видите цену каждого индекса и копии. Дальше — приёмы, живущие на этих срезах: два указателя и скользящее окно. Подстроки там не копируются — границы окна двигаются, а строка остаётся одной.