Итераторы в Go: range-over-func и пакет iter

Go 1.23 разрешил range по функции: iter.Seq и iter.Seq2 — это функции-«пуш-итераторы», которые получают yield и вызывают его на каждом элементе. Как это работает под капотом, как писать свои итераторы, как связаны slices/maps-итераторы, ранний break и очистка — на живых примерах

До версии 1.23 range в Go умел обходить только встроенные типы: слайсы, map, каналы, строки, целые числа. Свой контейнер — дерево, страничный курсор БД, поток строк из файла — обходить через range было нельзя; приходилось либо материализовать всё в слайс, либо тащить пользователя через Next()/Value() вручную. Go 1.23 снял это ограничение: теперь range умеет ходить по функции особого вида, а стандартная библиотека получила пакет iter с типами iter.Seq и iter.Seq2. Это и есть «итераторы» — но не интерфейс с методами, как в Java или Rust, а обычная функция, которая получает yield и зовёт его на каждом элементе.

Эта статья — про то, как устроен range-over-func, как писать свои итераторы и композировать их без промежуточных слайсов, что происходит при раннем break и почему очистка ресурсов внутри итератора работает. И — отдельно, честно — сколько эта абстракция стоит в наносекундах. Всё заземлено на живой стенд digital-cookbook/go-iterators/: каждый итератор — реальный исходник, поведение покрыто тестами, гарантия очистки при break показана в отдельном демо, цена абстракции — прогнанные бенчмарки (go1.26.3 windows/amd64; модуль объявляет go 1.25.0 как минимум, сами range-over-func и iter — с Go 1.23).

Схема «Полдень»: итераторы range-over-func (Go 1.23) — однопроходная композиция Count → Filter → Map без промежуточных коллекций, push-машина с рычагом yield (true — продолжать, false — стоп/break), defer cleanup срабатывает даже при break, адаптер iter.Pull и Zip двух последовательностей поэлементно; цена: голый for ~260 ns против range-over-func ~2300 ns — итератор не бесплатен

В статье

Что появилось в Go 1.23

range-over-func вводит два новых типа, оба объявлены в пакете iter:

type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

iter.Seq[V] — это функция, которая принимает единственный аргумент yield func(V) bool и внутри себя вызывает его на каждом элементе. iter.Seq2[K, V] — то же, но yield принимает пару (ключ и значение, индекс и элемент). Их называют пуш-итераторами: итератор сам «проталкивает» (push) элементы в yield, а не отдаёт их по запросу. Тело цикла на стороне читателя и становится этим самым yield.

Разворачивается это так. Когда вы пишете for v := range someSeq { body }, компилятор превращает body в функцию yield, а сам вызов range — в вызов someSeq(yield). То есть итератор гоняет свой внутренний цикл и на каждом элементе зовёт тело вашего цикла как функцию. Отсюда сразу два следствия, которые определяют всё остальное:

  • break, return, continue в теле цикла должны как-то долететь до итератора, который крутит свой собственный for. Для этого yield возвращает bool: true — «продолжай выдавать», false — «остановись». Ранний выход из цикла (break или return из внешней функции) заставляет range вернуть из yield false, и итератор обязан прекратить выдачу.
  • Каждый элемент — это вызов функции. Не инкремент индекса, а честный вызов yield(v). Это ключ к пониманию цены, к которой мы вернёмся ниже.

Контракт со стороны автора итератора минимален: вызывай yield на каждом элементе и проверяй его ответ — если вернул false, прекрати выдачу. Нарушишь контракт (проигнорируешь false и вызовешь yield снова) — рантайм это ловит явной паникой: panic: runtime error: range function continued iteration after function for loop body returned false. Особенно легко напороться, если итератор продолжает звать yield из фоновой горутины после остановки; поэтому false — это сигнал «немедленно прекратить», а не «можно ещё разок».

Свои итераторы: генератор и композиция

Начнём с генератора — итератора, который производит значения из ничего. Count(n) выдаёт 0..n-1:

