1. Зачем разбирать общий underlying array
Представьте, что вы делаете аккуратный «кусочек» слайса: взяли первые 3 элемента, что-то поменяли, а потом внезапно обнаружили, что «поменялось ещё где-то». С первого взгляда это похоже на баг, с третьего — на мистику, а с пятого — на закономерность.
Сегодня мы как раз разберём эту закономерность: под‑слайсы обычно делят один и тот же backing/underlying array, поэтому изменения через один слайс данных видны через другой. Эта модель — не ошибка в Go, а дизайн: он даёт скорость и экономию памяти, но требует внимательности.
Ментальная модель: слайс — это «окно»
Если вы раньше думали о слайсе как о «мини‑массиве», то вы не одиноки: это очень распространённая иллюзия (примерно как «Git — это флешка для кода»). Но в Go слайс — это маленькая структура‑описание: где лежат данные, сколько их «видно» и сколько можно «видеть при росте».
В упрощённой формулировке слайс хранит pointer + length + capacity , и два слайса могут смотреть на один и тот же массив.
Давайте изобразим это схемой. Пусть у нас есть один underlying array, а на него смотрят два слайса разными «окнами»:
underlying array (данные):
индексы: 0 1 2 3 4
[A] [B] [C] [D] [E]
s := s[0:5] видит A B C D E
t := s[1:4] видит B C D
Важно: данные (A..E) физически одни, а «окна» (s и t) — разные.
2. Под‑слайсы на примере списка задач
Чтобы не играться абстрактными A B C, давайте продолжим наше учебное консольное приложение — мини‑менеджер задач. Пока без структур и модных слов, просто список строк.
Мы хотим вывести «сегодняшние задачи» как первые N задач из общего списка. И вот тут обычно появляется первая ловушка: мы берём под‑слайс, считаем, что это «копия», меняем его — и удивляемся.
Делаем список задач и «вид на первые две»
Сначала минимальный код, который просто режет слайс:
package main
import "fmt"
func main() {
tasks := []string{"купить хлеб", "прочитать главу", "погулять"}
today := tasks[:2]
fmt.Println("tasks:", tasks) // tasks: [купить хлеб прочитать главу погулять]
fmt.Println("today:", today) // today: [купить хлеб прочитать главу]
}
Пока всё выглядит безобидно: today — это «первые две задачи».
Меняем today — и неожиданно меняется tasks
Теперь добавим одну строку, которая «помечает задачу выполненной» (условно, просто префиксом):
package main
import "fmt"
func main() {
tasks := []string{"купить хлеб", "прочитать главу", "погулять"}
today := tasks[:2]
today[0] = "[x] " + today[0]
fmt.Println("today:", today) // today: [[x] купить хлеб прочитать главу]
fmt.Println("tasks:", tasks) // tasks: [[x] купить хлеб прочитать главу погулять]
}
Почему так? Потому что today[0] и tasks[0] — это одна и та же ячейка underlying array, просто до неё можно добраться разными «окнами».
4. Как проявляется связь: запись, функции, два окна
В этом разделе закрепим идею «общего underlying array» тремя способами: через адреса, через передачу в функцию и через два разных под‑слайса.
Проверяем связь руками: одинаковые адреса элементов
Сейчас будет маленький трюк, который помогает мозгу поверить в происходящее. Мы уже встречались с &x во вводе через fmt.Scan, как с идеей «куда записать значение». Здесь используем & в том же духе: «где лежит элемент».
Мы выведем адрес первого элемента в tasks и адрес первого элемента в today. Если underlying array общий и начало окна совпадает — адреса будут одинаковыми.
package main
import "fmt"
func main() {
tasks := []string{"купить хлеб", "прочитать главу", "погулять"}
today := tasks[:2]
fmt.Printf("%p\n", &tasks[0]) // например: 0xc0000a4000
fmt.Printf("%p\n", &today[0]) // например: 0xc0000a4000
}
Числа будут другими на вашем запуске — это нормально. Важное наблюдение: они совпадают. Это и есть «общая память» на практике.
Aliasing в разрезе функций: «передали в функцию — она поменяла список»
Новички часто ожидают, что «если я передал что-то в функцию, то это копия». В Go это иногда так (например, массивы [N]T копируются), но со слайсами ситуация хитрее: в функцию передаётся копия заголовка слайса, а underlying array остаётся общим.
Именно поэтому изменение элементов внутри функции видно снаружи: мы меняем не «локальную копию данных», а общие данные.
Сделаем функцию markDoneFirst, которая помечает первую задачу выполненной:
package main
import "fmt"
func markDoneFirst(tasks []string) {
if len(tasks) == 0 {
return
}
tasks[0] = "[x] " + tasks[0]
}
func main() {
all := []string{"купить хлеб", "прочитать главу"}
markDoneFirst(all)
fmt.Println(all) // [[x] купить хлеб прочитать главу]
}
В этом нет «магии» — это прямое следствие общей памяти.
Два под‑слайса и эффект «протекания» изменений
Иногда проблема проявляется не между под‑слайс ↔ исходный, а между двумя под‑слайсами. Оба смотрят на один underlying array, и если их области пересекаются (или даже просто лежат в одном массиве), изменения могут быть неожиданными.
Представим, что мы делим задачи на «утро» и «вечер» как два окна:
package main
import "fmt"
func main() {
tasks := []string{"зарядка", "работа", "прогулка", "сон"}
morning := tasks[:2] // зарядка, работа
evening := tasks[2:] // прогулка, сон
evening[0] = "[x] " + evening[0]
fmt.Println("tasks: ", tasks) // tasks: [зарядка работа [x] прогулка сон]
fmt.Println("morning:", morning) // morning: [зарядка работа]
fmt.Println("evening:", evening) // evening: [[x] прогулка сон]
}
Здесь morning не изменился, потому что мы поменяли элемент tasks[2], а morning смотрит только на tasks[0] и tasks[1]. Но важный факт сохраняется: изменения идут в общий underlying array, просто не всегда видны каждому окну (всё зависит от диапазона).
5. Append и cap: как под‑слайс портит соседей
Сейчас мы подойдём к сценарию, который чаще всего вызывает настоящую боль. Он звучит примерно так: «Я взял первые 2 элемента, сделал append, а у меня внезапно поменялся третий элемент исходного слайса».
Это опять же не мистика. Если у под‑слайса есть запас по cap, то append может дописать данные в тот же underlying array — то есть прямо рядом с тем, что вы считали «чужими данными». В официальных материалах про слайсы постоянно подчёркивают, что разные слайсы могут разделять один массив, и операции, меняющие содержимое массива, влияют на всех наблюдателей.
Покажем на задачах:
package main
import "fmt"
func main() {
tasks := []string{"A", "B", "C", "D"}
head := tasks[:2] // len=2, cap обычно 4
head = append(head, "X") // дописываем третий элемент
fmt.Println("head: ", head) // head: [A B X]
fmt.Println("tasks:", tasks) // tasks: [A B X D]
}
Почему C превратилось в X? Потому что head «видел» запас ёмкости и append решил: «Зачем мне выделять новый массив, если можно дописать в этот?» И дописал в ячейку tasks[2].
Здесь важно не запомнить «магическое поведение append», а понять правило: если рост укладывается в cap — дописываем в тот же массив.
6. Когда sharing полезен и когда опасен
Если честно, underlying sharing — это одна из причин, почему Go часто быстрый «из коробки». Вместо того чтобы копировать большие куски памяти при каждом s[a:b], язык делает дешёвую операцию: создаёт новый заголовок слайса, который смотрит на ту же память. Получается быстро и экономно: вы можете нарезать «окна» для обработки данных почти бесплатно.
Но обратная сторона — ответственность. Если вы отдаёте под‑слайс наружу (например, возвращаете из функции) и кто-то его меняет, он меняет общие данные. Если вы внутри функции взяли под‑слайс и сделали append, вы могли случайно перезаписать хвост исходного массива. Это не «плохой Go», это «Go, который честно оптимизирует то, что вы попросили».
Чтобы закрепить, вот маленькая табличка, как думать об операциях:
| Действие | Что обычно происходит | Почему это важно |
|---|---|---|
|
Создаётся новое «окно», данные не копируются | sub и s связаны |
|
Меняется элемент underlying array | Изменения видны всем слайсам, которые его «видят» |
|
Запись идёт в тот же массив (если хватает cap) | Можно перезаписать «соседние» элементы исходного слайса |
|
Выделяется новый массив и копируются элементы (если cap не хватает) | Связь может «разорваться», но полагаться на это как на контракт опасно |
Мини‑сюжет: «витрина задач» и неожиданные изменения
Давайте соберём реалистичную ситуацию. Допустим, мы хотим показывать «витрину» первых задач (например, первые 3), а затем отдельно работать со всем списком.
Наивно можно сделать так: preview := tasks[:3], а потом в превью «подправить формат» (например, добавить номера). Но это будет менять исходный список.
Вот небольшой пример, который демонстрирует проблему:
package main
import "fmt"
func main() {
tasks := []string{"купить хлеб", "прочитать главу", "погулять"}
preview := tasks[:2]
preview[1] = "2) " + preview[1]
fmt.Println("preview:", preview) // preview: [купить хлеб 2) прочитать главу]
fmt.Println("tasks: ", tasks) // tasks: [купить хлеб 2) прочитать главу погулять]
}
Если ваша «витрина» должна быть только представлением, а не модификацией данных, то такая запись — логическая ошибка: мы нечаянно смешали «как хранится» и «как показывается».
На этом месте обычно рождается полезное правило проектирования: если функция или кусок кода должен только читать, старайтесь не делать записей в слайс вообще (и особенно — в под‑слайсы).
7. Типичные ошибки
Ошибка №1: думать, что sub := s[a:b] создаёт копию элементов.
Это очень естественная ошибка: визуально sub выглядит как отдельный список. Но на деле это всего лишь другое окно на те же данные. Если вы записываете в sub[i], вы записываете в underlying array, и это может отразиться в s. Поэтому в местах, где нужен «снимок данных», нельзя ограничиваться нарезкой.
Ошибка №2: считать, что t := s — это копирование.
Присваивание слайса копирует только заголовок. В результате t и s смотрят на один и тот же underlying array. Такая ошибка особенно неприятна в коде, который «для аккуратности» делает tmp := original, а потом «безопасно» меняет tmp. Безопасность там только в названии переменной.
Ошибка №3: модифицировать под‑слайс в функции, которая по смыслу должна только читать.
Часто баг не в Go и не в слайсах, а в контракте функции. Если функция называется условно printTasks, а внутри вдруг делает tasks[0] = ..., то это почти всегда сюрприз для вызывающего кода. Даже если «технически работает», позже это превращается в очень трудноуловимые проблемы.
Ошибка №4: неожиданные побочные эффекты из‑за append в под‑слайс.
Когда у под‑слайса есть запас по cap, append может писать в тот же массив и перезаписывать элементы «рядом». Это выглядит как «сломалась память» или «Go иногда чудит», хотя на самом деле это закономерное следствие общей ёмкости. Если вы видите, что после append изменились данные в исходном слайсе, почти всегда причина именно в этом.
Ошибка №5: пытаться «лечить» проблему отладочной печатью, не понимая модели.
Очень хочется добавить fmt.Println в десяти местах и ловить, где «впервые сломалось». Печать помогает, но без модели underlying sharing вы будете лечить симптомы: добавите лишнее копирование там, где оно не нужно, или наоборот, оставите опасный участок. Гораздо надёжнее один раз зафиксировать: под‑слайсы связаны, потому что данные общие, а слайс — это лишь окно на них.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