Слайс либы lib-slice - удобные утилиты для работы со срезами в Go

28 января 2026
Автор

Олег Марков

Введение

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

Слайс либа lib-slice (далее просто lib-slice) решает эту проблему. Она предоставляет набор готовых утилит для работы со срезами, опираясь на дженерики Go. В результате вы получаете:

  • менее шаблонный код;
  • меньше ошибок в циклах и индексах;
  • более читаемые функции бизнес-логики.

Давайте разберем, что именно предлагает lib-slice, как она устроена, какие функции есть “из коробки” и как безопасно использовать её в реальных проектах.


Обзор и принципы lib-slice

Что такое lib-slice

lib-slice — это небольшая утилитная библиотека для Go, которая добавляет к стандартным срезам:

  • функции обхода и преобразования (Map, Filter, Reduce, ForEach);
  • операции поиска (Find, Contains, IndexOf, LastIndexOf);
  • операции модификации (AppendIf, Unique, DeleteAt, DeleteFunc);
  • удобные конвертации и вспомогательные функции (Chunk, GroupBy, Keys, Values и т.п. — в зависимости от конкретной реализации).

Главная идея — не заменять стандартные срезы собственной оберткой, а дать вам набор чистых функций, которые принимают и возвращают обычные Go-срезы.

Зачем нужна отдельная “слайс либа”

Смотрите, обычно код без lib-slice выглядит так:

// Фильтрация четных чисел без lib-slice
func filterEven(nums []int) []int {
    res := make([]int, 0, len(nums))
    for _, n := range nums {
        if n%2 == 0 {              // условие фильтрации
            res = append(res, n)   // добавляем подходящее значение
        }
    }
    return res
}

С lib-slice та же логика может выглядеть так:

// Допустим, lib-slice лежит в пакете slice
import "github.com/your-org/lib-slice/slice"

func filterEven(nums []int) []int {
    // Здесь мы передаем функцию-фильтр как параметр
    return slice.Filter(nums, func(n int) bool {
        return n%2 == 0
    })
}

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

Основные принципы дизайна

Обычно lib-slice строится вокруг нескольких базовых идей:

  1. Функциональный стиль поверх обычных срезов
    Никаких собственных типов, ваших []T никто не забирает.

  2. Дженерики и строгая типизация
    Каждая функция имеет вид func[T any] ..., вы получаете проверку типов на этапе компиляции.

  3. Предсказуемая работа с памятью

    • при необходимости создаются новые срезы;
    • часто учитывается исходная длина/емкость для уменьшения аллокаций;
    • мутабельные операции стараются быть очевидными.
  4. Минимум магии
    Внутри те же циклы и проверки, которые вы бы писали руками, только протестированные и переиспользуемые.


Базовые операции обхода и преобразования

Map — преобразование элементов среза

Функция Map применяет переданную функцию к каждому элементу и возвращает новый срез.

// Пример сигнатуры
func Map[T any, R any](in []T, fn func(T) R) []R

Давайте разберемся на примере.

package main

import (
    "fmt"
    "github.com/your-org/lib-slice/slice"
)

func main() {
    nums := []int{1, 2, 3}

    // Здесь мы превращаем []int в []string
    strs := slice.Map(nums, func(n int) string {
        // Комментарий поясняет, что мы делаем с элементом
        return fmt.Sprintf("num=%d", n)
    })

    fmt.Println(strs)
    // Ожидаемый вывод:
    // [num=1 num=2 num=3]
}

Особенности, на которые стоит обратить внимание:

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

Filter — выборка по условию

Filter отбирает только те элементы, для которых условие возвращает true.

// Пример сигнатуры
func Filter[T any](in []T, pred func(T) bool) []T

Теперь вы увидите, как это выглядит в коде:

// Фильтрация строк: оставляем только непустые
names := []string{"Ann", "", "Bob", "", "Kate"}

// Здесь мы создаем новый срез, исходный не меняется
nonEmpty := slice.Filter(names, func(s string) bool {
    // Условие фильтрации - строка не пустая
    return s != ""
})

Задачи, где Filter особенно удобен:

  • отбор валидных сущностей перед сохранением в БД;
  • фильтрация по статусу (active, archived);
  • удаление nil или пустых значений из структур.

Reduce — свертка среза к одному значению

