JavaRush /Курсы /Go SELF /Типовые баги aliasing

Типовые баги aliasing

Go SELF
12 уровень , 5 лекция
Открыта

1. Откуда берутся “магические” баги со слайсами

Когда вы только начинаете программировать, хочется верить в простую сказку: «Если я сделал новый слайс, значит это новый список». И в некоторых языках так и бывает. Но Go — язык, который экономит вам копирования (и делает это довольно успешно), поэтому за кулисами часто оказывается, что вы создали не «новый список», а «новое окно на старые данные».

Слайс хранит pointer+len+cap (это официальная модель, которой удобно мыслить). Из-за этого два слайса могут иметь разные len, но один backing array. А append — вообще отдельный персонаж: он может создать новый массив, а может дописать в старый. И это «может» зависит от cap.

Именно тут рождается классика жанра: «я добавил элемент в под‑слайс, а у меня испортились данные в исходном слайсе». Давайте разберём, как это выглядит на практике — на примере маленького учебного приложения.

Мини‑приложение ToDo: режем список задач

Чтобы баги с aliasing были не абстрактными, а жизненными, представим, что мы пишем крошечный консольный планировщик. Пока без структур и файлов: просто список задач []string. Это соответствует нашему текущему уровню курса: строки и слайсы уже есть, а структуры мы оставим в покое.

Начнём с утилиты, которая печатает слайс вместе с len/cap, чтобы мы видели «скрытую ёмкость»:

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

Комментарий: dump — это наш «рентген». Он не покажет память напрямую, но покажет главное: есть ли у слайса запас cap, в который append теоретически может писать.

2. Баг №1: append в под‑слайс переписывает исходный слайс

Сейчас воспроизведём самый известный баг дня: берём «задачи на сегодня» как под‑слайс, делаем append, а потом видим, что «задачи на завтра» внезапно изменились.

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	tasks := []string{"почитать", "погулять", "помыть посуду", "поспать"}
	today := tasks[:2] // "окно" на первые две задачи

	dump("tasks", tasks)
	dump("today", today)

	today = append(today, "срочно: купить хлеб")

	dump("tasks", tasks)
	dump("today", today)
}

Один из типичных выводов может выглядеть так (у вас конкретные числа cap могут отличаться, но эффект важнее):

tasks: len=4 cap=4 [почитать погулять помыть посуду поспать]
today: len=2 cap=4 [почитать погулять]
tasks: len=4 cap=4 [почитать погулять срочно: купить хлеб поспать]
today: len=3 cap=4 [почитать погулять срочно: купить хлеб]

Мы добавили элемент в today, а изменился tasks[2]. Почему? Потому что today имел cap=4, то есть у него был доступный «хвост» в backing array, и append решил: «зачем выделять новый массив, если можно записать прямо сюда».

Можно представить это примерно так:

Backing array (tasks):
[почитать] [погулять] [помыть посуду] [поспать]
   ^           ^
   |           |
 today окно: [0..2) len=2, но cap позволяет дотянуться до конца массива

После append(today, "...") третий элемент backing array перезаписался, и исходный tasks это увидел.

4. Баг №2: “иногда работает” из‑за cap

Самая раздражающая часть этого бага в том, что он не всегда проявляется. И это делает его идеальным кандидатом в «пятничные» баги: в четверг работало, в пятницу упало, а в понедельник снова работает, потому что вы перезапустили программу и ёмкости распределились чуть иначе.

Здесь важно понять простое правило:

Если у слайса хватает cap, append может дописать в текущий backing array. Если не хватает — он вынужден выделить новый backing array и скопировать туда элементы.

Сделаем пример, где мы явно управляем cap, чтобы увидеть два разных поведения.

Случай A: cap маленький → append выделяет новый массив

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	tasks := make([]string, 2, 2)
	tasks[0] = "почитать"
	tasks[1] = "погулять"

	today := tasks[:] // len=2 cap=2

	today = append(today, "срочно: купить хлеб")

	dump("tasks", tasks) // tasks: [почитать погулять]
	dump("today", today) // today: [почитать погулять срочно: купить хлеб]
}

Здесь cap(today)==2, места нет, значит append создаёт новый backing array для today. Исходный tasks не меняется.

Случай B: cap большой → append пишет в общий массив

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	tasks := make([]string, 4, 4)
	tasks[0] = "почитать"
	tasks[1] = "погулять"
	tasks[2] = "помыть посуду"
	tasks[3] = "поспать"

	today := tasks[:2] // len=2 cap=4

	today = append(today, "срочно: купить хлеб")

	dump("tasks", tasks) // tasks[2] перезаписан
	dump("today", today)
}

