Привет, Хабр! Я бэкенд-разработчик в спортивном медиа Спортс”. В этой статье расскажу о glinq – LINQ-подобном API для работы с коллекциями в Go. После появления дженериков в Go 1.18 стало возможным реализовать type-safe функциональные операции без рефлексии и дорогостоящих приведений типов.
Что такое glinq
glinq — это библиотека для функциональной работы с коллекциями, вдохновлённая LINQ из C#. Основная идея — превратить императивные циклы в декларативные цепочки операций:
// Императивный стиль
result := make([]int, 0)
for _, x := range numbers {
if x > 0 {
doubled := x * 2
if doubled < 100 {
result = append(result, doubled)
if len(result) >= 10 {
break
}
}
}
}
// Декларативный стиль
result := glinq.From(numbers).
Where(func(x int) bool { return x > 0 }).
Select(func(x int) int { return x * 2 }).
Where(func(x int) bool { return x < 100 }).
Take(10).
ToSlice()
Ключевые отличия от императивного подхода:
Ленивость — операции не выполняются до вызова материализующего метода (
ToSlice,First,Count)Композируемость — легко добавлять/удалять шаги без переписывания логики управления
Ранний выход — Take(10) останавливает обработку после 10-го элемента, даже если цепочка содержит фильтры
Но главная фишка glinq — не синтаксический сахар, а оптимизация через size hints. Это система, которая позволяет библиотеке заранее знать размер результата и избегать лишних аллокаций. Именно это даёт прирост производительности до 33,000x в оптимальных случаях по сравнению с наивными реализациями.
Ленивые вычисления и оптимизация памяти
В glinq операции не выполняются немедленно – они строят ленивый pipeline, который материализуется только при вызове финальной операции. Дополнительно библиотека отслеживает размер коллекции для оптимизации памяти.
Как работает ленивость
Рассмотрим пример: найти первое удвоенное чётное число.
numbers := []int{1, 2, 3, 4, 5}
// Eager-подход (samber/lo):
filtered := lo.Filter(numbers, func(x int) bool { return x%2 == 0 }) // [2, 4]
mapped := lo.Map(filtered, func(x int) int { return x * 2 }) // [4, 8]
result := mapped[0] // 4
// Lazy-подход (glinq):
first := glinq.From(numbers).
Where(func(x int) bool { return x%2 == 0 }).
Select(func(x int) int { return x * 2 }).
First() // 4 – обработан только первый элементВ eager-подходе создаются промежуточные массивы на каждом шаге, и обрабатываются все элементы. В glinq обход начинается только при вызове First() и останавливается после первого совпадения – это даёт ранний выход и отсутствие промежуточных аллокаций.
Size hints для эффективной работы с памятью:
glinq отслеживает размер коллекции через интерфейс Sizable[T]. Это позволяет предварительно аллоцировать слайсы нужного размера и реализовать Count() за O(1).
Когда размер известен:
From(slice)→len(slice)Select()→ размер сохраняется (1-к-1 преобразование)Take(n)→ min(size, n)
Когда размер теряется:
Where()— неизвестно, сколько элементов пройдёт фильтрSelectMany()— 1-ко-многим преобразованиеDistinctBy()— зависит от количества дубликатов
Благодаря size hints при материализации коллекции в ToSlice() glinq избегает множественных реаллокаций. Например, для коллекции в 1 млн элементов без преаллокации произойдёт ~20 аллокаций с экспоненциальным ростом (8KB → 16KB → ... → 8MB), итого ~40 MB выделенной памяти. С преаллокацией – одна аллокация 8MB, экономия памяти до 80%.
Типобезопасность через дженерики
До Go 1.18 многие библиотеки работали через interface{} и runtime-приведения. glinq использует дженерики, что позволяет компилятору гарантировать правильность типов на этапе сборки.
// go-linq: работа через interface{}
users1 := linq.From(data).
Where(func(x interface{}) bool {
return x.(User).Age >= 18
})
var slice []User
users1.ToSlice(&slice) // нужно вручную указывать тип
// glinq: типы проверяются компилятором
users := glinq.From(data).
Where(func(u User) bool { return u.Age >= 18 })
slice := users.ToSlice() // []User — тип известен на этапе компиляцииПреимущества: ошибки типов выявляются при компиляции, нет необходимости использовать type assertions, IDE корректно подсказывает типы и методы, код более читаемый и безопасный.
Композиция операций
Одним из ключевых преимуществ glinq является возможность композиции. Каждая операция возвращает новый Stream, который можно использовать для дальнейших трансформаций. При этом обработка элементов остаётся ленивой, промежуточные массивы не создаются.
type User struct {
Name string
Age int
IsActive bool
}
users := []User{
{"Alina", 25, true},
{"Bob", 17, true},
{"Charlie", 30, false},
{"Diana", 40, true},
}
activeAdults := glinq.From(users).
Where(func(u User) bool { return u.Age >= 18 }).
Take(2).
Where(func(u User) bool { return u.IsActive }).
ToSlice()
// Результат: ["Alina"]Несколько вызовов Where() объединяются логически. Итерация начинается только при вызове ToSlice() и останавливается после Take(2) – Charlie даже не будет проверен.
Работа с любыми источниками данных
glinq может работать с любым источником через простой интерфейс:
type Enumerable[T any] interface {
Next() (T, bool)
}
type Sizable[T any] interface {
Enumerable[T]
Size() (int, bool) // размер, если известен
}Любой тип, реализующий метод Next(), можно использовать с glinq. Если дополнительно реализован Size(), библиотека сможет оптимизировать работу с памятью.
Рассмотрим два практических примера.
Пример 1: генератор Фибоначчи
type FibonacciGenerator struct {
a, b int
n int // количество элементов
index int
}
func (f *FibonacciGenerator) Next() (int, bool) {
if f.index >= f.n { return 0, false }
val := f.a
f.a, f.b = f.b, f.a+f.b
f.index++
return val, true
}
func (f *FibonacciGenerator) Size() (int, bool) {
return f.n, true
}
// Использование:
fib := &FibonacciGenerator{a: 0, b: 1, n: 20}
first20 := glinq.FromEnumerable(fib).ToSlice()
// [0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181]Благодаря реализации Size() glinq аллоцирует слайс точн�� под 20 элементов. Генератор можно комбинировать с ленивыми операциями:Благодаря реализации Size() glinq аллоцирует слайс точно под 20 элементов. Генератор можно комбинировать с ленивыми операциями:
largeFibs := glinq.FromEnumerable(&FibonacciGenerator{0, 1, 1000}).
Where(func(x int) bool { return x > 100 }).
Take(5).
ToSlice()
// [144 233 377 610 987]Пример 2: чтение больших файлов построчно
type FileLineReader struct {
file *os.File
scanner *bufio.Scanner
}
func (r *FileLineReader) Next() (string, bool) {
if r.scanner.Scan() {
return r.scanner.Text(), true
}
r.file.Close()
return "", false
}
// Поиск первых 100 строк с ошибками в большом логе:
reader, _ := NewFileReader("/var/log/app.log")
errors := glinq.FromEnumerable(reader).
Where(func(line string) bool { return strings.Contains(line, "ERROR") }).
Take(100).
ToSlice()Файл читается лениво, построчно. Take(100) останавливает чтение после первых 100 совпадений – остальная часть файла не загружается в память. Это критично для логов размером в гигабайты.
Стриминг из каналов
type Event struct {
Severity int
Message string
}
const Critical = 1
type ChannelSource[T any] struct {
ch <-chan T
}
func (c *ChannelSource[T]) Next() (T, bool) {
val, ok := <-c.ch
return val, ok
}
func main() {
source := []Event{
{Severity: Critical},
{Severity: 2},
{Severity: Critical},
{Severity: 3},
{Severity: Critical},
}
dataChan := make(chan Event, 100)
go func() {
for _, event := range source {
dataChan <- event
}
close(dataChan)
}()
criticalEvents := glinq.FromEnumerable(&ChannelSource[Event]{ch: dataChan}).
Where(func(e Event) bool { return e.Severity == Critical }).
Take(2).
ToSlice()
fmt.Printf("Найдено %d критических события\n", len(criticalEvents))
}
Производительность: когда glinq выигрывает
Сравним glinq с популярными библиотеками на наборе из 100,000 элементов. Ниже приведены ключевые сценарии, показывающие, где какой подход эффек��ивнее. (cpu: Apple M1 Pro)
Сценарий | Описание | Glinq | Samber/lo | GoLinq/v3 | go-funk |
|---|---|---|---|---|---|
Filter+Map+First | Ранний выход при нахождении первого элемента < 10 |
|
|
|
|
Take(100) из 100k | Ленивая цепочка с ранним выходом после 100 элементов |
|
|
|
|
Select +Count | Подсчет элементов < 10 |
|
|
|
|
Aggregate | Полная обработка и суммирование элементов < 10 |
|
|
|
|
Логи бенчмарка
BenchmarkFilterMapFirst/Glinq-8 10000 104617 ns/op 304 B/op 10 allocs/op
BenchmarkFilterMapFirst/GoLinq-8 1459 812346 ns/op 400205 B/op 50009 allocs/op
BenchmarkFilterMapFirst/Samber-8 6504 184060 ns/op 1204232 B/op 2 allocs/op
BenchmarkFilterMapFirst/GoFunk-8 50 23449776 ns/op 10410284 B/op 400050 allocs/op
BenchmarkComplexChain/Glinq-8 350278 3460 ns/op 1400 B/op 18 allocs/op
BenchmarkComplexChain/GoLinq-8 82870 15371 ns/op 8592 B/op 785 allocs/op
BenchmarkComplexChain/Samber-8 5038 268217 ns/op 1605641 B/op 3 allocs/op
BenchmarkComplexChain/GoFunk-8 32 35077564 ns/op 14811527 B/op 549915 allocs/op
BenchmarkSelectCount/Glinq-8 15859772 75.57 ns/op 128 B/op 4 allocs/op
BenchmarkSelectCount/GoLinq-8 481 2658378 ns/op 1599151 B/op 199877 allocs/op
BenchmarkSelectCount/Samber-8 12759 94658 ns/op 802828 B/op 1 allocs/op
BenchmarkSelectCount/GoFunk-8 68 16912007 ns/op 9701547 B/op 300030 allocs/op
BenchmarkAggregate/Glinq-8 35685 33657 ns/op 216 B/op 7 allocs/op
BenchmarkAggregate/GoLinq-8 5096 219795 ns/op 120025 B/op 14989 allocs/op
BenchmarkAggregate/Samber-8 68434 18747 ns/op 81920 B/op 1 allocs/op
BenchmarkAggregate/GoFunk-8 470 2555667 ns/op 738362 B/op 45021 allocs/op
BenchmarkMemoryEfficiency/Glinq-8 2082 582978 ns/op 401801 B/op 14 allocs/op
BenchmarkMemoryEfficiency/GoLinq-8 424 2821977 ns/op 2645278 B/op 199542 allocs/op
BenchmarkMemoryEfficiency/Samber-8 8720 138429 ns/op 1204231 B/op 3 allocs/op
BenchmarkMemoryEfficiency/GoFunk-8 46 25055697 ns/op 14265498 B/op 450082 allocs/opКлючевые выводы: glinq превосходит в сценариях с ранним выходом и при известном размере коллекции. Eager-библиотеки (samber/lo) быстрее при полной обработке всех элементов. Библиотеки через interface{} (go-linq) проигрывают из-за конверсий и аллокаций.
Ограничения и сценарии применения
Хотя glinq приносит в Go мощные концепции ленивости и композируемости, важно понимать его ограничения. Как и любой абстрактный слой, он вносит определенные накладные расходы и не всегда является идеальным выбором.
Когда glinq особенно эффективен
Ленивые цепочки с ранним выходом:
First(), Take(), Any(), AnyMatchостанавливают обход сразу после достижения результатаSize-tracked операции:
Count()работает заO(1), ToSlice()аллоцирует память заранееСтриминг больших данных: чтение файлов построчно, обработка каналов, работа с источниками, которые не помещаются в память
Когда лучше использовать ручные циклы
Простейшие операции на маленьких массивах (<50–100 элементов): ручной цикл – это лучшая возможная оптимизация
Полная материализация: операции вроде
ToSlice(), OrderBy(), Reverse()материализуют поток целиком – glinq становится красивым сахаром без выигрыша в производительностиКритичная производительность: каждый оператор создаёт новый stream, замыкание и iterator-factory, что добавляет небольшие накладные расходы относительно простого цикла
Заключение
glinq пытается дать LINQ-подобный API, но без магии, с нормальной скоростью и минимальными накладными расходами. В экосистеме Go уже есть стрим-либы, и спрос на них есть — не всем хватает голых циклов. На этом фоне glinq выглядит неплохо: он остаётся простым, даёт ранний выход и работает быстро. Но если задача простая, обычный for всё равно лучше.
Главные идеи:
Ленивые вычисления для эффективной работы с большими данными
Type safety через дженерики Go 1.18+
Size hints для оптимизации Count() и ToSlice()
Extensibility для работы с любыми источниками данных