Reduce последовательно “сворачивает” срез к одному значению, накапливая результат.

// Пример сигнатуры
func Reduce[T any, R any](in []T, init R, fn func(R, T) R) R

Покажу вам, как это реализовано на практике:

// Сумма чисел с помощью Reduce
nums := []int{1, 2, 3, 4}

// init = 0 - стартовое значение аккумулятора
sum := slice.Reduce(nums, 0, func(acc, n int) int {
    // Здесь мы добавляем текущее число к аккумулятору
    return acc + n
})

// sum == 10

Другой пример — конкатенация строк:

names := []string{"Ann", "Bob", "Kate"}

result := slice.Reduce(names, "", func(acc, name string) string {
    if acc == "" {
        return name
    }
    // Аккуратно добавляем разделитель между элементами
    return acc + ", " + name
})
// result == "Ann, Bob, Kate"

ForEach — простой обход без создания новых срезов

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

// Пример сигнатуры
func ForEach[T any](in []T, fn func(T))

Давайте посмотрим, что происходит в следующем примере:

logs := []string{"start", "processing", "done"}

// Здесь мы просто печатаем элементы без изменения среза
slice.ForEach(logs, func(s string) {
    fmt.Println("log:", s)
})

Эта функция особенно полезна, когда вам нужно:

  • записать элементы в лог;
  • отправить их по сети;
  • провести какие-то побочные действия (side effects), не строя новый срез.

Поиск и проверка наличия

Contains — проверка, есть ли элемент в срезе

Contains отвечает на простой вопрос: “Содержит ли срез данный элемент?”.

// Пример сигнатуры
func Contains[T comparable](in []T, value T) bool

Важно, что здесь используется comparable, а не any: это значит, что тип должен поддерживать сравнение через ==.

Пример:

ids := []int{10, 20, 30}

// Проверяем, есть ли в срезе значение 20
has20 := slice.Contains(ids, 20)   // true
has40 := slice.Contains(ids, 40)   // false

Find — поиск первого подходящего элемента

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

// Пример сигнатуры
func Find[T any](in []T, pred func(T) bool) (T, bool)

Функция возвращает:

  • найденный элемент;
  • флаг ok, который показывает, был ли элемент найден.

Давайте разберемся на примере:

type User struct {
    ID   int
    Name string
}

users := []User{
    {ID: 1, Name: "Ann"},
    {ID: 2, Name: "Bob"},
}

// Ищем пользователя по ID
u, ok := slice.Find(users, func(u User) bool {
    // Условие поиска - нужный ID
    return u.ID == 2
})

if ok {
    fmt.Println("found user:", u.Name)
} else {
    fmt.Println("user not found")
}

IndexOf и LastIndexOf — где именно находится элемент

Вместо полного перебора вручную можно использовать функции IndexOf и LastIndexOf:

// Возможные сигнатуры
func IndexOf[T comparable](in []T, value T) int
func LastIndexOf[T comparable](in []T, value T) int

Обычно:

  • при отсутствии элемента возвращается -1;
  • поиск идет слева направо (IndexOf) или справа налево (LastIndexOf).

Пример:

nums := []int{1, 2, 3, 2, 1}

first := slice.IndexOf(nums, 2)     // 1
last  := slice.LastIndexOf(nums, 2) // 3
none  := slice.IndexOf(nums, 5)     // -1

Модификация срезов и безопасность

AppendIf — добавление при выполнении условия

Иногда нужно добавить элемент в срез только если выполнено условие. Вместо ручной проверки можно использовать AppendIf.

// Возможная сигнатура
func AppendIf[T any](in []T, value T, cond bool) []T

Теперь давайте перейдем к примеру:

nums := []int{1, 2}

// Условие истинно - элемент будет добавлен
nums = slice.AppendIf(nums, 3, 3 > 2)

// Условие ложно - элемент не добавится
nums = slice.AppendIf(nums, 4, 4 < 0)

// В итоге nums == []int{1, 2, 3}

В некоторых реализациях могут быть расширенные варианты:

  • AppendIfFunc — когда условие задается функцией;
  • AppendUnique — добавить, только если элемента еще нет.

DeleteAt — удаление по индексу

Удалять элемент по индексу руками несложно, но код получается шаблонным:

// Стандартный код без lib-slice
func deleteAt[T any](in []T, i int) []T {
    return append(in[:i], in[i+1:]...)
}

lib-slice часто предлагает готовую функцию DeleteAt:

// Возможная сигнатура
func DeleteAt[T any](in []T, index int) []T

Обратите внимание, как этот фрагмент кода решает задачу:

nums := []int{10, 20, 30, 40}

// Удаляем элемент с индексом 1 (значение 20)
nums = slice.DeleteAt(nums, 1)

// Теперь nums == []int{10, 30, 40}

Хорошо, когда библиотека дополнительно:

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

DeleteFunc — удаление по условию

Иногда хочется удалить все элементы, которые удовлетворяют какому-то предикату:

// Возможная сигнатура
func DeleteFunc[T any](in []T, pred func(T) bool) []T

Пример:

nums := []int{1, -2, 3, -4, 5}

// Удаляем все отрицательные числа
nums = slice.DeleteFunc(nums, func(n int) bool {
    // Если возвращаем true - элемент будет удален
    return n < 0
})

// Теперь nums == []int{1, 3, 5}

Это более “говорящий” код, чем ручная фильтрация через цикл.

Unique — удаление дубликатов

Одна из частых задач — сделать список уникальных значений. Часто lib-slice содержит функцию Unique:

// Возможная сигнатура
func Unique[T comparable](in []T) []T

Посмотрим на практический пример:

nums := []int{1, 2, 2, 3, 3, 3}

// Здесь мы получаем срез с уникальными значениями
unique := slice.Unique(nums)

// unique == []int{1, 2, 3} (порядок обычно сохраняется)

Обычно внутри используется map[T]struct{} для отслеживания уже встреченных значений.


Работа с группировками и “нарезкой” срезов

Chunk — разбиение на подмассивы фиксированного размера

Функция Chunk помогает разбить один большой срез на несколько меньших.

// Возможная сигнатура
func Chunk[T any](in []T, size int) [][]T

Давайте разберемся на примере:

nums := []int{1, 2, 3, 4, 5}

// Разбиваем на чанки размером 2
chunks := slice.Chunk(nums, 2)

// Ожидаемый результат:
// [][]int{
//   {1, 2},
//   {3, 4},
//   {5},
// }

Такая функция полезна, когда нужно:

  • отправлять данные “пакетами” (batch) в БД;
  • ограничивать размер одной операции (например, при работе с API).

Важно понимать, как lib-slice ведет себя внутри:

  • часто создаются новые срезы для чанков;
  • иногда чанки могут ссылаться на исходный массив (через срезы in[i:j]), если это устраивает контракт библиотеки.

GroupBy — группировка по ключу

GroupBy собирает элементы в группы по какому-то ключу.

// Возможная сигнатура
func GroupBy[T any, K comparable](in []T, keyFn func(T) K) map[K][]T

Покажу вам, как это реализовано на практике:

type Order struct {
    ID     int
    Status string
}

orders := []Order{
    {ID: 1, Status: "new"},
    {ID: 2, Status: "paid"},
    {ID: 3, Status: "new"},
}

// Группируем заказы по статусу
byStatus := slice.GroupBy(orders, func(o Order) string {
    // В качестве ключа берем поле Status
    return o.Status
})

// Теперь byStatus["new"] содержит заказы с ID 1 и 3
// а byStatus["paid"] - заказ с ID 2

Здесь вы получаете сразу map[Status][]Order, что сильно упрощает дальнейшую обработку.


Преобразование типов и вспомогательные утилиты

MapTo — преобразование с явным указанием типа результата

Иногда удобно разделять “обычный” Map и специальные функции преобразования. В некоторых вариантах lib-slice есть функции вроде MapToInt, MapToString и т.д. Но более универсальный вариант — дженериковый Map, о котором мы уже говорили.

Дополнительные варианты, которые часто встречаются:

  • ToPtrs — превращает []T в []*T;
  • Deref — превращает []*T в []T с учетом nil.

Давайте разберемся на примере с указателями.

// Превращаем значения в указатели
vals := []int{1, 2, 3}

ptrs := slice.Map(vals, func(v int) *int {
    // Создаем переменную и возвращаем на нее указатель
    vv := v
    return &vv
})

// Теперь ptrs - это []*int