func Count(n int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for i := 0; i < n; i++ {
			if !yield(i) { // range сделал break — прекращаем
				return
			}
		}
	}
}

Обратите внимание на if !yield(i) { return } — это и есть соблюдение контракта. Пока yield возвращает true, крутим цикл; вернул false — читатель прервался, выходим. Использование ничем не отличается от обхода слайса:

for v := range Count(3) {
	fmt.Println(v) // 0, 1, 2
}

Самое ценное в этой модели — композиция. Итератор-обёртка над другим итератором — это просто функция, которая ходит по источнику через range и переизлучает элементы в свой yield. Filter пропускает только то, что проходит предикат:

func Filter[V any](src iter.Seq[V], keep func(V) bool) iter.Seq[V] {
	return func(yield func(V) bool) {
		for v := range src {
			if !keep(v) {
				continue
			}
			if !yield(v) {
				return
			}
		}
	}
}

Map трансформирует элементы, меняя при этом тип последовательности (iter.Seq[A]iter.Seq[B]):

func Map[A, B any](src iter.Seq[A], f func(A) B) iter.Seq[B] {
	return func(yield func(B) bool) {
		for a := range src {
			if !yield(f(a)) {
				return
			}
		}
	}
}

Оба — обычные обобщённые функции ([V any], [A, B any]); связь итераторов с параметрами типа разбирается в статье про дженерики в Goготовится, с 17 сентября. Теперь их можно соединять в конвейер, и он останется ленивым: ни один промежуточный слайс не создаётся, элемент проходит через всю цепочку за один проход.

// чётные из 0..9, каждое умножено на 10: 0, 20, 40, 60, 80
nums := Filter(Count(10), func(v int) bool { return v%2 == 0 })
tens := Map(nums, func(v int) int { return v * 10 })
for v := range tens {
	fmt.Println(v)
}

Если нужен не iter.Seq[V], а пара «индекс + значение» (как встроенный range по слайсу отдаёт i, v), помогает Enumerate — переход Seq[V]Seq2[int, V]:

func Enumerate[V any](src iter.Seq[V]) iter.Seq2[int, V] {
	return func(yield func(int, V) bool) {
		i := 0
		for v := range src {
			if !yield(i, v) {
				return
			}
			i++
		}
	}
}

Правило одно и то же в каждом итераторе: всегда проверяйте ответ yield. Пропустите if !yield(...) { return } хотя бы в одной обёртке — и break читателя не дойдёт до источника: Filter остановится, а Count под ним продолжит крутиться. В генераторах вроде Count это ещё и защита от бесконечного цикла.

Ранний break и очистка ресурсов

Раз break гарантированно доводит до итератора сигнал остановки (yield вернул false), итератор может надёжно освобождать ресурсы через defer. Это то, ради чего итераторы удобны для файлов, курсоров БД и соединений: открыл в начале, defer cleanup() — и очистка выполнится при любом выходе, включая ранний break.

func WithCleanup[V any](src iter.Seq[V], cleanup func()) iter.Seq[V] {
	return func(yield func(V) bool) {
		defer cleanup()
		for v := range src {
			if !yield(v) {
				return // cleanup всё равно выполнится через defer
			}
		}
	}
}

Механика такая: когда читатель делает break, range возвращает из yield false; итератор ловит это, делает return — и при выходе из функции-итератора срабатывает отложенный defer cleanup(). То же самое произойдёт, если источник просто закончится (цикл завершится штатно) или если тело цикла паникнёт. Ресурс закрывается в любом из этих случаев.

Демо cmd/break-cleanup показывает это буквально: итератор оборачивает Count(100) в WithCleanup, а читатель прерывается на 3-м элементе.

it := seq.WithCleanup(seq.Count(100), func() {
	fmt.Println(">>> cleanup: ресурс закрыт (defer в итераторе)")
})

for v := range it {
	fmt.Printf("  получили %d\n", v)
	if v == 2 {
		fmt.Println("  break")
		break
	}
}

Реальный вывод go run ./cmd/break-cleanup (сверено):

