Генерация слайсов в Go - паттерн slice-generator с примерами и объяснениями

04 февраля 2026
Автор

Олег Марков

Введение

Генерация слайсов (срезов) в Go часто сводится не только к использованию встроенной функции make, но и к проектированию удобных и безопасных генераторов — функций и конструкторов, которые создают и заполняют слайсы по заданным правилам. В англоязычной среде это иногда называют подходом slice-generator — когда вы строите API вокруг возвращаемых слайсов: от простых фабрик данных до ленивых и потоковых генераторов.

Смотрите, здесь мы подробно разберем, как в Go проектировать такие генераторы, какие у них типичные ошибки, как управлять емкостью (cap) и длиной (len), а также как избегать лишних аллокаций и утечек памяти через слайсы. Будем опираться только на стандартный Go, без внешних библиотек, чтобы вы могли применять эти знания в любом проекте.


Базовая теория срезов как основы slice‑generator

Чтобы уверенно проектировать генерацию слайсов, нужно хорошо понимать, как устроен срез в Go.

Что такое слайс в Go

Срез в Go — это структура-описатель поверх массива в памяти. Он хранит три вещи:

  • указатель на первый элемент в массиве;
  • текущую длину (len);
  • емкость (cap) — сколько элементов можно еще добавить без перевыделения памяти.

Схематично:

type sliceHeader struct {
    Data unsafe.Pointer // указатель на первый элемент массива
    Len  int            // текущая длина среза
    Cap  int            // емкость среза (размер подложного массива)
}

Когда вы работаете с генераторами слайсов, вы фактически управляете:

  • тем, как и когда создается подложный массив;
  • как заполняется слайс;
  • при каких условиях слайс будет увеличивать емкость (через append);
  • какие гарантии по len и cap вы даете вызывающему коду.

Способы создания слайсов

Есть три основных способа создать срез:

// 1. Литерал среза
nums := []int{1, 2, 3}   // len=3, cap=3

// 2. make с длиной
buf := make([]byte, 10)  // len=10, cap=10, все элементы = 0

// 3. make с длиной и емкостью
list := make([]string, 0, 100) // len=0, cap=100

Для генераторов чаще всего используется третий вариант — make с заделом по емкости. Давайте разберемся, почему.


Паттерн slice‑generator: что это и зачем

Когда говорят о slice-generator, обычно имеют в виду функции/методы, которые:

  • создают срез (часто с заранее известной емкостью);
  • заполняют его (детерминированно или на лету);
  • возвращают готовый срез или канал, из которого можно читать элементы.

Вы как разработчик можете проектировать такие функции так, чтобы:

  • минимизировать аллокации;
  • дать понятные гарантии по содержимому (нет нулевых значений там, где их не ожидают);
  • избежать неожиданного изменения данных из-за общих подложных массивов.

Простой детерминированный slice‑generator

Давайте начнем с самого простого примера: генератор диапазона чисел.

// GenerateRange генерирует слайс целых чисел от start до end включительно.
// Если start > end - возвращается пустой срез.
func GenerateRange(start, end int) []int {
    if start > end {
        // Возвращаем срез нулевой длины
        return []int{}
    }

    // Вычисляем длину будущего среза
    length := end - start + 1

    // Создаем срез с нужной длиной и емкостью
    result := make([]int, 0, length)

    // Заполняем срез
    for v := start; v <= end; v++ {
        result = append(result, v) // безопасное добавление
    }

    return result
}

Обратите внимание:

  • мы заранее знаем длину, поэтому задаем cap и избегаем лишних аллокаций;
  • возвращаем пустой срез ([]int{}), а не nil, чтобы вызывающему было проще работать (это распространенный стиль, но в некоторых API используют и nil — мы позже обсудим отличия).

Управление len и cap в генераторах

Теперь давайте чуть глубже про то, как управлять len и cap, когда вы проектируете slice‑generator.

Когда задавать длину сразу

Если вы точно знаете, сколько элементов будет в результате, можно создавать срез с ненулевой длиной:

// Squares генерирует слайс квадратов чисел от 0 до n-1.
func Squares(n int) []int {
    if n <= 0 {
        return []int{}
    }

    // len и cap одинаковы, сразу все элементы будут заполнены
    result := make([]int, n)

    for i := 0; i < n; i++ {
        result[i] = i * i // записываем по индексу
    }

    return result
}

Это чуть эффективнее, чем append в цикле, потому что:

  • не нужно увеличивать len каждый раз;
  • не делаются проверки необходимости расширения емкости.

Но такой подход требует точного знания количества элементов.

Когда использовать длину 0 и большую емкость

