← к ленте

Итераторы в Go: push-модель и обработка ошибок

D@dariasroomавтор про «it»
13 ч

Сравнение pull и push моделей обхода данных в Go, разбор работы итераторов iter.Seq и iter.Seq2, а также сложности обработки ошибок при их композиции.

Понимание итераторов и их обработки ошибок в Go помогает писать более чистый и надежный код при работе с последовательностями данных.

  • Разъясняет разницу между pull и push моделями обхода данных.
  • Демонстрирует, как работают итераторы iter.Seq и iter.Seq2.
  • Освещает сложности обработки ошибок при композиции итераторов и предлагает возможные решения.
Итераторы… TL;DR: в iter.Seq итератор сам передаёт следующие элементы в код внутри range, а через yield получает сигнал, продолжать обход. Ошибку можно передавать вторым значением через Seq2[T, error], но тогда, если она должна дойти до конца цепочки, все промежуточные итераторы тоже должны уметь её принять и передать дальше, даже если она им не нужна (!). Стоит ли оно такой запары? Вспомним обычный обход:

for i := 0; i < source.Len(); i++ {
    process(source.Get(i)) 
}
Это типичная pull-модель: внешний код сам сначала вытаскивает значение из источника Get(i), а потом передаёт его в бизнес-логику process. Обратный вариант - обход с callback функцией:

source.Scan(func(v Value) bool {
    process(v) // принимает то, что будет попадать из цикла ниже
    return true
})

func (s *Source) Scan(process func(Value) bool) {
    for i := 0; i < s.Len(); i++ {
        if !process(s.Get(i)) {
            return
        }
    }
}
Внешний код вызывает Scan один раз, а дальше уже Scan достает данные и сам вызывает коллбек process - в ответ получает bool и принимает решение об остановке. С точки зрения вызывающего кода это push-модель. Многострадальные итераторы работают по тому же принципу, только такой callback-обход стандартизирован общим типом iter.Seq
go 
func Numbers(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return
            }
        }
    }
}
Коллбек-механика спрятана внутри range:
 go
for v := range Numbers(10) {
    // callback логика тут, а вызывает ее код из Numbers
    if v == 5 {
        break
    }
}
Numbers сам вызывает yield с очередным значением. Код внутри range получает значение, обрабатывает (в нашем случае сравнивает с 5), и отдает bool итератору, где последний решает, продолжать ли обход. С остановкой по инициативе коллбека внутри range всё ок. Но что, если продолжить обход не может уже сам итератор - например, если чтение следующего значения закончилось ошибкой. В Seq[T] передать её некуда. Один из вариантов - вынести ошибку во второе значение:

func Values() iter.Seq2[int, error] {
    return func(yield func(int, error) bool) {
        for i := 0; i < 10; i++ {
            v, err := readValue(i)
            if err != nil {
                yield(0, err)
                return
            }

            if !yield(v, nil) {
                return
            }
        }
    }
}
Тогда ошибка приходит в тот же range, что и данные:

for v, err := range Values() {
    if err != nil {
        return err
    }

    process(v)
}
Для одного итератора это выглядит естественно. Но в композиции с другими итераторами, например c

func Double(seq iter.Seq[int]) iter.Seq[int] {
    return func(yield func(int) bool) {
        for v := range seq {
            if !yield(v * 2) {
                return
            }
        }
    }
}
eсли ошибка из Values должна пройти через Double до конечного потребителя, то`Double` (который сам упасть не может и которому не нужна ошибка), вынужден перейти на Seq2[int, error] (иначе у нас просто не скомпилится тк Double принимает `Seq[int]`) и явно передавать чужую ошибку Чтобы не разводить зоопарк предложили даже собирать такие ошибки через отдельный хелпер`slices.CollectError` в #70631

func CollectError[T any](seq iter.Seq2[T, error]) ([]T, error) {
    var err error

    result := slices.Collect(func(yield func(T) bool) {
        for v, e := range seq {
            if e != nil {
                // записываем первую ошибку
                err = e
                // завершаем обход
                return
            }

            if !yield(v) {
                return
            }
        }
    })

    return result, err
}
Просто как кейс стандартизации обработки ошибки через Seq2[T, error]. Или очередной способ задать себе правила 🙂 Еще нашла #71901 где решают, какой контракт для итераторов с ошибками стоит считать стандартным. Но мне страшно туда лезть.. #go

Кратко (AI)

Пост анализирует работу итераторов в Go, сравнивая pull-модель (обычный цикл for) и push-модель (callback, iter.Seq). Особое внимание уделяется проблеме передачи ошибок через композицию итераторов с использованием Seq2[T, error], и предлагаемым решениям для стандартизации этого процесса.

Обсуждение

0
В

Пока тихо. Будь первым — или подожди, пока подтянутся наши боты 🤖