итератор открывает ресурс и обязан закрыть его при любом выходе

читаем и прерываемся на 3-м элементе:
  получили 0
  получили 1
  получили 2
  break
>>> cleanup: ресурс закрыт (defer в итераторе)

Вывод: несмотря на break, cleanup выполнился. range-over-func
сообщает итератору об остановке (yield вернул false), и defer отработал.

Получили 0, 1, 2, вышли по break — и cleanup всё равно отработал. Это не деталь реализации, а часть контракта range-over-func: остановка обхода извне всегда проходит через false из yield, поэтому defer внутри итератора — надёжное место для закрытия ресурса.

Ошибки в итераторах

Мотивирующие примеры итераторов — курсор БД, поток строк из файла — это ровно те случаи, где ошибка ввода-вывода неизбежна: соединение отвалилось, строка не распарсилась. Но у протокола yield нет отдельного канала под ошибку: сам итератор не может «вернуть error» через rangeyield отдаёт только значения. (Из тела цикла потребитель, конечно, может сделать return err — но это его собственная обработка, а не способ итератора сообщить о сбое.) Значит, ошибку нужно закодировать в самом значении или хранить отдельно. Идиома — сделать ошибку вторым элементом последовательности через iter.Seq2[V, error]: на каждом шаге отдаём либо (значение, nil), либо (zero, err) и на этом прекращаем выдачу.

// lines читает строки из r; при ошибке чтения отдаёт (zero, err) и останавливается.
func lines(r io.Reader) iter.Seq2[string, error] {
    return func(yield func(string, error) bool) {
        sc := bufio.NewScanner(r)
        for sc.Scan() {
            if !yield(sc.Text(), nil) {
                return // потребитель вышел по break
            }
        }
        if err := sc.Err(); err != nil {
            yield("", err) // ошибку отдаём последним шагом
        }
    }
}

Потребитель проверяет ошибку прямо в цикле и сам решает — прервать обход или продолжить:

for line, err := range lines(f) {
    if err != nil {
        return fmt.Errorf("чтение: %w", err) // break происходит автоматически при return
    }
    process(line)
}

Альтернатива, если по контракту у последовательности не может быть «частичного» результата, — оставить iter.Seq[V] и держать ошибку в замыкании, проверяя её после цикла (по образцу bufio.Scanner, у которого сам Scan возвращает bool, а Err() — уже после). Обе схемы рабочие; выбор — «ошибка на каждом шаге» против «одна ошибка в конце». Чего делать НЕ стоит — глотать ошибку молча: range-обёртка над источником данных без пути для ошибки прячет реальные сбои I/O.

iter.Pull: pull-итераторы

Пуш-модель удобна, но у неё есть граница: она умеет обходить одну последовательность за раз. Есть задачи, которые одним range не выразить, — например, идти по двум источникам «в ногу» (zip, слияние отсортированных потоков). Для них пакет iter даёт iter.Pull: он превращает пуш-итератор iter.Seq[V] в пул-итератор — пару функций next и stop, где элементы вытягиваются вручную.

func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())

next() возвращает следующий элемент и ok (false — последовательность кончилась), stop() досрочно завершает итерацию и освобождает её ресурсы. stop обязательно вызывать — обычно через defer stop() — иначе итератор, реализованный на горутине, останется висеть, а его defer-очистка не выполнится.

Zip идёт по двум источникам параллельно, продвигая каждый на шаг и выдавая пары, пока не кончится любой:

func Zip[A, B any](a iter.Seq[A], b iter.Seq[B]) iter.Seq2[A, B] {
	return func(yield func(A, B) bool) {
		nextA, stopA := iter.Pull(a)
		defer stopA() // обязательно освободить ресурсы пул-итератора
		nextB, stopB := iter.Pull(b)
		defer stopB()

		for {
			av, okA := nextA()
			bv, okB := nextB()
			if !okA || !okB { // хотя бы одна кончилась
				return
			}
			if !yield(av, bv) {
				return
			}
		}
	}
}

Take берёт первые n элементов, не материализуя весь источник, — важно, когда источник огромен (миллион строк, бесконечный генератор):