И обратное преобразование:

// Здесь мы создаем новый срез значений, игнорируя nil
vals2 := slice.Filter(
    slice.Map(ptrs, func(p *int) (int, bool) {
        if p == nil {
            // В этом случае нам понадобится немного
            // другая вспомогательная функция, если она есть в lib-slice
        }
        return *p, true
    }),
    func(v int) bool { return true },
)

Конкретные названия функций могут отличаться, но подход примерно один: вы комбинируете базовые кирпичики (Map, Filter) или используете более специализированные средства lib-slice, если они есть.


Производительность и работа с памятью

Когда lib-slice полезна, а когда лучше обойтись без нее

Lib-slice позволяет очень сильно упростить код бизнес-логики. Но важно понимать цену:

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

Где lib-slice особенно полезна:

  • в коде, где важнее читаемость, чем предельная оптимизация;
  • в слоях бизнес-логики, обработке данных, маппинге DTO <-> доменные модели;
  • в тестах и вспомогательных утилитах.

Где стоит подумать, прежде чем использовать:

  • в горячих участках (hot path), вызываемых миллионы раз в секунду;
  • в низкоуровневых библиотеках, где важна каждая наносекунда.

Минимизация аллокаций

Многие функции lib-slice спроектированы так, чтобы:

  • заранее знать длину результатов (Map, Chunk при точном размере);
  • по возможности не создавать лишних срезов.

Например, Map может быть реализован так:

func Map[T any, R any](in []T, fn func(T) R) []R {
    // Здесь мы сразу создаем срез нужной длины,
    // чтобы избежать динамического роста через append
    out := make([]R, len(in))
    for i, v := range in {
        out[i] = fn(v)
    }
    return out
}

Filter, наоборот, не знает заранее, сколько элементов пройдет фильтр, поэтому часто делает:

  • либо один make(..., 0, len(in)) (с запасом емкости);
  • либо более сложную стратегию, чтобы уменьшить перераспределения.

Работа с мутабельными и иммутабельными операциями

Важно понимать, какие функции:

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

На практике большинство функций вроде Map, Filter, Unique, Chunk — иммутабельные: они создают новый срез. Это снижает риск неожиданных побочных эффектов.

Функции вида DeleteAt могут:

  • либо возвращать новый срез (чаще всего так и есть);
  • либо работать “на месте”, если так указано в документации.

Лучше всегда смотреть документацию lib-slice и придерживаться следующего стиля:

// Явно переопределяем переменную, чтобы не было двусмысленности
nums = slice.DeleteAt(nums, 2)
nums = slice.Unique(nums)

Примеры интеграции lib-slice в реальный код

Обработка HTTP-запроса с помощью lib-slice

Представим, что у вас есть API, которое принимает список ID и возвращает только активные сущности.

type Item struct {
    ID     int
    Active bool
}

func filterActive(items []Item) []Item {
    // Смотрите, я покажу вам, как с помощью Filter
    // оставить только активные элементы
    return slice.Filter(items, func(it Item) bool {
        // Условие - флаг Active должен быть true
        return it.Active
    })
}

Дальше вы можете добавить преобразование:

type ItemDTO struct {
    ID int `json:"id"`
}

func toDTO(items []Item) []ItemDTO {
    // Здесь мы размещаем пример Map, чтобы было проще понять
    return slice.Map(items, func(it Item) ItemDTO {
        // Преобразуем доменную модель в DTO
        return ItemDTO{ID: it.ID}
    })
}

И итоговую сборку в хэндлере:

func handleItems(w http.ResponseWriter, r *http.Request) {
    // Допустим, items вы получили из базы данных
    items := loadItemsFromDB()

    active := filterActive(items)
    dtos   := toDTO(active)

    // Теперь просто отправляем dtos клиенту в JSON
    // (здесь мы опускаем детали маршалинга и обработки ошибок)
}

Читая такую функцию, вы видите не детали циклов, а цепочку бизнес-операций.

Валидация и накопление ошибок

Допустим, вы валидируете список входных сущностей и хотите:

  • собрать все ошибки;
  • отбросить невалидные сущности.

lib-slice помогает сократить шаблонный код:

type Input struct {
    Value int
}

type Validated struct {
    Value int
}