Если вы не уверены до конца в количестве элементов, но есть разумный верхний предел — используйте len=0, cap=N:

// FilterEven отфильтровывает четные числа из исходного среза.
func FilterEven(src []int) []int {
    // Задаем емкость равной длине исходного, это верхний предел
    dst := make([]int, 0, len(src))

    for _, v := range src {
        if v%2 == 0 {
            dst = append(dst, v) // добавляем только подходящие значения
        }
    }

    return dst
}

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


Генерация слайсов и функция append

Функция append — ключевой инструмент в любом slice‑generator. Давайте отдельно разберем, как она влияет на емкость и когда создает новые массивы.

Как работает append

Коротко:

  • если текущей емкости среза хватает — элементы записываются в тот же массив;
  • если емкости не хватает — создается новый массив большего размера, и все элементы копируются в него.

Пример генератора, где легко увидеть рост емкости:

// GrowSlice демонстрирует, как растет емкость при добавлении элементов.
func GrowSlice(n int) []int {
    result := make([]int, 0) // len=0, cap=0

    for i := 0; i < n; i++ {
        result = append(result, i)

        // В реальном коде так делать не нужно, это для демонстрации
        // fmt.Printf("len=%d cap=%d\n", len(result), cap(result))
    }

    return result
}

В реальных генераторах вы обычно не печатаете len и cap, но понимание этого поведения помогает:

  • избегать избыточных копирований;
  • осознанно выбирать начальную емкость.

Прием: заранее выделять емкость

Если вы проектируете публичный slice‑generator, полезно дать пользователю возможность задавать примерную или точную емкость. Давайте сделаем пример:

// NewBuffer создает срез байтов с заданной емкостью.
// Это типичный slice-generator в виде конструктора.
func NewBuffer(capacity int) []byte {
    if capacity < 0 {
        capacity = 0
    }

    // len=0, cap=capacity — буфер пустой, но готов к заполнению
    buf := make([]byte, 0, capacity)
    return buf
}

Теперь вызывающий может написать:

// Здесь мы создаем буфер на 1024 байта
buf := NewBuffer(1024)

// Далее можем безопасно добавлять данные
buf = append(buf, 'H', 'e', 'l', 'l', 'o')

Генераторы на основе существующих данных

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

Паттерн Filter

Мы уже видели простой фильтр по четным числам. Давайте обобщим его до функции высшего порядка:

// Filter возвращает новый срез, содержащий элементы src,
// для которых функция pred вернула true.
func Filter[T any](src []T, pred func(T) bool) []T {
    // Задаем емкость равной длине src — верхний предел
    dst := make([]T, 0, len(src))

    for _, v := range src {
        if pred(v) {
            dst = append(dst, v)
        }
    }

    return dst
}

Теперь вы можете использовать этот slice‑generator так:

// Здесь мы фильтруем только положительные значения
positives := Filter([]int{-2, -1, 0, 1, 2}, func(v int) bool {
    return v > 0
})

Комментарии:

  • мы не меняем исходный срез src;
  • создаем новый срез с отдельным подложным массивом;
  • вызывающий код получает безопасный результат, не зависящий от дальнейших изменений src.

Паттерн Map

Еще один распространенный паттерн — преобразование элементов (map):

// Map применяет функцию f к каждому элементу src и
// возвращает новый срез из результатов.
func Map[T any, R any](src []T, f func(T) R) []R {
    if len(src) == 0 {
        return []R{}
    }

    // Мы знаем точную длину результата
    dst := make([]R, len(src))

    for i, v := range src {
        dst[i] = f(v)
    }

    return dst
}

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

// Здесь мы преобразуем числа в строки
strs := Map([]int{1, 2, 3}, func(v int) string {
    return fmt.Sprintf("num=%d", v) // поясняем в комментарии: форматируем число
})

Такой slice‑generator:

  • предсказуем по длине;
  • не вызывает лишних аллокаций;
  • легко тестируется.

Паттерн FlatMap (генератор с "расплющиванием")

Бывает, что каждый элемент исходного среза порождает несколько элементов в результате. Давайте сделаем генератор, который дублирует каждый элемент k раз:

// RepeatEach возвращает новый срез, где каждый элемент src
// повторен k раз подряд.
func RepeatEach[T any](src []T, k int) []T {
    if k <= 0 || len(src) == 0 {
        return []T{}
    }

    // Общая длина = len(src) * k
    total := len(src) * k

    // Сразу выделяем нужную емкость и длину 0
    dst := make([]T, 0, total)

    for _, v := range src {
        for i := 0; i < k; i++ {
            dst = append(dst, v)
        }
    }

    return dst
}