func Take[V any](src iter.Seq[V], n int) []V {
	next, stop := iter.Pull(src)
	defer stop()

	out := make([]V, 0, n)
	for i := 0; i < n; i++ {
		v, ok := next()
		if !ok {
			break
		}
		out = append(out, v)
	}
	return out
}

Практическое правило: пуш-итератор (iter.Seq) — для «прошёл и обработал», пул-итератор (iter.Pull) — когда нужен ручной контроль над продвижением: синхронный обход нескольких источников, склейка, ограничение по числу элементов. И всегда defer stop().

Стандартные итераторы: slices и maps

С Go 1.23 стандартная библиотека сама начала отдавать iter.Seq. Наиболее ходовые функции — в slices и maps:

  • slices.All(s)iter.Seq2[int, V] — индексы и значения слайса.
  • slices.Values(s)iter.Seq[V] — только значения.
  • slices.Collect(seq)[]V — материализует последовательность в слайс.
  • slices.Sorted(seq)[]V — собирает и сразу сортирует.
  • maps.Keys(m)iter.Seq[K], maps.Values(m)iter.Seq[V] — ключи и значения map как последовательности. Порядок при этом не определён (как у любого обхода map — см. срезы и мапы); нужен воспроизводимый вывод — сортируйте: slices.Sorted(maps.Keys(m)) даёт ключи по возрастанию одним выражением.

Смысл в том, что теперь ваши Filter/Map и stdlib-итераторы говорят на одном языкеiter.Seq. Можно собрать конвейер над slices.Values, отфильтровать, преобразовать и в конце материализовать через slices.Collect:

src := slices.Values([]int{1, 2, 3, 4, 5, 6})
even := Filter(src, func(v int) bool { return v%2 == 0 })
result := slices.Collect(even) // []int{2, 4, 6}

Collect устроен просто — гоняет range по последовательности и делает append в растущий слайс (в стенде это функция Collect, локальный аналог slices.Collect):

func Collect[V any](src iter.Seq[V]) []V {
	var out []V
	for v := range src {
		out = append(out, v)
	}
	return out
}

Ровно тут ленивый конвейер перестаёт быть ленивым: Collect прогоняет всю цепочку и складывает результат в память, а слайс по ходу растёт с переаллокациями. Как именно append удваивает бэкинг-массив и почему это стоит держать в голове при материализации больших последовательностей — в разборе внутренностей срезов и map. Пока последовательность не материализована, промежуточных слайсов нет вообще — в этом и ценность.

Цена абстракции

Теперь — честно, сколько это стоит. Есть расхожее мнение, что «итератор бесплатен, компилятор всё заинлайнит». Это миф. Замерим на стенде сумму 0..999 разными способами; числа сверены на go1.26.3 windows/amd64 (ориентировочно, машинозависимы):

Бенчмарк ns/op allocs/op Что показывает
BenchmarkPlainLoop ~260 0 голый for i — эталон
BenchmarkSliceRange ~340–420 0 range по слайсу
BenchmarkSeqRange ~2300 0 range по iter.Seq — каждый элемент это вызов yield
BenchmarkSeqFilterMap ~6500 5 (104 B) конвейер Filter+Map через итераторы, один проход

Разрыв заметный: на этом прогоне (go1.26.3 windows/amd64) range по iter.Seq (~2300 ns) вышел примерно в девять раз дороже голого цикла (~260 ns). Конкретная кратность машинозависима — на Linux/AMD в тех же условиях разница ближе к трёхкратной (PlainLoop ~1280 ns против SeqRange ~4020 ns), — но направление и причина одинаковы везде: каждый элемент — это вызов функции-yield, а не инкремент индекса. На тривиальном теле (сложение) этот вызов и составляет почти всю работу, поэтому накладной расход виден в чистом виде. range по слайсу (~340–420 ns) стоит между ними — здесь компилятор разворачивает обход без вызова на элемент. Не привязывайтесь к «девяти разам» как к константе языка — снимите свои числа.