func validateInput(in Input) (Validated, error) {
    if in.Value < 0 {
        // Сообщаем об ошибке, если условие нарушено
        return Validated{}, fmt.Errorf("value must be non-negative")
    }
    return Validated{Value: in.Value}, nil
}

func validateAll(inputs []Input) ([]Validated, []error) {
    // Сначала превращаем []Input в []результатов валидации
    type res struct {
        v   Validated
        err error
    }

    results := slice.Map(inputs, func(in Input) res {
        // Проверяем каждый элемент
        v, err := validateInput(in)
        return res{v: v, err: err}
    })

    // Теперь разделяем успешные и ошибочные элементы
    valids := make([]Validated, 0, len(results))
    errs   := make([]error, 0)

    slice.ForEach(results, func(r res) {
        if r.err != nil {
            // Если есть ошибка - добавляем ее в список ошибок
            errs = append(errs, r.err)
            return
        }
        // Если все хорошо - добавляем валидированное значение
        valids = append(valids, r.v)
    })

    return valids, errs
}

Такой подход делает структуру обработки ошибок более прозрачной и тестируемой.


Заключение

lib-slice — это набор утилит поверх стандартных срезов Go, который:

  • уменьшает объем шаблонного кода;
  • делает операции над коллекциями более выразительными;
  • использует дженерики и строгую типизацию.

Вы можете комбинировать базовые функции (Map, Filter, Reduce, ForEach, Find, Contains, DeleteAt, DeleteFunc, Unique, Chunk, GroupBy) для решения почти любых задач обработки срезов.

Важно помнить о балансе:

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

При грамотном использовании lib-slice помогает писать более чистый и поддерживаемый код, при этом оставаясь полностью совместимой со стандартной моделью срезов в Go.


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

1. Можно ли использовать lib-slice для конкурентной обработки элементов

Да, но аккуратно. Сама lib-slice обычно не запускает горутины внутри. Вы можете обернуть вызовы в свою конкурентную логику:

  1. Разбейте срез на чанки через Chunk.
  2. Для каждого чанка запустите горутину, которая применяет Map или Filter.
  3. Соберите результаты в один срез (убедитесь, что делаете это под мьютексом или заранее резервируете позиции).

Важно не модифицировать один и тот же срез одновременно из нескольких горутин.

2. Как избежать лишних аллокаций при использовании Map и Filter в цепочке

Есть два подхода:

  1. Разбивать цепочку:
    • сначала Filter, затем Map (или наоборот);
    • каждый шаг сохранять в переменную, чтобы компилятор мог оптимизировать.
  2. Если производительность критична, подумайте о написании одного “ручного” цикла, который сразу и фильтрует, и мапит. В остальных случаях накладные расходы lib-slice, как правило, приемлемы.

3. Можно ли использовать lib-slice с кастомными типами, которые не comparable

Да. Большинство функций (например, Map, Filter, ForEach, Find) работают с any и не требуют comparable. Ограничение comparable нужно только для функций, где используется == (Contains, IndexOf, Unique и т.п.). Для сложных типов используйте функции с предикатами (Find с func(T) bool и т.д.).

4. Как правильно тестировать код, который опирается на lib-slice

Подход простой:

  1. Тестируйте свою бизнес-логику как “черный ящик” — подавайте срезы на вход, проверяйте срезы на выходе.
  2. Не нужно мокать lib-slice — это обычные функции, их можно считать доверенной зависимостью.
  3. Если вы используете сложные цепочки (MapFilterGroupBy), разбейте код на несколько маленьких функций и тестируйте каждую по отдельности.

5. Что делать, если нужна функция, которой еще нет в lib-slice

Есть два варианта:

  1. Собрать нужное поведение из уже имеющихся примитивов (Map, Filter, Reduce, GroupBy, Chunk).
  2. Написать свою утилитную функцию по аналогии с существующими:
    • сделать ее дженерик-функцией func[T any] ...;
    • следовать тем же принципам работы с памятью (минимум аллокаций, понятное поведение);
    • при необходимости внести ее обратно в lib-slice как contribution, если это open source библиотека.
Стрелочка влевоСлайс модели в Go - работа с model-sliceГоризонтальные слайсы horizontal-slices - практическое руководство по организации кодаСтрелочка вправо

Все гайды по Fsd

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

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