1. Введение
Если в обычном слайсе вы делаете append, часто всё выглядит логично: добавился элемент в конец — красота. Но как только появляется под‑слайс, который смотрит на середину общего массива, append внезапно может начать писать «в хвост» этого массива, перезаписывая элементы, которые вы считали чужими. И выглядит это обычно как мистическая порча памяти, хотя на самом деле это просто использование свободной ёмкости.
Представьте, что у вас есть маленькое учебное приложение: список задач в памяти. Пока без файлов и без структур — просто []string.
package main
import "fmt"
func main() {
tasks := []string{"learn Go", "drink water", "sleep"}
fmt.Println(tasks) // [learn Go drink water sleep]
}
Теперь допустим, вы хотите показать пользователю «первые две задачи» — то есть сделать view на начало:
package main
import "fmt"
func main() {
tasks := []string{"learn Go", "drink water", "sleep", "repeat"}
first := tasks[:2]
fmt.Println(first) // [learn Go drink water]
}
А теперь где-то внутри логики вы решили дописать в first подсказку (например, для UI или отчёта), и вы пишете:
first = append(first, "TIP: don't panic")
И вот тут возможен момент, когда вы внезапно видите, что у tasks поменялся третий элемент. Это не потому, что Go «вредный». Это потому, что у first мог быть cap больше, чем len, и append аккуратно использовал свободное место в том же backing array.
Кстати, сама идея append как «умного добавления» появилась именно для того, чтобы программист не писал руками расширение массива и copy на каждый рост. Go сознательно дал нам append, чтобы сократить сложность типового кода. Но у удобства есть цена: нужно понимать, что происходит с cap.
2. len и cap у под‑слайса: быстрый рефреш
Перед трёхиндексным срезом полезно ещё раз «поймать руками» механику:
- len — это сколько элементов вы видите,
- cap — сколько элементов вы можете потенциально видеть, если расширите окно (reslice/append), не переезжая на новый массив.
И самое важное: у под‑слайса cap обычно тянется до конца backing array, а не до конца len.
Давайте сделаем диагностический пример. Он маленький, но очень показательный:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d"}
first := tasks[:2]
fmt.Println(len(first), cap(first)) // 2 4
}
Почему cap(first) равен 4? Потому что first начинается с индекса 0 того же backing array, и до конца массива ещё есть место: потенциально можно расширять окно до 4 элементов.
А теперь под‑слайс из середины:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d", "e"}
mid := tasks[1:3] // видим b,c
fmt.Println(mid) // [b c]
fmt.Println(len(mid), cap(mid)) // 2 4 (обычно)
}
len(mid) = 2, а cap(mid) обычно будет cap(tasks) - 1, потому что окно начинается с индекса 1 и может «расти вправо» ещё достаточно далеко.
И вот теперь главный вывод, который нам нужен на сегодня: если у под‑слайса есть лишняя ёмкость (cap > len), то append может дописывать в тот же backing array и тем самым менять «чужие» элементы исходного слайса. Это прямое следствие модели slice header (pointer+len+cap).
3. Проблема append в под‑слайсе: почему ломаются соседние данные
Сейчас мы специально устроим маленькую «катастрофу», чтобы её потом красиво предотвратить. Важно: мы не делаем ничего запрещённого. Мы делаем то, что выглядит разумно, а потом удивляемся. Это почти 80% взрослого программирования (остальные 20% — ставить fmt.Printf в неожиданных местах).
package main
import "fmt"
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
first := tasks[:2] // [learn water], но cap может быть 4
first = append(first, "tip") // дописываем в "первый список"
fmt.Println(first) // [learn water tip]
fmt.Println(tasks) // [learn water tip repeat] ← сюрприз
}
Что произошло? first — это окно на backing array tasks. У first была свободная ёмкость, и append решил: «Зачем мне выделять новый массив, если в старом ещё есть место? Я просто запишу элемент в хвост». И записал "tip" туда, где раньше лежал "sleep".
Это поведение не случайность и не «оптимизация, которая иногда срабатывает». Это базовая модель append: если хватает cap, растём на месте; если не хватает — выделяем новый массив и копируем. Поэтому в Go так настойчиво повторяют «результат append всегда сохраняем»: append может вернуть слайс, указывающий либо на старый массив, либо на новый.
Нам нужна защита: мы хотим уметь сказать «вот тебе под‑слайс, но расти вправо ему нельзя». И вот здесь появляется герой лекции: полный (трёхиндексный) slice expression.
4. Полный slice expression s[a:b:c]: синтаксис и смысл
Трёхиндексный срез выглядит пугающе: s[a:b:c]. Но смысл у него очень инженерный и прямолинейный: мы задаём не только len, но и ограничиваем cap результата.
Формально для t := s[a:b:c] выполняются две формулы:
| Выражение | Что получится |
|---|---|
|
|
|
|
И есть правило допустимости границ:
0 <= a <= b <= c <= cap(s)
Обратите внимание: верхняя граница c сравнивается именно с cap(s), а не с len(s). Это логично: мы управляем ёмкостью, а ёмкость — это про backing array.
Почему я подчёркиваю «официальность»? Потому что этот синтаксис — часть языка и спецификации slice expressions. Это не «хак для избранных», а предусмотренный механизм: «я осознанно задаю границы видимости и границы роста».
5. Главный приём: сделать cap == len, чтобы append не портил соседей
Сейчас будет самое практичное: чаще всего вам не нужен произвольный c. Вам нужен простой и мощный паттерн — ограничить cap ровно до len, чтобы любой append вынужден был выделить новый backing array.
Для этого используют формы:
- s[:n:n] — берём первые n элементов и запрещаем рост вправо;
- общая форма s[a:b:b] — «cap заканчивается там же, где заканчивается len».
Сначала посмотрим разницу по cap:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d"}
view1 := tasks[:2] // len=2, cap=4
view2 := tasks[:2:2] // len=2, cap=2
fmt.Println(len(view1), cap(view1)) // 2 4
fmt.Println(len(view2), cap(view2)) // 2 2
}
А теперь — главный эффект. Сначала «опасный» вариант:
package main
import "fmt"
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
first := tasks[:2]
first = append(first, "tip")
fmt.Println(tasks) // [learn water tip repeat]
}
Теперь «защищённый» вариант:
package main
import "fmt"
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
first := tasks[:2:2] // cap ограничили
first = append(first, "tip")
fmt.Println(tasks) // [learn water sleep repeat]
fmt.Println(first) // [learn water tip]
}
Почему теперь tasks не меняется? Потому что у first cap == len == 2. Значит, добавить третий элемент «на месте» нельзя. append обязан создать новый backing array и перенести туда данные, чтобы разместить новый элемент.
Важный нюанс: s[a:b:c] не делает данные независимыми само по себе. До первого append у вас всё ещё общий backing array, и если вы меняете существующие элементы (например, first[0] = "X"), это изменит исходный tasks. Трёхиндексный срез защищает именно от записи «в хвост» через рост, а не от изменения уже существующих элементов.
6. Как выбирать c и что защищает cap‑limit
Как читать a:b:c, чтобы не путаться
Чтобы s[a:b:c] не выглядел как заклинание, держите простую «карту»:
- a — откуда начинается окно (start),
- b — где заканчивается длина (end of len),
- c — где заканчивается ёмкость (end of cap).
И всё это — в координатах исходного backing array.
Давайте наглядно на нашем tasks:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d", "e"}
mid := tasks[1:3:3] // start=1, endLen=3, endCap=3
fmt.Println(mid) // [b c]
fmt.Println(len(mid), cap(mid)) // 2 2
}
Здесь a = 1, b = 3, c = 3. Значит:
len = b-a = 3-1 = 2
cap = c-a = 3-1 = 2
То есть мы взяли "b", "c" и запретили этому виду расти вправо вообще.
Если попытаться сделать append(mid, "X"), append не сможет писать в хвост исходного массива (там дальше лежит "d"), и будет вынужден выделить новый backing array.
Мини‑схема: что именно защищает ограничение cap
Иногда проще понять глазами. Представим backing array как полку с ячейками, а слайс как рамку‑окно.
Допустим:
tasks backing array:
[0] a | [1] b | [2] c | [3] d | [4] e
Если вы берёте mid := tasks[1:3], то у mid окно на b, c, но cap обычно тянется до конца backing array:
mid len: 2 элемента (b, c)
mid cap: может расти вправо до e
И append(mid, "X") может записать "X" в позицию [3], перезаписав d.
Если вы берёте mid := tasks[1:3:3], то вы говорите: «моё окно заканчивается на 3, и потолок роста тоже на 3». То есть:
mid len: 2 элемента (b, c)
mid cap: 2 элемента (ровно b, c)
Расти вправо уже нельзя. Это и есть защита.
7. Практика: безопасный view для отчёта и отличие от копии
Пример: «вьюха для отчёта, которую можно дополнять»
Теперь соберём идею в сценарий, который реально встречается в коде. Допустим, у нас есть список задач, и мы хотим сформировать отдельный список для вывода: берём первые N задач и добавляем в конец строку "---" как разделитель. Мы хотим, чтобы добавление разделителя не меняло исходный список задач.
Сделаем функцию, которая возвращает «безопасный» view (с ограниченным cap). Мы используем функции, потому что так код ближе к реальной разработке.
package main
import "fmt"
func topView(tasks []string, n int) []string {
if n > len(tasks) {
n = len(tasks)
}
return tasks[:n:n] // cap == len, защита от append
}
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
view := topView(tasks, 2)
view = append(view, "---")
fmt.Println(tasks) // [learn water sleep repeat]
fmt.Println(view) // [learn water ---]
}
Здесь мы не делали копию руками. Мы просто вернули под‑слайс с «обрезанной ёмкостью», так что дальнейший append в view уже не может испортить исходный массив.
Но ещё раз (это важно): если вместо append вы сделаете view[0] = "X", то вы измените tasks[0]. Потому что это всё ещё view на те же элементы. Ограничение cap — про рост, а не про «владение данными».
Почему это не копия: честно различаем две стратегии
Очень легко попасть в самообман: «раз append после cap-limit не меняет исходный слайс, значит это копия». Нет. Это похоже на ситуацию «я поставил ограничитель скорости и теперь машина не разгоняется» — но машина всё та же.
cap-limit делает две вещи.
Во-первых, он запрещает под‑слайсу использовать чужой хвост backing array как свою «зону роста». Это именно то, что нужно, чтобы append не переписывал соседние элементы.
Во-вторых, он делает поведение более предсказуемым: как только вы попробуете увеличить слайс через append, произойдёт переезд в новый backing array. И это часто удобно, потому что вы получаете «новую область памяти» как побочный эффект роста.
Но если вам нужна независимость уже сейчас, без всякого роста, или если вы хотите гарантированно отделить данные для последующих изменений элементов, это уже другая стратегия. Важно просто не путать: s[a:b:c] — это про управление ёмкостью, а не про физическое копирование.
8. Типичные ошибки при работе с s[a:b:c]
Ошибка №1: думать, что s[a:b:c] — это «какой-то хак», который можно не знать.
На практике это официальный синтаксис slice expressions, и он существует ровно для инженерной задачи: контролировать cap результата. Это не «трюк для олимпиадников», а способ сделать код предсказуемым, особенно когда в проекте много функций, которые принимают/возвращают под‑слайсы.
Ошибка №2: ожидать, что s[a:b:c] делает копию элементов.
После sub := s[a:b:c] у вас по‑прежнему общий backing array. Если вы меняете существующие элементы sub[i] = ..., это влияет на исходный s. Ограничение cap защищает в основном от эффектов роста через append, но не отменяет aliasing.
Ошибка №3: путать роли индексов и писать «как почувствовал».
Полезно держать в голове: a — начало окна, b — конец длины, c — конец ёмкости. Если вы не можете быстро объяснить словами, что значит конкретная тройка a:b:c, лучше остановиться и посчитать len = b-a, cap = c-a на бумаге. Это быстрее, чем потом отлаживать «призрачную порчу данных».
Ошибка №4: нарушать порядок a <= b <= c и получать panic.
Трёхиндексный срез строже, чем кажется: нельзя поставить c меньше b, нельзя поставить b меньше a. Go не пытается «догадаться, что вы имели в виду», он честно падает, потому что границы окна не могут быть отрицательными или перепутанными.
Ошибка №5: забывать, что c сравнивается с cap(s), а не с len(s).
Новички часто мыслят «у меня же всего len элементов». Но cap может быть больше, особенно если слайс получился после append или make(..., len, cap). Поэтому допустимость c определяется ёмкостью backing array, а не текущей длиной окна.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