Вот почему баг «плавающий»: он зависит от cap, а cap часто зависит от того, как именно вы собирали слайс до этого (через make, через append, через возврат из функции, и так далее).

5. Баг №3: функция вернула под‑слайс — и его “сломали” сверху

Теперь сделаем ситуацию, которая встречается в реальном коде чаще, чем хочется: у вас есть функция, которая возвращает под‑слайс (например, «задачи на сегодня»), а где-то выше по коду кто-то делает с ним append, считая, что это отдельный список.

Сначала напишем «наивную» функцию:

package main

func todayTasks(all []string) []string {
	if len(all) < 2 {
		return all
	}
	return all[:2] // под-слайс, общий backing array
}

И использование:

package main

import "fmt"

func todayTasks(all []string) []string {
	if len(all) < 2 {
		return all
	}
	return all[:2]
}

func main() {
	all := []string{"почитать", "погулять", "помыть посуду", "поспать"}
	today := todayTasks(all)

	today = append(today, "срочно: купить хлеб")

	fmt.Println(all)   // [почитать погулять срочно: купить хлеб поспать]
	fmt.Println(today) // [почитать погулять срочно: купить хлеб]
}

Технически функция todayTasks не сделала ничего «неправильного». Она честно вернула под‑слайс. Но с точки зрения продукта/логики приложения это может быть катастрофой: вы хотели «добавить в план на сегодня», а не «переписать задачу на завтра».

И вот это место важно почувствовать: aliasing — это не «баг языка». Это нормальная модель работы слайсов. Баг появляется, когда контракт владения не проговорён в коде.

Почему именно append особенно опасен

В прошлых темах мы уже закрепили правило «результат append всегда сохраняем». Сегодня добавим вторую часть, более коварную: append не только меняет len результата, он ещё и может писать в общий backing array.

Можно кратко сформулировать так:

append безопасен как операция, но опасен как намерение, если вы не уверены, кто владеет буфером.

Ситуация становится ещё неприятнее, если вы где-то сохраняете «оригинальные» данные, рассчитывая, что они неизменны:

original := tasks
today := tasks[:2]
today = append(today, "X")

// original и tasks — это тоже "окна" на тот же backing array

Тут original — не «копия», а такой же алиас.

6. Как чинить: три стратегии

Сейчас будет самая практичная часть лекции: что делать, чтобы append в под‑слайс не делал вам сюрпризы.

Я дам три стратегии. Они не «взаимоисключающие», и выбор зависит от того, что именно вы хотите по смыслу.

Небольшая таблица решений

Что вы хотите по смыслу Что делаем в коде Почему работает
«Я возвращаю view, но запрещаю расширять его через append» ограничиваем cap через s[a:b:b] append будет вынужден выделить новый массив
«Я возвращаю независимый список, его можно менять как угодно» делаем make + copy новый backing array, никакого aliasing
«Я возвращаю под‑слайс и честно разрешаю менять общий массив» ничего не делаем, но явно документируем контракт это тоже нормальный вариант, но должен быть осознанным

Дальше — разберём первые две стратегии на коде.

7. Защита №1: ограничиваем cap у под‑слайса

Этот приём — как поставить «ограничитель» на дверь: ходить можно, расширяться нельзя.

Если вы хотите вернуть «задачи на сегодня» как view, но при этом не хотите, чтобы кто-то смог append-ом залезть в хвост общего массива, делайте так:

package main

func todayTasksSafe(all []string) []string {
	if len(all) < 2 {
		return all[:len(all):len(all)] // cap=len, даже если len меньше 2
	}
	return all[:2:2] // len=2 cap=2
}

Обратите внимание на all[:2:2]: третий индекс — это «граница ёмкости». В результате cap у возвращённого слайса становится ровно len, и append не сможет дописать в хвост общего массива.

Проверим:

package main

import "fmt"

func todayTasksSafe(all []string) []string {
	if len(all) < 2 {
		return all[:len(all):len(all)]
	}
	return all[:2:2]
}

func main() {
	all := []string{"почитать", "погулять", "помыть посуду", "поспать"}
	today := todayTasksSafe(all)

	today = append(today, "срочно: купить хлеб")

	fmt.Println(all)   // [почитать погулять помыть посуду поспать]
	fmt.Println(today) // [почитать погулять срочно: купить хлеб]
}

Здесь append всё равно сработает, но он будет вынужден выделить новый backing array для today, потому что cap(today)==2.

Очень важный нюанс: ограничение cap не делает данные независимыми. Если вы сделаете today[0] = "...", это всё равно изменит all[0], потому что элементы разделяются. Этот приём защищает именно от «роста в хвост».

8. Защита №2: делаем копию через copy

