Олег Марков
Генерация слайсов в Go - паттерн slice-generator с примерами и объяснениями
Введение
Генерация слайсов (срезов) в 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
}
Это защищает от неожиданного потребления слишком большого объема памяти.