Конвейер Filter+Map (~6500 ns, 5 аллокаций / 104 B) добавляет к цене yield ещё и аллокации: замыкания конвейера уходят в кучу. Почему именно замыкания в цепочке итераторов аллоцируют — потому что захваченные ими переменные переживают вызов и «убегают» из стека; механика разобрана в статье про escape-анализ и размещение памяти. При этом важно: пять аллокаций — это на весь проход по тысяче элементов, а не на элемент. Промежуточных слайсов конвейер не создаёт — в этом он выигрывает у наивного «отфильтровали в новый слайс, потом преобразовали в ещё один».

Ключевая оговорка про интерпретацию этих чисел: относительная цена тает, когда тело делает реальную работу. Разница в разы (девятикратная на этом Windows-прогоне, трёхкратная на Linux/AMD) возникает потому, что «полезная нагрузка» — одно сложение — сама по себе почти бесплатна, и на её фоне вызов yield огромен. Как только тело начинает парсить строку, читать из сети, обращаться к диску или считать что-то нетривиальное, пара наносекунд на вызов yield растворяется в общем времени и перестаёт быть значимой.

Отсюда практический вывод:

  • Итераторы берут ради читаемости и композиции — ленивые конвейеры без промежуточных слайсов, единый интерфейс обхода для любого источника (слайс, map, дерево, курсор БД), надёжная очистка ресурсов через defer. Это архитектурная ценность, не производительная.
  • В горячем счётном цикле — где тело тривиально, а итераций миллионы в секунду — оставляйте обычный for. Здесь range-over-func платит за абстракцию наносекундами, которые нечем компенсировать.

Иными словами, выбор между итератором и голым циклом — это не выбор «медленно или быстро», а выбор «читаемо и композируемо» против «максимально дёшево на элемент». Для сравнения с обходом через интерфейс с методами Next()/Value() — и почему пара «тип+указатель» у интерфейса тоже не бесплатна — см. статью про интерфейсы Go.

Демо и версии

  • Код: живой стенд digital-cookbook/go-iterators/ — подкаталог seq/ (пуш-итераторы: Count, Filter, Map, Enumerate, WithCleanup, Collect), pull/ (iter.Pull: Zip, Take) и cmd/break-cleanup/ (демо очистки при break). Поведение покрыто тестами (go test ./... зелёный), код в тексте — выжимки.
  • Как воспроизвести: go run ./cmd/break-cleanup (очистка при break) и go test -bench=. -benchmem -run=^$ ./seq/ (цена абстракции). Чистый Go, без docker, только stdlib (iter). Числа сверены на go1.26.3 windows/amd64; модуль объявляет go 1.25.0 как минимум, range-over-func и iter доступны с Go 1.23.
  • Оговорка: числа бенчмарков машинозависимы и версионнозависимы — снимайте их на своей сборке, а не сверяйтесь дословно с приведёнными. Абсолютные значения плавают, но соотношение «голый цикл дешевле range-over-func» устойчиво.
  • Смежные статьи: дженерики в Goготовится, с 17 сентября (обобщённые Filter/Map), внутренности срезов и map (материализация в слайс, рост append), escape-анализ и память (почему конвейер аллоцирует), интерфейсы Go (сравнение с интерфейсным обходом).

Документация и первоисточники

  • pkg.go.dev/iter — пакет iter: типы Seq, Seq2, функция Pull, контракт yield и правила использования пул-итераторов.
  • Go Blog — Range Over Function Types — как разворачивается range-over-func, зачем yield возвращает bool, разбор пуш- и пул-итераторов.
  • Go Blog — Iterators — обзор итераторов в стандартной библиотеке (slices, maps) и паттерны их применения.
  • The Go Programming Language Specification — For statements with range clause — формальное правило range по функции: какие сигнатуры допустимы и как трактуется тело цикла.
  • Go 1.23 Release Notes — раздел про range-over-func и появление пакета iter, а также новые итераторы в slices/maps.

Обсуждение в Telegram

Присоединиться →

Комментарии