Привет, Хабр! Я бэкенд-разработчик в спортивном медиа Спортс”. В этой статье расскажу о 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()

Ключевые отличия от императивного подхода:

  1. Ленивость — операции не выполняются до вызова материализующего метода (ToSlice, First, Count)

  2. Композируемость — легко добавлять/удалять шаги без переписывания логики управления

  3. Ранний выход — 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

104.6 μs

304 B

10 allocs

184.1 μs

1.2 MB

2 allocs

812.3 μs

400.2 KB

50K allocs

23.4 ms

10.4 MB

400K allocs

Take(100) из 100k

Ленивая цепочка с ранним выходом после 100 элементов

1.27 μs

744 B

12 allocs

82.6 μs

401.4 KB

1 alloc

60.8 μs

13.6 KB

1.1K allocs

14.7 ms

3.6 MB

226K allocs

Select

+Count

Подсчет элементов < 10

75.6 ns

128 B

4 allocs

94.7 μs

802.8 KB

1 alloc

2.66 ms

1.6 MB

199K allocs

16.9 ms

9.7 MB

300K allocs

Aggregate

Полная обработка и суммирование элементов < 10

33.7 μs

216 B

7 allocs

18.7 μs

81.9 KB

1 alloc

219.8 μs

120 KB

15K allocs

2.56 ms

738 KB

45K allocs

Логи бенчмарка
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 для работы с любыми источниками данных


GitHub: https://github.com/CreateLab/GLinq

Комментарии (0)