Понимание slice в Go на примере практической задачи

slices-в-go

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

Что, если попытаться изменить срез внутри функции с помощью append? Давайте разберем наглядный пример, который заставит задуматься даже опытного разработчика.

Постановка задачи

Рассмотрим код

package main

import (
    "fmt"
    "sort"
)

func main() {
    v := []int{3, 4, 1, 2, 5}
    ap(v)
    sr(v)
    fmt.Println(v)
}

func ap(arr []int) {
    arr = append(arr, 10)
}

func sr(arr []int) {
    sort.Ints(arr)
}

Вопрос: Что будет выведено в результате выполнения программы?

Ответ и объяснение

Вывод программы:

[1 2 3 4 5]

Почему число 10 не попало в результат? Давайте разберемся поэтапно.

Детальный разбор

1. Структура среза в Go

Slice (срез) — это структура, состоящая из 3-х элементов:

  • Pointer (указатель) на массив (указывает на начальный элемент среза в массиве)
  • Length (длина) — количество элементов в срезе
  • Capacity (емкость) — максимальное количество элементов без переаллокации.

Изначально в main():

v := []int{3, 4, 1, 2, 5}
// v: pointer -> [3, 4, 1, 2, 5], len = 5, cap = 5

2. Анализ функции ap(arr []int)

func ap(arr []int) {
    arr = append(arr, 10)
}

Ключевой момент: Срезы передаются в функции по значению, но это значение содержит указатель на общий массив данных.

Что происходит при вызове ap(v):

  1. Передается копия структуры среза — создается новый набор переменных (указатель, длина, емкость), но эта новая структура продолжает указывать на исходный массив данных.
  2. Еще раз, arr и v указывают на один и тот же массив в памяти.
  3. При попытке append(arr, 10):
    1. Исходная емкость (5) равна длине (5) — нет свободного места.
    2. Происходит переаллокация — создается новый массив большего размера, но уже в другом участке памяти.
    3. arr теперь указывает на новый массив: [3, 4, 1, 2, 5, 10]
    4. Но v в функции main() продолжает указывать на старый массив

Результат: изменения в ap() не видны в main().

3. Анализ функции sr(arr []int)

Что происходит при вызове sr(v):

  1. Снова создается полная копия структуры среза arr (указатель, длина и емкость), но все три поля указывают на те же данные.
  2. arr и v указывают на один и тот же массив [3, 4, 1, 2, 5]
  3. sort.Ints(arr) сортирует массив in-place (на месте)
  4. Поскольку обе копии указывают на один и тот же массив, изменения видны в main()

Результат: Исходный массив отсортирован -> [1, 2, 3, 4, 5].

Правильные подходы для модификации срезов

1. Возвращать измененный срез

package main

import "fmt"

func main() {
    v := []int{1, 2, 3}
    v = appendToSlice(v, 4)
    fmt.Println(v) // [1 2 3 4]
}

func appendToSlice(arr []int, value int) []int {
    return append(arr, value)
}

2. Использовать указатель на срез

package main

import "fmt"

func main() {
    v := []int{1, 2, 3}
    appendToSlicePtr(&v, 4)
    fmt.Println(v) // [1 2 3 4]
}

func appendToSlicePtr(arr *[]int, value int) {
    *arr = append(*arr, value)
}

3. Изменять элементы без изменения структуры среза

package main

import "fmt"

func main() {
    v := []int{1, 2, 3}
    modifyElements(v)
    fmt.Println(v) // [10 20 30]
}

func modifyElements(arr []int) {
    for i := range arr {
        arr[i] = arr[i] * 10
    }
}

4. Работа со срезами достаточной емкости

package main

import "fmt"

func main() {
    // создаем срез с запасом емкости
    v := make([]int, 3, 10) // len=3, cap=10
    v[0], v[1], v[2] = 1, 2, 3

    appendWithoutAllocation(v)
    fmt.Println(v[:4]) // [1 2 3 100] - видим изменения
}

func appendWithoutAllocation(arr []int) {
    if len(arr) < cap(arr) {
        // увеличиваем длину в пределах емкости
        arr = arr[:len(arr)+1]
        arr[len(arr)-1] = 100
    }
}

5. Комбинированный подход — модификация + возврат

package main

import (
    "fmt"
    "sort"
)

func main() {
    v := []int{3, 1, 4, 2}
    
    // модифицируем и сортируем
    v = processSlice(v, 5)
    fmt.Println(v) // [1 2 3 4 5]
}

func processSlice(arr []int, newValue int) []int {
    // изменяем существующие элементы
    for i := range arr {
        arr[i] *= 1
    }

    // добавляем новые элементы (возможно с переаллокацией)
    arr = append(arr, newValue)

    // сортируем (изменения видны)
    sort.Ints(arr)

    return arr
}

6. Работа с под-срезами (slicing)

package main

import "fmt"

func main() {
    data := []int{0, 1, 2, 3, 4, 5}

    // создаем под-срез
    slice := data[1:4] // [1, 2, 3]
    modifySlice(slice)

    fmt.Println(data)  // [0 10 20 30 4 5]
    fmt.Println(slice) // [10 20 30]
}

func modifySlice(arr []int) {
    for i := range arr {
        arr[i] = arr[i] * 10
    }
}

Когда изменения видны, а когда нет?

Изменения ВИДНЫ, когда:

  • Меняются элементы существующего среза (в пределах длины)
  • Сортируются, переставляются элементы
  • Используются срезы с достаточной емкостью для append

Изменения НЕ ВИДНЫ, когда:

  • Append приводит к переаллокации (емкость недостаточна)
  • Меняется длина или емкость среза без возврата значения

Понимание внутреннего устройства срезов — ключ к эффективной работе с Go.

Ключевой вывод

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

Эта особенность делает Go одновременно эффективным (минимум копирования данных) и безопасным (контроль над модификациями), но требует от разработчика четкого понимания механизма работы.

А вам доводилось попадать в эту ловушку со срезами? Делитесь в комментариях — какие баги из-за этого приходилось исправлять?

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: