Массив представляет собой значение фиксированного размера, и длина входит в его тип, поэтому присваивание или передача копирует все элементы; слайс устроен как маленький заголовок (указатель на массив, len и cap), который смотрит на часть массива, общего с другими слайсами.
len показывает, сколько элементов можно индексировать сейчас; cap показывает, сколько поместится до конца нижележащего массива, то есть как далеко append может зайти без аллокации.
[4]int и [5]int являются разными типами, и функция, принимающая [4]int, ничего другого не примет. Поэтому массивы редко встречаются в сигнатурах.
При копировании слайса копируется только заголовок (24 байта на 64-битной платформе), поэтому обе копии видят записи друг друга в s[i].
Массивы сравниваются через == и годятся в ключи мапы; слайсы сравниваются только с nil, а для равенства есть slices.Equal.
Почти всегда нужен слайс, а массив берут для значения фиксированной ширины, вроде [32]byte из sha256.Sum256.
GoJunior
Слайсы, мапы и строки
Заголовок слайса, алиасинг при append, внутренности map, руны и strings.Builder
Закрыто вопросов
0 / 24
- 0Повторить
- 9Часто спрашивают
Вопросы
Нулевое значение слайса равно **nil: заголовок с nil-указателем, len 0 и cap 0**. У пустого слайса вроде []int{} тоже len 0, но указатель не nil. Почти весь код обращается с ними одинаково.
len, cap, range, append и s[:0] работают с nil-слайсом. append сам выделит массив, так что делать make перед добавлением не нужно.
Пустоту проверяйте через len(s) == 0, а не s == nil: второе пропустит пустые слайсы, которые не nil.
Разница видна в encoding/json: nil кодируется как null, а пустой слайс кодируется как []. Клиент, который вызывает .length у этого поля, падает на null, поэтому полю API, обещающему список, нужен make([]T, 0, n).
reflect.DeepEqual тоже их различает, а slices.Equal считает равными.
make([]T, n) создаёт слайс, в котором уже лежат n нулевых значений (len n); make([]T, 0, n) пуст, но с местом на n элементов (len 0, cap n). append всегда пишет после len, поэтому при добавлении в первый перед вашими данными останутся n нулевых значений.
Два правильных варианта: make([]T, n) и присваивание out[i] = v или make([]T, 0, n) и out = append(out, v). Их смешение и есть баг.
Ёмкость, зарезервированная заранее, экономит повторные «вырасти и скопируй», которые append делает, когда места не осталось.
Подсказка может быть приблизительной: слишком большая тратит немного памяти, слишком маленькая просто отдаёт дело обычному росту.
b := a копирует только заголовок, и оба слайса по-прежнему делят один массив; настоящей копии нужна новая память: **slices.Clone(a)** (Go 1.21+) или make нужной длины плюс copy. copy учитывает len, а не cap, поэтому в make([]T, 0, n) он ничего не скопирует.
copy(dst, src) копирует min(len(dst), len(src)) элементов и возвращает это число. Смотрит он на длину, а не на ёмкость, поэтому приёмник, созданный через make([]T, 0, n), не получит ничего.
append([]T(nil), a...): идиома постарше. Пустой a она превращает в nil, что важно для JSON; slices.Clone сохраняет nil как nil, а пустой слайс как пустой.
Все эти копии поверхностные: если элементы являются указателями, мапами или слайсами, обе копии указывают на одни и те же данные.
Чтобы склеить слайсы в новую память, есть slices.Concat(a, b) (Go 1.22+).
Выведет [{a 1} {b 2}]: в переменную it в range попадает копия каждого элемента, поэтому it.Count++ меняет копию, а слайс остаётся прежним.
Вместо этого обращайтесь по индексу: for i := range items { items[i].Count++ } пишет в сам элемент.
Или возьмите указатель на элемент, p := &items[i], если меняете несколько полей.
В []*Item копией будет указатель, поэтому it.Count++ доберётся до общей структуры.
Go 1.22 сделал it новой переменной на каждой итерации, что починило замыкания, которые её захватывают. Но это по-прежнему копия.
Строка в Go устроена как неизменяемая последовательность байтов, обычно в UTF-8, поэтому len считает байты, а s[0] даёт один byte. В Дмитрий 7 букв, каждая кириллическая буква занимает 2 байта, итого 14.
Не-ASCII символ занимает от 2 до 4 байт. name[0] возвращает 208, первую половину Д, а срез посреди буквы даёт невалидный UTF-8.
Символы считает utf8.RuneCountInString(s). rune (псевдоним int32) представляет одну кодовую точку Unicode; []rune(s) позволяет индексироваться по символам ценой копирования.
for i, r := range s декодирует UTF-8 за вас: r содержит руну, а i хранит смещение в байтах, поэтому оно прыгает на 1–4.
s[0] = 'x' не компилируется. Чтобы изменить строку, переведите её в []byte или []rune, измените и переведите обратно; обе конвертации копируют.
Чтение из мапы никогда не падает: отсутствующий ключ даёт нулевое значение, поэтому наличие проверяют отдельно, формой comma-ok v, ok := m[k], где ok равен true, только если ключ действительно есть; запись в nil-мапу паникует, чтение из неё безопасно.
_, ok := m[k] проверяет наличие ключа; так же проверяют элемент множества на map[string]struct{}.
Именно нулевое значение делает рабочим counts[word]++ на свежей мапе и m[k] = append(m[k], v) для ключа, под которым слайса ещё нет.
Nil-мапа (var m map[string]int) при чтении ведёт себя как пустая: m[k], len, range и delete работают. Запись паникует с assignment to entry in nil map.
Создавайте мапу через make(map[K]V) или литерал до первой записи, обычно в конструкторе структуры, которая её хранит.
Спецификация не определяет порядок обхода мапы, а **рантайм намеренно начинает каждый range со случайной позиции**, чтобы никакой код не начал на него полагаться. Для стабильного вывода отсортируйте ключи и обходите их.
Так реализация остаётся свободной для изменений (и она изменилась, на Swiss tables в Go 1.24), а код, зависящий от порядка, ломается сразу, а не после обновления.
Маленькая мапа может какое-то время выглядеть стабильной, поэтому обычно это проявляется как флаки-тест, а не как падение в первый же день.
slices.Sorted(maps.Keys(m)) (Go 1.23+) даёт отсортированные ключи одной строкой.
fmt.Println(m), encoding/json и text/template уже сортируют ключи мапы; без порядка остаются только ваши собственные циклы range.
Через **slices.SortFunc(s, cmp)** с функцией, которая возвращает отрицательное число, ноль или положительное, обычно собранной из cmp.Compare; cmp.Or добавляет второй ключ, который используется, только когда первый равен.
slices.SortFunc (Go 1.21+) дженериковый и обычно быстрее старого sort.Slice, который меняет элементы местами через рефлексию.
Ни та, ни другая сортировка не стабильна: равные элементы могут поменяться местами. Если существующий порядок должен сохраниться, например список уже отсортирован по дате, берите slices.SortStableFunc.
Для сортировки по убыванию поменяйте аргументы местами: cmp.Compare(b.Age, a.Age).
Сравнение строк сравнивает байты, а не правила языка. Именам, которые видит пользователь, на русском или немецком нужен golang.org/x/text/collate.
Go всё передаёт по значению, а значение слайса и есть его заголовок: функция получает свою копию указателя, len и cap, которая указывает на массив вызывающего. Программа выведет [100 200 0] 3 [100 200 0 4].
s[0] = 100 пишет через общий указатель, поэтому вызывающий это видит.
append находит свободную ёмкость (cap 10) и кладёт 4 в тот же массив, но длина 4 появляется только у копии заголовка в функции. У вызывающего len по-прежнему 3; 4 лежит в массиве за его длиной, и a[:4] её показывает.
Переаллокации не было, поэтому s[1] = 200 тоже попадает в общий массив. С make([]int, 3) append выделил бы новый массив, s[1] = 200 ушло бы туда, a у вызывающего был бы [100 0 0] (а a[:4] вызвало бы панику, так как cap равен 3).
Увидит ли вызывающий изменение, зависит от ёмкости, поэтому такие баги то появляются, то исчезают.
Исправление повторяет форму самого append: вернуть слайс и присвоить его, a = add(a). Параметр *[]int тоже работает, но встречается редко и читается хуже.
Если новые элементы помещаются в cap, **append пишет в существующий массив и ничего не аллоцирует**; иначе он выделяет массив побольше, копирует туда элементы и возвращает заголовок, указывающий на него. Маленькие слайсы удваиваются; с 256 элементов рост замедляется до 1,25×.
Правило в runtime.growslice (Go 1.18+): если нужная длина больше удвоенной старой cap, берётся ровно она; до 256 элементов ёмкость удваивается; дальше на каждом шаге добавляется (cap + 768) / 4, то есть плавный переход от 2× к 1,25×.
Результат округляется вверх до size class аллокатора, поэтому ёмкость не совпадает с формулой точно: []int растёт с 512 до 848, а не до 832.
До Go 1.18 переключение было резким, на 1024 элементах: 2× до, 1,25× после. Старые статьи до сих пор цитируют именно его.
После переаллокации старый массив остаётся как был, и другие слайсы над ним перестают видеть ваши записи. В этом корень большинства сюрпризов с алиасингом.
Добавление по одному элементу стоит амортизированно O(1), но каждая переаллокация копирует всё накопленное; если n известно, выделяйте заранее через make([]T, 0, n).
Выведет [0 0 2] [0 0 2], а затем 0 7. **Оба append записали в единственный свободный слот массива base**, поэтому y затёр то, что туда положил x; z добавляли к заполненному слайсу, и он получил новый массив.
x и y являются разными заголовками над одной памятью: &x[0] == &y[0]. Массив могут делить сколько угодно слайсов, и ни один из них им не владеет.
Для append(x, 3) нужен четвёртый слот, а у x cap 3, поэтому рантайм копирует данные в новый массив. Дальше записи в z не трогают x.
Делят ли два слайса память, зависит от ёмкости в данный момент, поэтому один и тот же код ведёт себя по-разному на входных данных разного размера.
Исправления: отдавайте каждому вызывающему base[:len(base):len(base)], чтобы его первый append был вынужден копировать, или slices.Clone(base), если получатель хранит или меняет данные.
Оно режет как s[low:high], но ещё и **задаёт ёмкость max - low**, поэтому результат не дотянется до элементов за max. При max == high первый append вынужден выделить новый массив, а не перезаписывать данные вызывающего.
Используйте его, когда отдаёте часть своего буфера коду, который вы не контролируете: return buf[:n:n] гарантирует, что append у вызывающего никогда не запишет в buf[n:].
Границы должны удовлетворять low <= high <= max <= cap(s), иначе будет паника slice bounds out of range.
Оно ограничивает только рост. Запись s[i] в пределах max - low по-прежнему меняет общий массив; если получатель хранит или правит данные, клонируйте их.
Для строк оно запрещено: к ним и так нельзя дописать на месте.
Строки неизменяемы, поэтому **каждый s += part выделяет новую строку и копирует в неё всё накопленное**: n добавлений копируют O(n²) байт. strings.Builder дописывает в один растущий буфер и отдаёт результат без финального копирования.
Отчёт на 100 КБ, собранный так из 10 000 коротких строк, аллоцирует около 500 МБ выброшенных строк.
b.Grow(n), если размер известен, сводит всё к одной аллокации.
strings.Join(parts, sep) подходит, когда части уже лежат в слайсе: он суммирует длины и аллоцирует один раз.
bytes.Buffer подходит, когда нужны байты или io.Reader. Оба реализуют io.Writer, поэтому fmt.Fprintf(&b, ...) пишет прямо в них.
strings.Builder нельзя копировать после первого использования: передавайте *strings.Builder, иначе будет паника strings: illegal use of non-zero Builder copied by value.
Две-три части в одном выражении, a + ":" + b, склеивать нормально: компилятор соберёт их за одну аллокацию.
Удаление безопасно и чётко определено: элемент, удалённый до того, как цикл до него дошёл, просто не будет выдан. Добавление допустимо, но непредсказуемо: ключ, вставленный во время цикла, может попасть в этот же обход, а может и не попасть.
Поэтому for k, v := range m { if v.Expired() { delete(m, k) } } является корректным кодом на Go, и собирать ключи заранее не нужно. maps.DeleteFunc(m, pred) (Go 1.21+) делает то же самое одной строкой.
Вставка может заставить таблицу вырасти и переместить элементы под итератором. Спецификация оставляет результат открытым, вместо того чтобы платить за детерминизм, поэтому код, который на него полагается, даёт разный результат от запуска к запуску.
Если в цикле нужно добавлять ключи, соберите их в слайс и вставьте после цикла.
Всё это касается одной горутины. Изменение мапы из другой горутины во время range считается гонкой данных (data race), и она может уронить процесс.
Элементы мапы не адресуемы (мапа может переносить записи при росте), поэтому нельзя присвоить поле значения из мапы или взять его адрес (cannot assign to struct field m["a"].Count in map). Скопируйте структуру, измените и положите обратно либо храните в мапе указатели.
Копировать, менять, класть обратно: it := m[k]; it.Count++; m[k] = it.
map[string]*Item позволяет писать m[k].Count++, но для отсутствующего ключа вернётся nil-указатель, поэтому сначала проверяйте ok, а общие указатели требуют осторожности при конкурентном доступе.
Элемент слайса адресуем, поэтому items[i].Count++ работает; со слайсами такой ошибки не бывает.
То же правило отвергает &m[k] и вызовы методов с указателем, например m[k].Inc().
Мапа устроена как хеш-таблица: хеш ключа со случайным для каждой мапы сидом определяет, где искать, поэтому чтение, запись и удаление в среднем O(1). С Go 1.24 рантайм использует Swiss tables (группы по 8 слотов с контрольным байтом на каждый слот) вместо бакетов с цепочками переполнения.
До Go 1.23: бакеты
Заголовок мапы (hmap) указывал на 2^B бакетов. В каждом бакете лежали 8 ключей, 8 значений и 8 байт tophash (старшие биты каждого хеша для быстрой предварительной проверки).
Младшие биты хеша выбирали бакет; заполненный бакет цеплял overflow-бакет.
При средней загрузке 6,5 элемента на бакет мапа удваивалась и эвакуировала старые бакеты постепенно, по несколько на каждой записи, чтобы ни одна вставка не платила за весь переезд.
С Go 1.24: Swiss tables
Хеш делится на H1 (57 бит), который выбирает группу, с которой начинается поиск, и H2 (7 бит), хранящийся в контрольном байте. Поиск сравнивает H2 сразу со всеми 8 контрольными байтами группы и сверяет полные ключи только у совпавших.
Таблица вмещает до 1024 слотов и удваивается при заполнении на 7/8; после этого размера она делится надвое, а таблицу выбирает директория по старшим битам хеша. Каждый шаг роста ограничен одной таблицей, а не всей мапой.
Мапа до 8 элементов хранится в одной группе вообще без таблицы.
Что не изменилось
Случайный порядок обхода, значения, от которых нельзя взять адрес, и падение concurrent map writes.
Мапа никогда не сжимается: delete и clear освобождают слоты, но оставляют таблицу. Чтобы вернуть память, замените мапу новой.
Нет. Одновременные чтения допустимы, но запись параллельно с любым другим обращением считается гонкой данных, и рантайм может остановить процесс с **fatal error: concurrent map writes**. Это фатальная ошибка, а не паника, поэтому recover() её не поймает.
Проверка опирается на дешёвый флаг, который ставится на время записи, поэтому она срабатывает, только когда два обращения действительно пересеклись. Гонка может пережить неделю на стейджинге и уронить прод; go test -race сообщает о ней на первом же несинхронизированном обращении.
Процесс обязан умереть: запись, перемешанная с ростом таблицы, может испортить мапу, а отвечать из испорченной таблицы хуже, чем упасть.
Обычное решение: sync.RWMutex (или sync.Mutex) рядом с мапой, который берётся на каждое обращение, включая чтения.
sync.Map подходит в двух случаях: ключи пишутся один раз и много раз читаются или горутины работают с непересекающимися ключами. В остальных он обычно медленнее мапы с мьютексом. При сильной конкуренции шардируйте: N мап, у каждой свой лок.
Мапе, полностью собранной до запуска горутин и больше не изменяемой, лок не нужен вовсе.
С Go 1.21 они закрывают циклы, которые раньше все писали руками (slices.Contains, Index, Sort, BinarySearch, Compact, maps.Clone). Sort и Reverse работают на месте; Delete, Insert, Compact и Replace возвращают новый заголовок, который нужно присвоить.
slices.Sort, SortFunc и Reverse работают на месте и ничего не возвращают.
Delete, DeleteFunc, Compact, Insert и Replace возвращают новый заголовок, как append, поэтому результат всегда присваивайте: s = slices.Delete(s, i, i+1). Если в массиве есть место, они переиспользуют его, и другие слайсы над этим массивом видят сдвинутые элементы.
С Go 1.22 функции удаления обнуляют слоты за новой длиной, чтобы выброшенные указатели мог собрать сборщик мусора.
slices.BinarySearch нужен отсортированный вход; на неотсортированном он молча вернёт неверный ответ.
maps.Keys возвращает итератор (iter.Seq), а не слайс. Мапы сравниваются через == только с nil; для остального есть maps.Equal.
Цикл увеличивает i после удаления элемента, но append(sessions[:i], sessions[i+1:]...) только что сдвинул следующую сессию на индекс i, поэтому она не проверяется: из двух подряд идущих просроченных сессий вторая остаётся.
Почему так происходит: удаление на месте копирует хвост на одну позицию влево в том же нижележащем массиве. После i++ цикл смотрит на то, что раньше было i+2. Для a b c d e, где просрочены все, кроме c, результат будет b c e.
Почему нет паники: условие i < len(sessions) вычисляется на каждой итерации. Если написать for i := range sessions, длина фиксируется в начале, и тот же баг заканчивается index out of range, как только что-то удалено.
Исправление: увеличивать i только когда элемент остаётся. Ещё проще: slices.DeleteFunc(sessions, func(s Session) bool { return s.ExpiresAt.Before(now) }) делает один проход за O(n) вместо сдвига за O(n) на каждое удаление, а начиная с Go 1.22 зануляет освободившийся хвост.
Вызывающий код: функция переиспользует массив вызывающего, поэтому переданный слайс оказывается со сдвинутыми/продублированными элементами; всегда используйте возвращённый слайс.
Как поймать в следующий раз: табличный тест с двумя просроченными элементами подряд и одним в конце; одиночный просроченный элемент в середине тест проходит.
Нет. Пересрез меняет только заголовок; нижележащий массив остаётся выделенным, пока на него указывает хоть один слайс, поэтому, сохранив body[:64] от тела запроса в 4 МБ, вы держите живыми все 4 МБ.
Сборщик мусора освобождает объекты целиком. Указатель в любое место массива делает достижимым весь массив; освободить «4 МБ минус 64 байта» он не может.
s = s[:0], s = s[2:] и s = s[:5] никогда не уменьшают аллокацию. Со строками то же самое: big[:10] от строки в 1 МБ держит весь мегабайт.
Копируйте то, что сохраняете, через bytes.Clone, strings.Clone или slices.Clone; большой массив станет мусором на следующем цикле.
Слайс указателей так же удерживает объекты за своей длиной: clear(s[n:]) перед укорачиванием, это разобрано в следующем вопросе.
Профиль кучи относит эту память к коду, который выделил массив, а не к коду, сохранившему подслайс; подсказкой служит долгоживущий слайс, у которого cap намного больше len.
append сдвигает хвост влево внутри того же массива и возвращает заголовок на один элемент короче, но в старом последнем слоте остаётся значение. В слайсе указателей эта устаревшая копия держит объект достижимым, пока жив массив.
Ничего не выделяется и не освобождается. Удалённого элемента в s больше нет, но дубликат последнего элемента лежит за len: вам он не виден, а сборщику мусора виден.
С Go 1.22 slices.Delete(s, i, i+1) делает тот же сдвиг, а затем обнуляет освободившиеся слоты, и они перестают удерживать объекты. До этого s[len(s)-1] = nil приходилось писать самим.
Это по-прежнему операция на месте: любой другой слайс над этим массивом видит сдвинутые элементы.
Одно удаление стоит O(n), поэтому удаление совпадений по одному в цикле обходится в O(n²). slices.DeleteFunc убирает их все за один проход.
Если порядок не важен, перенесите последний элемент в образовавшуюся дыру и укоротите слайс: O(1).
Чтение слайса, в который никто не пишет, безопасно; запись, пока другая горутина читает или пишет тот же элемент, считается гонкой данных (data race), и append тоже гоняется, потому что обновляет заголовок и может писать в общий массив. Дайте каждой горутине свою часть или передайте владение.
Записи по разным индексам не гоняются: воркеры, заполняющие out[i] для своих i, в порядке. Перед чтением результата дождитесь sync.WaitGroup.
append в одну общую переменную-слайс из нескольких горутин теряет элементы или портит заголовок. Дайте каждому воркеру свой слайс и объедините после либо защитите мьютексом.
Если воркеры получают подслайсы одного буфера, режьте их как buf[lo:hi:hi], чтобы append одного воркера не залез в диапазон соседа.
Буфер, отправленный в канал или возвращённый в sync.Pool, принадлежит получателю: отправитель больше не должен его трогать, а подслайсы от него никто не вправе хранить, иначе прочитает байты, которые уже перезаписал следующий пользователь.
go test -race находит первое же несинхронизированное обращение.
Add сохраняет указатель на текущий нижележащий массив слайса, но когда у append кончается ёмкость, он копирует users в новый массив; старые указатели по-прежнему смотрят на старую копию, поэтому Rename пишет в память, которую List никогда не читает.
Почему так происходит: &r.users[i] даёт адрес элемента в одном конкретном массиве. Каждая реаллокация переносит элементы, и все указатели, взятые до неё, устаревают. Правильно переименовываются только пользователи, добавленные после последнего роста, поэтому баг выглядит случайным: в прогоне с тремя пользователями переименования пользователей 1 и 2 теряются, работает только 3-й.
Ни паники, ни гонки: старый массив остаётся валидной памятью, его держат живым те самые указатели, поэтому паники нет, а -race молчит (горутина одна).
Исправление: хранить индекс, а не адрес: append никогда не меняет индексы существующих элементов. Если вы ещё и удаляете из середины, индексы тоже сдвигаются; тогда храните []*User, чтобы слайс и мапа делили один объект в куче на пользователя.
Предаллокация ничего не исправляет: make([]User, 0, 1000) лишь прячет баг до 1001-го пользователя.
Как поймать в следующий раз: тест, который добавляет больше элементов, чем начальная ёмкость, а потом меняет первый; на ревью считайте багом любой &s[i], который живёт дольше следующего append.