До версии 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).
В статье
- Что появилось в Go 1.23
- Свои итераторы: генератор и композиция
- Ранний break и очистка ресурсов
- Ошибки в итераторах
- iter.Pull: pull-итераторы
- Стандартные итераторы: slices и maps
- Цена абстракции
- Демо и версии
- Документация и первоисточники
Что появилось в 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вернуть изyieldfalse, и итератор обязан прекратить выдачу.- Каждый элемент — это вызов функции. Не инкремент индекса, а честный вызов
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» через range — yield отдаёт только значения. (Из тела цикла потребитель, конечно, может сделать 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.
Комментарии