Если по смыслу вам нужна полноценная копия (например, «возвращаю отдельный список задач на сегодня, который дальше редактируется как черновик»), то правильный путь — создать новый слайс и скопировать элементы.

Сделаем функцию «независимые задачи на сегодня»:

package main

func todayTasksCopy(all []string) []string {
	n := 2
	if len(all) < n {
		n = len(all)
	}

	src := all[:n]
	dst := make([]string, len(src))
	copy(dst, src)

	return dst
}

И проверим:

package main

import "fmt"

func todayTasksCopy(all []string) []string {
	n := 2
	if len(all) < n {
		n = len(all)
	}
	src := all[:n]
	dst := make([]string, len(src))
	copy(dst, src)
	return dst
}

func main() {
	all := []string{"почитать", "погулять", "помыть посуду", "поспать"}
	today := todayTasksCopy(all)

	today[0] = "переписал в копии"
	today = append(today, "срочно: купить хлеб")

	fmt.Println(all)   // [почитать погулять помыть посуду поспать]
	fmt.Println(today) // [переписал в копии погулять срочно: купить хлеб]
}

Теперь today полностью независим: можно менять элементы, можно расширять, можно делать что угодно — исходный all не пострадает.

Да, это копирование, и оно стоит ресурсов. Но обычно дешевле один раз скопировать, чем потом три дня искать, почему «в списке задач внезапно поменялся третий элемент» (а поменялся он, потому что кто-то в другом месте делал append).

9. Как диагностировать aliasing на месте

Когда вы ловите странное поведение, полезно мыслить как детектив: «где у этого слайса лишний cap

Самый простой практический приём на нашем уровне курса — временно печатать len/cap для подозрительных слайсов и смотреть, есть ли у под‑слайса запас:

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	all := []string{"A", "B", "C", "D"}
	sub := all[:2]

	dump("all", all) // all: len=4 cap=4 ...
	dump("sub", sub) // sub: len=2 cap=4 ... <-- вот “лишняя ёмкость”
}

Если вы видите cap(sub) > len(sub), то append(sub, "...") потенциально способен писать в общий backing array. Не всегда напишет (иногда Go решит по-другому, например если append вызывает рост больше, чем оставшийся cap), но риск уже есть.

И это как раз тот момент, когда стоит либо ограничить cap, либо копировать, либо явно запретить append по контракту.

10. Типичные ошибки

Ошибка №1: думать, что sub := s[a:b] создаёт “новый список”.
На практике это почти всегда просто новое окно на те же данные. Если дальше вы меняете элементы под‑слайса, вы меняете backing array, а значит — потенциально меняете и исходный слайс. Это не баг компилятора и не «память сломалась», это нормальная модель слайсов.

Ошибка №2: делать append в под‑слайс и удивляться, что испортился s.
Если у под‑слайса cap > len, то append может записать в «хвост» общего массива. Внешне это выглядит как «я добавил элемент в одно место, а изменилось другое». Исправляется либо ограничением cap (s[a:b:b]), либо копированием.

Ошибка №3: надеяться, что “вроде не ломается, значит всё хорошо”.
Самые вредные баги — плавающие. Сегодня append выделил новый массив и всё красиво, завтра — дописал в общий буфер и вы получили сюрприз. Если логика требует независимости, делайте её явно через copy. Если логика требует «view без роста» — делайте cap-limit явно.

Ошибка №4: путать “защиту от append” и “полную независимость”.
all[:2:2] защищает только от роста, но не от изменения элементов. Если вы хотите полный разрыв связи, нужен make + copy. Часто новички делают cap-limit и уверены, что теперь «это копия», а потом меняют today[0] и удивляются, что изменился all[0].

Ошибка №5: возвращать под‑слайс из функции без оговорённого контракта.
Когда функция возвращает all[:2], вызывающий код легко решит, что это «отдельный результат», и начнёт делать append. В результате ломается исходный список, и виноват как будто бы «тот, кто писал функцию», хотя формально он сделал всё допустимое. Хорошая практика — либо возвращать all[:2:2] (если это view), либо возвращать копию (если это результат, который будут менять).

1
Задача
Go SELF, 12 уровень, 5 лекция
Недоступна
Общий буфер
Общий буфер
1
Задача
Go SELF, 12 уровень, 5 лекция
Недоступна
Новый массив
Новый массив
1
Задача
Go SELF, 12 уровень, 5 лекция
Недоступна
Безопасный просмотр
Безопасный просмотр
1
Задача
Go SELF, 12 уровень, 5 лекция
Недоступна
Независимая копия
Независимая копия
1
Опрос
Срезы и aliasing, 12 уровень, 5 лекция
Недоступен
Срезы и aliasing
Срезы и aliasing
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