Здесь мы явно используем идею slice‑generator: заранее вычисляем размер и под него выделяем емкость.


Ленивые и потоковые slice‑generators

Иногда генерировать "все сразу" нерационально: данных слишком много, или нужно обрабатывать их по частям. Тогда используют ленивые генераторы или генераторы на каналах.

Генератор на канале

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

// IntGenerator запускает горутину, которая генерирует числа от 0 до n-1
// и отправляет их в канал. Когда числа заканчиваются - канал закрывается.
func IntGenerator(n int) <-chan int {
    ch := make(chan int)

    go func() {
        defer close(ch) // в конце закрываем канал

        for i := 0; i < n; i++ {
            ch <- i // отправляем следующее значение
        }
    }()

    return ch
}

Теперь вы можете сделать свой slice‑generator поверх этого канала:

// CollectInts читает все значения из канала и складывает их в срез.
func CollectInts(ch <-chan int) []int {
    // Начинаем с небольшого среза и даем append самому увеличивать емкость
    result := make([]int, 0)

    for v := range ch {
        result = append(result, v)
    }

    return result
}

Такой подход удобен, когда:

  • источник данных потенциально бесконечен или очень велик;
  • вам нужно иметь возможность останавливаться раньше (например, по контексту).

Комбинирование ленивого и "жадного" генераторов

Вы можете комбинировать ленивый источник и "жадный" slice‑generator с ограничением:

// Take собирает не более limit элементов из канала в срез.
func Take(ch <-chan int, limit int) []int {
    if limit <= 0 {
        return []int{}
    }

    // Емкость не больше limit
    result := make([]int, 0, limit)

    for v := range ch {
        result = append(result, v)

        if len(result) == limit {
            break // остановка, даже если в канале еще есть данные
        }
    }

    return result
}

Теперь вы видите, как можно контролировать размер результата при генерации слайса из потоков данных.


Безопасность и "утечки" через слайсы

Slice‑generator часто возвращает срез "наружу". Важно понимать, какие риски это несет.

Общие подложные массивы

Когда вы делаете срез от среза, оба среза могут указывать на один и тот же массив. Если вы неаккуратно сделаете slice‑generator, вызывающий код может случайно изменить ваши внутренние данные.

Пример опасного генератора:

// LastN возвращает срез, содержащий последние n элементов src.
// ВНИМАНИЕ - возвращаемый срез разделяет подложный массив с src.
func LastN[T any](src []T, n int) []T {
    if n <= 0 || len(src) == 0 {
        return []T{}
    }

    if n > len(src) {
        n = len(src)
    }

    // Здесь мы просто берем "хвост" исходного среза
    return src[len(src)-n:]
}

Проблема в том, что внешние изменения src повлияют на результат, и наоборот. Иногда это приемлемо, но часто — нет.

Безопасный генератор копий

Давайте перепишем пример так, чтобы генератор возвращал независимый срез:

// SafeLastN безопасно возвращает копию последних n элементов src.
func SafeLastN[T any](src []T, n int) []T {
    if n <= 0 || len(src) == 0 {
        return []T{}
    }

    if n > len(src) {
        n = len(src)
    }

    start := len(src) - n

    // Создаем новый срез нужной длины
    dst := make([]T, n)
    copy(dst, src[start:]) // копируем данные

    return dst
}

Теперь ни одна из сторон не может случайно испортить данные другой.

Утечки памяти через ссылки в слайсах

Отдельная проблема генераторов — хранение в слайсе "лишних" данных через подложный массив. Ситуация:

  • у вас есть очень длинный срез big[]byte;
  • вы нашли в нем небольшой подстрочный фрагмент;
  • вернули "маленький" слайс как результат, но он все еще указывает на весь большой массив.

Пример:

// FindPrefix возвращает слайс первого найденного префикса.
// ВНИМАНИЕ - результат может удерживать в памяти весь bigSlice.
func FindPrefix(bigSlice []byte, prefix []byte) []byte {
    for i := 0; i+len(prefix) <= len(bigSlice); i++ {
        if string(bigSlice[i:i+len(prefix)]) == string(prefix) {
            return bigSlice[i : i+len(prefix)]
        }
    }
    return nil
}

Чтобы избежать удержания большого массива, генератор должен копировать данные:

// SafeFindPrefix копирует найденный фрагмент в новый срез,
// не удерживая весь исходный bigSlice в памяти.
func SafeFindPrefix(bigSlice []byte, prefix []byte) []byte {
    for i := 0; i+len(prefix) <= len(bigSlice); i++ {
        if string(bigSlice[i:i+len(prefix)]) == string(prefix) {
            // Создаем новый срез и копируем туда только найденный фрагмент
            result := make([]byte, len(prefix))
            copy(result, bigSlice[i:i+len(prefix)])
            return result
        }
    }
    return nil
}

Обратите внимание, как этот фрагмент кода решает задачу: мы платим копированием за то, чтобы сборщик мусора мог освободить большой массив, когда он больше не нужен.


API-дизайн: как "выглядят" хорошие slice‑generators

Теперь давайте поговорим, как грамотно проектировать публичные функции-генераторы слайсов.

Ясные контракты по nil и пустым срезам

Хорошей практикой считается четко описывать в документации:

  • может ли функция вернуть nil-слайс;
  • что значит пустой срез;
  • можно ли безопасно вызывать len(result) и итерироваться по результату без дополнительных проверок.

Например:

// UsersByRole возвращает всех пользователей с ролью role.
// Если пользователей с такой ролью нет - возвращается пустой срез, но не nil.
func UsersByRole(role string) []User {
    // Здесь мы всегда инициализируем срез
    result := make([]User, 0)

    // ... заполняем result при необходимости

    return result
}

Такой контракт упрощает жизнь вызывающему коду — не нужно проверять nil.

Возможность переиспользования буфера

Иногда вызывающий хочет контролировать аллокации и передать буфер, в который генератор запишет результат. Это продвинутый, но полезный прием.

Пример:

// ReadAllInto читает все данные из r в buf.
// Возвращает срез, использующий (возможно расширенный) buf.
func ReadAllInto(r io.Reader, buf []byte) ([]byte, error) {
    // Если буфер слишком маленький - append сам создаст больший массив
    for {
        tmp := make([]byte, 1024)        // временный буфер
        n, err := r.Read(tmp)            // читаем данные
        if n > 0 {
            buf = append(buf, tmp[:n]...) // добавляем в основной буфер
        }
        if err == io.EOF {
            break // достигли конца
        }
        if err != nil {
            return buf, err // возвращаем то, что успели прочитать
        }
    }
    return buf, nil
}

Теперь вы видите, как slice‑generator может работать и без выделения нового массива при каждом вызове, если вы передаете подготовленный buf.


Параллельная генерация слайсов

Иногда вы хотите ускорить генерацию, разбив ее на несколько горутин и затем объединив результаты.

Генерация "кусками" и объединение

Давайте сделаем пример: генерируем квадраты чисел от 0 до n-1 в несколько потоков.

// ParallelSquares генерирует квадраты чисел от 0 до n-1,
// обрабатывая диапазон в нескольких горутинах и собирая результат в один срез.
func ParallelSquares(n, workers int) []int {
    if n <= 0 || workers <= 0 {
        return []int{}
    }

    // Результирующий слайс заранее нужной длины
    result := make([]int, n)

    var wg sync.WaitGroup
    wg.Add(workers)

    // Размер блока для одного воркера
    chunkSize := (n + workers - 1) / workers // округление вверх

    for w := 0; w < workers; w++ {
        go func(workerID int) {
            defer wg.Done()

            // Вычисляем границы участка для этой горутины
            start := workerID * chunkSize
            end := start + chunkSize
            if end > n {
                end = n
            }

            // Если участок пустой - просто выходим
            if start >= end {
                return
            }

            // Заполняем "свой" участок в общем срезе
            for i := start; i < end; i++ {
                result[i] = i * i
            }
        }(w)
    }

    wg.Wait()
    return result
}

Комментарии к этому генератору:

  • каждая горутина пишет только в свой диапазон индексов;
  • общий срез result создан заранее;
  • не используется append конкурентно (это важно — в конкурентном режиме append без синхронизации использовать нельзя).

Тестирование slice‑generators

Хорошие генераторы легко тестировать, потому что они детерминированы: на одинаковый вход — одинаковый выход.

Пример теста для генератора диапазона

func TestGenerateRange(t *testing.T) {
    tests := []struct {
        name     string
        start    int
        end      int
        expected []int
    }{
        {
            name:     "normal range",
            start:    1,
            end:      3,
            expected: []int{1, 2, 3},
        },
        {
            name:     "single value",
            start:    5,
            end:      5,
            expected: []int{5},
        },
        {
            name:     "start greater than end",
            start:    3,
            end:      1,
            expected: []int{},
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := GenerateRange(tt.start, tt.end)

            // Сравниваем ожидаемый и полученный результаты
            if !reflect.DeepEqual(got, tt.expected) {
                t.Errorf("expected %v, got %v", tt.expected, got)
            }
        })
    }
}

Здесь я размещаю пример, чтобы вам было проще понять, как проверять:

  • правильность данных;
  • граничные случаи;
  • поведение при пустых результатах.

Практические советы по проектированию slice‑generators

Соберем основные рекомендации, которые помогут вам писать качественные генераторы слайсов.

1. Отделяйте генерацию от изменения

Старайтесь, чтобы:

  • функции-генераторы создавали и возвращали новые срезы;
  • функции-модификаторы принимали срез и меняли его "на месте".

Это делает API предсказуемым.

2. Документируйте поведение по nil и пустым срезам

Явно указывайте:

  • возвращает ли функция nil при отсутствии данных;
  • или всегда возвращает непустой указатель на срез длины 0.

Для большинства бизнес-логики проще всегда возвращать непустой срез.

3. Осознанно решайте, копировать ли данные

Если генератор:

  • возвращает срез, ссылающийся на большой внутренний массив;
  • или на данные, которые могут измениться "под ногами";

подумайте, не лучше ли сделать копию.

4. Пользуйтесь профилированием

Если вы сомневаетесь, насколько эффективен ваш slice‑generator:

  • напишите бенчмарк;
  • посмотрите на количество аллокаций (-benchmem);
  • при необходимости задайте cap явно.

5. Не бойтесь обобщений (generics), но используйте их по делу

Общие генераторы (как Filter, Map) облегчают переиспользование кода, но:

  • старайтесь, чтобы сигнатуры оставались читаемыми;
  • не превращайте генераторы в "магические" функции с пятью параметрами-функциями.

Заключение

Генерация слайсов в Go — это не только вызов make и append, но и целый набор подходов к проектированию функций, которые создают и возвращают срезы. Паттерн slice‑generator позволяет вам:

  • контролировать количество аллокаций и емкость срезов;
  • строить удобные и предсказуемые API;
  • безопасно работать с памятью и избегать "утечек" через общие подложные массивы;
  • организовывать ленивую и параллельную генерацию данных.

Вы посмотрели на разные виды генераторов: детерминированные, ленивые, потоковые, параллельные, а также на паттерны Filter, Map, FlatMap и на способы безопасного копирования данных. Практикуясь в этих техниках, вы сможете писать более эффективный и понятный код, особенно в проектах, где слайсы — основной способ представления коллекций.


Частозадаваемые технические вопросы по теме

Как избежать лишних аллокаций в цикле при генерации слайса неизвестного размера

Используйте стратегию постепенного увеличения емкости с контролем:

func GenerateUnknown() []int {
    result := make([]int, 0, 16) // стартовая емкость

    for hasMoreData() {
        if len(result) == cap(result) {
            // Выделяем новый срез с удвоенной емкостью
            newCap := cap(result) * 2
            if newCap == 0 {
                newCap = 16
            }
            tmp := make([]int, len(result), newCap)
            copy(tmp, result)
            result = tmp
        }
        result = append(result, nextValue())
    }

    return result
}

Так вы контролируете стратегию роста емкости вместо "магии" стандартного append.

Как сделать генератор, который можно "останавливать" через контекст

Добавьте context.Context и проверяйте его в цикле:

func GenerateWithContext(ctx context.Context, n int) []int {
    out := make([]int, 0, n)

    for i := 0; i < n; i++ {
        select {
        case <-ctx.Done():
            return out // возвращаем то, что успели сгенерировать
        default:
        }
        out = append(out, i)
    }
    return out
}

Теперь можно отменить генерацию через ctx.Cancel.

Как переиспользовать уже выделенный слайс в генераторе без лишних копирований

Обнуляйте длину, но сохраняйте емкость:

func ReuseBuffer(buf []byte) []byte {
    // len=0, cap сохраняется, подложный массив не освобождается
    return buf[:0]
}

Дальше используйте его как обычный срез для append.

Как безопасно передать слайс из генератора в горутину

Делайте копию, если данные могут меняться:

func SafeSendToGoroutine(data []int) {
    copyData := make([]int, len(data))
    copy(copyData, data)

    go func(d []int) {
        // работаем с копией d
        _ = d
    }(copyData)
}

Так вы исключаете гонки данных между основным кодом и горутиной.

Как ограничить максимальный размер слайса при генерации больших данных

Проверяйте длину перед append и останавливайтесь:

func GenerateWithLimit(limit int) []int {
    res := make([]int, 0, limit)

    for hasMoreData() {
        if len(res) == limit {
            break // превышать лимит нельзя
        }
        res = append(res, nextValue())
    }

    return res
}

Это защищает от неожиданного потребления слишком большого объема памяти.

Стрелочка влевоКонфигурация Vite vite-configНастройка линтеров linter-config для современных проектовСтрелочка вправо

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий