Цикл по функции в Go: тело — это функция, которую вызывает итератор, и отсюда всё остальное
`for x := range f` выглядит сахаром над обычным циклом. Это не сахар: тело становится отдельной функцией, `break` превращается в `return false`, а `return` из тела не выходит немедленно — и ровно поэтому `defer` внутри итератора отрабатывает при любом выходе. Четыре ошибки итератора ловит рантайм, а обход стоит либо ноль, либо вызов на каждый элемент, и решает это не итератор, а то, видит ли компилятор функцию в точке обхода.
Полное техническое изложение
TL;DR
for x := range f, гдеf— функция, появилось в Go 1.23. Это не сокращение записи: компилятор делает из тела цикла отдельную функцию и отдаёт её итератору.- Итератор вызывает ваше тело. Не наоборот.
break— этоreturn false,continue—return true. Больше у тела ничего нет: одно булево значение на всё управление.returnиз тела не выходит сразу. Сначала доигрывает итератор — и отрабатывает егоdefer. Поэтому файл, открытый итератором, закроется при любом выходе из цикла.- Четыре ошибки итератора ловит рантайм, во время работы. Одна из них — про
defer recover()внутри итератора. - Цена: ноль или в шесть раз. Решает не итератор, а то, видит ли компилятор функцию в точке обхода.
Что вообще происходит
Справа от range теперь может стоять функция. Спецификация объясняет, что это
значит, одной фразой:
For a function f, the iteration proceeds by calling f with a new,
synthesized yield function as its argument.
Для функции f обход выполняется вызовом f с новой синтезированной функцией yield в качестве аргумента.
То есть цикл по функции — это вызов этой функции. А тело цикла компилятор
превращает в отдельную функцию и передаёт ей аргументом. Она и называется
yield.
// то, что вы написали
for x := range f {
...
}
// то, во что это превращается
f(func(x T) bool {
...
})Проверить это можно без всякой теории: пусть итератор печатает, что он делает, а тело — что получило.
1. ЦИКЛ ПО ФУНКЦИИ — ЭТО ВЫЗОВ ЭТОЙ ФУНКЦИИ
------------------------------------------------------------------
for v := range talking { ... }
итератор: начал работу
итератор: зову yield(0)
тело цикла: получил 0
итератор: yield вернул true
итератор: зову yield(1)
тело цикла: получил 1
итератор: yield вернул true
итератор: зову yield(2)
тело цикла: получил 2
итератор: yield вернул true
итератор: значения кончились, возвращаюсь сам
Строки чередуются: тело выполнилось между двумя строками итератора. Так бывает только в одном случае — если тело вызвал сам итератор.
Управление проходит через одно значение
Тело стало функцией, а функция умеет только вернуть значение. Значит, всё, чем цикл управляет собой, придётся уложить в возвращаемое значение.
2. BREAK — ЭТО RETURN FALSE, CONTINUE — ЭТО RETURN TRUE
------------------------------------------------------------------
тело доработало до конца шаг 0: yield вернул true
тело доработало до конца шаг 1: yield вернул true
тело доработало до конца шаг 2: yield вернул true
тело сделало continue шаг 0: yield вернул true
тело сделало continue шаг 1: yield вернул true
тело сделало continue шаг 2: yield вернул true
тело сделало break шаг 0: yield вернул false
Первые две группы совпали дословно: для итератора continue и «тело
доработало до конца» — одно событие. Он не знает слов break и continue, он
видит только true или false.
Что из этого следует для вас. Если вы пишете итератор, результат yield
игнорировать нельзя:
// сломано: обход продолжится, что бы тело ни ответило
for _, v := range s {
yield(v)
}
// правильно
for _, v := range s {
if !yield(v) {
return // тело сказало «хватит»
}
}Первый вариант компилируется и выглядит безобидно. Он падает на первом же
break в чужом цикле — паникой, текст которой приведён ниже.
Главное: return из тела не выходит сразу
Тело — это функция. Из функции нельзя вернуться за того, кто её вызвал. А
return внутри цикла обязан выйти из внешней функции — той, где цикл стоит.
3. RETURN ИЗ ТЕЛА НЕ ВЫХОДИТ НЕМЕДЛЕННО
------------------------------------------------------------------
Вызываем функцию, у которой внутри цикла стоит return:
итератор: открыл ресурс
тело: получил 0
тело: получил 1
тело: получил 2
тело: делаю return
итератор: yield вернул false, возвращаюсь
итератор: ЗАКРЫЛ ресурс (defer)
функция вернула: "значение из тела цикла"
Смотрите на порядок. После «делаю return» управление не ушло из функции.
Сначала yield вернул false. Потом итератор доработал. Потом отработал его
defer. И только после этого вернулась вызывающая функция.
Компилятору некуда деть этот return: между телом и внешней функцией стоит
живой вызов итератора с невыполненными defer. Поэтому return в теле
означает «останови цикл и запомни, что надо выйти», а сам выход происходит
позже.
Ради этого итераторы и удобны. Раз итератор всегда доигрывает свои
defer — при любом выходе из тела, — уборку внутри него можно писать как в
обычной функции:
func lines(name string) iter.Seq[string] {
return func(yield func(string) bool) {
f, err := os.Open(name)
if err != nil {
return
}
defer f.Close() // выполнится при любом выходе из тела цикла
sc := bufio.NewScanner(f)
for sc.Scan() {
if !yield(sc.Text()) {
return
}
}
}
}Вызывающему коду не надо знать, что там открыт файл:
for line := range lines("access.log") {
if strings.Contains(line, "panic") {
return line // файл закроется сам
}
}Обратный вызов так не умеет: из него нельзя ни выйти наружу, ни прервать обход — возвращать ему нечего. В этом и разница, и она не косметическая.
А вот тут есть тонкость, которая решает, куда класть уборку. При return из
тела итератор получает управление обратно и доигрывает свой код после yield.
При панике в теле — не получает: yield не возвращает управление вовсе,
паника разматывает кадр итератора насквозь, и из его кода выполняются только
defer. Замерено:
итератор: открыл ресурс
тело: паникую
итератор: ЗАКРЫЛ ресурс (defer)
вызывающий: поймал панику: паника тела
Отсюда правило: уборка идёт в defer, а не в строку после yield. А
после yield держите только выход — при return эта строка выполняется, и всё
дороже проверки булева значения вы платите ещё раз.
Четыре ошибки, которые ловит рантайм
Все четыре — во время работы, а не при компиляции.
5. ЧЕТЫРЕ ОШИБКИ ИТЕРАТОРА, КОТОРЫЕ ЛОВИТ РАНТАЙМ
------------------------------------------------------------------
yield после break: runtime error: range function continued iteration after function for loop body returned false
yield после паники: runtime error: range function continued iteration after loop body panic
yield после цикла: runtime error: range function continued iteration after whole loop exit
итератор съел панику: runtime error: range function recovered a loop body panic and did not resume panicking
Первые три — про повторный вызов yield. У каждого цикла есть своя переменная
состояния, и каждый вход в тело с ней сверяется. Поэтому итератор, который
сохранил yield и зовёт его позже или из другой горутины, падает предсказуемо,
а не портит данные молча.
Четвёртая — та, до которой не додумываются.
func bad(yield func(int) bool) {
defer func() { _ = recover() }() // выглядит как разумная защита
yield(0)
}Такой defer ставят, чтобы итератор не уронил программу на своей ошибке. Но
паника из тела проходит через кадр итератора — тело вызвано отсюда, — и
этот recover ловит её тоже. Снаружи паника просто исчезла бы. Рантайм этого
не допускает.
Три правила отсюда: не сохраняйте yield; проверяйте его результат и выходите
по false; не ставьте в итераторе общий defer recover().
Цена: ноль или в шесть раз
Обычный довод против итераторов — «вызов функции на каждый элемент дорог». Ответа два, и их надо читать вместе.
Компилятор видит, какая функция придёт:
8. КОГДА КОМПИЛЯТОР ВИДИТ ИТЕРАТОР, ОБХОД СТОИТ КАК ОБЫЧНЫЙ
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range по срезу 4782-4971 0 0 x1.0
range по slices.Values 4108-4242 0 0 x0.9
range по рукописному iter.Seq 4024-4339 0 0 x0.8
iter.Seq параметром функции 4030-4327 0 0 x0.8
обратный вызов, без итератора 4027-4248 0 0 x0.8
Не видит:
9. КОГДА НЕ ВИДИТ, ПОЯВЛЯЕТСЯ ВЫЗОВ НА КАЖДЫЙ ЭЛЕМЕНТ
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range по срезу 4716-4931 0 0 x1.0
iter.Seq в функцию без вкладыв. 28820-29810 42 3 x6.0
iter.Seq из пакетной переменной 29615-31949 42 3 x6.2
Итератор один и тот же, работа одна и та же. Изменилось одно: во втором случае компилятор не знает, какая функция придёт, и не может её вложить. Появились и вызов на элемент, и выделения памяти.
Правило, которое можно применять глазами. Итератор бесплатен там, где
компилятор видит функцию в точке обхода: slices.Values(s) прямо в заголовке
цикла, свой итератор рядом, короткая обёртка. Он стоит вызова на элемент там,
где функция приходит параметром большой функции, из переменной или из поля
структуры.
Не «не пользуйтесь итераторами», а «не прячьте итератор за границу вкладывания в горячем цикле».
Честно про десятую долю. В блоке 8 строки с итератором вышли чуть быстрее
обычного range, и диапазоны не перекрылись. Из этого не следует, что итератор
быстрее: это два равносильных цикла, которые компилятор развернул по-разному, и
знак такой разницы меняется от версии к версии. Следует другое — ожидаемой
шестикратности здесь нет вовсе.
Не меряйте это на b.Loop
Если пойдёте проверять цену у себя, знайте про ловушку.
11. ЛОВУШКА ЗАМЕРА: ВНУТРИ B.LOOP ВКЛАДЫВАНИЕ ВЫКЛЮЧЕНО
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range, замер на b.Loop 6209-6769 0 0 x1.0
slices.Values, замер на b.Loop 29275-33132 88 4 x4.7
Те же две строки, что в блоке 8, и другой ответ. Дело не в итераторе, а в
замере: начиная с Go 1.24 компилятор не вкладывает вызовы внутри тела b.Loop.
Это записано в самом компиляторе:
No inlining nor devirtualization performed on b.Loop body (Внутри тела b.Loop не выполняется ни вкладывание, ни девиртуализация)
Значит, замер на b.Loop всегда показывает только дорогую половину — ту, что
в блоке 9. Меряйте циклом по b.N и с -benchmem.
iter.Seq — это просто имя типа функции
В язык ничего нового не добавляли:
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)Поэтому ваш итератор ничем не отличается от библиотечного. maps.Keys и
maps.Values дают последовательность по карте, slices.Values — по срезу,
slices.All — пары «индекс, значение». В обратную сторону: slices.Collect
собирает в срез, slices.Sorted собирает и сортирует. Отсюда самый короткий
способ обойти карту по порядку — slices.Sorted(maps.Keys(m)).
Одно соглашение стоит соблюдать: метод обхода коллекции называется All, и
возвращает он iter.Seq или iter.Seq2. Тогда вызывающий код читается
одинаково у всех: for k, v := range c.All().
Что осталось за рамками
iter.Pull — обратное направление, когда следующее значение дёргаете вы сами;
это отдельная тема. И одноразовые итераторы — те, которые нельзя обойти дважды:
здесь их нет ни в одном замере.
Короткий список
- Проверяйте результат
yieldи выходите поfalse. - Уборку кладите в
deferитератора — она отработает при любом выходе из тела. - После
yieldставьте только выход. - Не сохраняйте
yieldи не передавайте его в горутину. - Не ставьте в итераторе общий
defer recover(). - Метод обхода коллекции называйте
All. - В горячем цикле не прячьте итератор за границу вкладывания.
- Не меряйте итераторы на
b.Loop.
TL;DR
for x := range f — не сокращение записи. Компилятор превращает тело цикла
в отдельную функцию и отдаёт её итератору. Всё остальное следует отсюда.
- Итератор вызывает ваше тело, а не наоборот. В стеке видны чередующиеся
кадры, и у тела есть собственное имя с суффиксом
-rangeN. break— этоreturn false,continue—return true. Итератор не знает этих слов; всё управление проходит через одно булево значение.returnиз тела не выходит немедленно. Сначалаyieldвозвращаетfalse, итератор доигрывает и отрабатывает свойdefer, и только потом возвращается вызывающая функция. Ради этого итераторы и удобны: уборка вdeferвыполняется при любом выходе из тела, включаяreturnи панику.- Четыре ошибки итератора ловит рантайм, все — во время работы. Самая
неочевидная: общий
defer recover()в итераторе съедает панику вашего тела. - Цена — не одно число, а два, и между ними шесть раз. Компилятор видит функцию — 4108–4242 нс и ноль выделений; не видит — 28820–29810 нс, 42 Б и 3 выделения на операцию.
- Мерить это на
b.Loopнельзя: внутри его тела вкладывание выключено, и итератор всегда выходит дорогим.
Механизм, которого в языке нет
Go 1.23 разрешил писать for x := range f, где f — функция. Замеры в этой
статье сняты на go1.24.7 — то есть на версии, где механизм уже год как не
новость.
Соблазн прочитать это как сахар велик: справа от range стояли срез, карта,
канал и число, теперь стоит ещё и функция — казалось бы, ещё один случай в том
же списке. Но остальные случаи компилятор разворачивает в цикл, а этот —
выворачивает наизнанку.
For a function f, the iteration proceeds by calling f with a new,
synthesized yield function as its argument.
Для функции f обход выполняется вызовом f с новой синтезированной функцией yield в качестве аргумента.
Синтезированная — то есть придуманная компилятором. Придумывает он её из вашего
тела цикла. Комментарий в rewrite.go начинается ровно с этого:
The basic idea is to rewrite
(Основная идея — переписать)
for x := range f {
...
}в
f(func(x T) bool {
...
})— и следующей же строкой себя поправляет:
But it's not usually that easy.
(Но обычно всё не так просто.)
Дальше — по одному месту, где «не так просто», на раздел. Каждое из них что-нибудь меняет в том, как вы напишете код.
Тело цикла — это функция, и вызывает её итератор
Проверяется это без всякой теории: пусть итератор печатает, что он делает, а тело — что получило.
1. ЦИКЛ ПО ФУНКЦИИ — ЭТО ВЫЗОВ ЭТОЙ ФУНКЦИИ
------------------------------------------------------------------
for v := range talking { ... }
итератор: начал работу
итератор: зову yield(0)
тело цикла: получил 0
итератор: yield вернул true
итератор: зову yield(1)
тело цикла: получил 1
итератор: yield вернул true
итератор: зову yield(2)
тело цикла: получил 2
итератор: yield вернул true
итератор: значения кончились, возвращаюсь сам
Печать итератора и печать тела чередуются: тело выполнилось
МЕЖДУ двумя строками итератора. Так может быть только в
одном случае — если тело вызвал сам итератор.
Стек вызовов из тела двух вложенных циклов по функциям
(внешний идёт по one, внутренний по other):
0 main.frames
1 main.block1.one.block1-range2-range4
2 main.other
3 main.block1-range2
4 main.one
5 main.block1
6 main.main
7 runtime.main
8 runtime.goexit
Стек читается снизу вверх, и кадры в нём идут парами: main.one — итератор,
main.block1-range2 — тело внешнего цикла, main.other — второй итератор,
main.block1.one.block1-range2-range4 — тело внутреннего. Составное имя у
верхнего кадра — след вкладывания: компилятор втянул one внутрь block1 и
собрал имя из обоих.
И что с того. Во-первых, у вашего тела цикла есть имя, и вы его увидите —
в трассировке паники, в профиле, в отладчике. Кадр с суффиксом -rangeN — не
чужой код и не деталь рантайма: это строки, которые вы написали между фигурными
скобками, и искать их надо по этому имени.
Во-вторых, и это важнее: между вашим кодом до цикла и вашим кодом внутри
цикла теперь стоит чужой кадр. Всё, что в обычном цикле работало «через
границу тела» — выход из функции, defer, паника, — теперь пересекает границу
вызова. Дальше вся статья про то, что из этого следует.
break — это return false, continue — это return true
Тело стало функцией, а функция умеет только вернуть значение. Значит, всё, чем цикл управляет собой, надо уместить в возвращаемое значение — и оно там одно.
2. BREAK — ЭТО RETURN FALSE, CONTINUE — ЭТО RETURN TRUE
------------------------------------------------------------------
тело доработало до конца шаг 0: yield вернул true
тело доработало до конца шаг 1: yield вернул true
тело доработало до конца шаг 2: yield вернул true
тело сделало continue шаг 0: yield вернул true
тело сделало continue шаг 1: yield вернул true
тело сделало continue шаг 2: yield вернул true
тело сделало break шаг 0: yield вернул false
Первые две группы совпадают дословно. С точки зрения итератора continue и
«тело доработало до конца» — одно и то же событие, потому что и то и другое
означает return true. Компилятор про это говорит прямо:
If the body contains a "break", that break turns into "return false", to tell f to stop. And if the body contains a "continue", that turns into "return true", to tell f to proceed with the next value. (Если тело содержит break, этот break превращается в return false, чтобы сказать f остановиться. А если тело содержит continue, он превращается в return true, чтобы сказать f перейти к следующему значению.)
Ту же вещь с другой стороны — со стороны того, кто итератор пишет, —
формулирует пакет iter:
Yield returns true if the iterator should continue with the next element in
the sequence, false if it should stop.
Yield возвращает true, если итератору следует продолжить со следующим элементом последовательности, и false, если следует остановиться.
И что с того. Если вы пишете итератор, у вас нет права игнорировать
результат yield. Строка
for _, v := range s {
yield(v) // результат выброшен — итератор сломан
}компилируется и выглядит безобидно, но означает «продолжать обход, что бы тело
ни ответило». Первый же break в чужом цикле превратит это в панику — какую
именно, показано двумя разделами ниже. Правильная форма ровно одна:
for _, v := range s {
if !yield(v) {
return // тело сказало «хватит» — обход обязан прекратиться
}
}return из тела не выходит немедленно
Здесь механизм перестаёт быть безобидной перестановкой кода. Тело — это
функция; из функции нельзя вернуться за того, кто её вызвал. А return в теле
цикла обязан выйти из внешней функции — той, где стоит цикл.
3. RETURN ИЗ ТЕЛА НЕ ВЫХОДИТ НЕМЕДЛЕННО
------------------------------------------------------------------
Вызываем функцию, у которой внутри цикла стоит return:
итератор: открыл ресурс
тело: получил 0
тело: получил 1
тело: получил 2
тело: делаю return
итератор: yield вернул false, возвращаюсь
итератор: ЗАКРЫЛ ресурс (defer)
функция вернула: "значение из тела цикла"
Порядок строк здесь — весь результат. После «делаю return» управление не
ушло из функции. Сначала yield вернул false. Потом итератор доработал
свой код после yield. Потом отработал его defer. И только после этого
вернулась вызывающая функция — со значением, которое тело просило вернуть три
строки назад.
Иначе быть не может, и это стоит проговорить до конца. Компилятору некуда
деть return: он не может выпрыгнуть из тела через кадр итератора, потому что
между ними живой вызов с ещё не выполненными defer. Поэтому return в теле
означает не «выйти», а «остановить цикл и запомнить, что после него надо
выйти»: код выхода уходит в переменную, а сам выход выполняется в вызывающей
функции — уже после того, как итератор вернул управление.
Спецификация ту же вещь описывает со стороны договора:
If the loop body terminates (such as by a break statement), yield returns
false and must not be called again.
Если тело цикла завершается (например, оператором break), yield возвращает false и не должен вызываться снова.
Обратите внимание на слово terminates: break назван примером, а не
единственным случаем. return из тела — такое же завершение, и приходит к
итератору тем же способом: как false из yield.
И что с того, а это здесь сильнее самого факта. Отсроченный выход
означает, что итератор всегда доигрывает свои defer. Не «обычно», не
«если тело вело себя прилично» — всегда, при любом выходе из тела. А раз так,
уборку внутри итератора можно делать так же, как в обычной функции:
func lines(name string) iter.Seq[string] {
return func(yield func(string) bool) {
f, err := os.Open(name)
if err != nil {
return
}
defer f.Close() // отработает при любом выходе из тела цикла
sc := bufio.NewScanner(f)
for sc.Scan() {
if !yield(sc.Text()) {
return
}
}
}
}Вызывающему коду не надо ни закрывать файл, ни знать, что он есть:
for line := range lines("access.log") {
if strings.Contains(line, "panic") {
return line // файл закроется, хотя закрывать его никто не просил
}
}Это и есть причина, по которой итераторы удобнее обратных вызовов: обратный вызов не может ни прервать обход, ни выйти из внешней функции — возвращать ему нечего, — а итератор и то и другое разрешает, не отдавая наружу владение ресурсом. Ради этого механизм и устроен так, как устроен, и именно эта черта пропадает, когда его называют сахаром.
Паника приходит к итератору не так, как return
Слово «всегда» выше относится к defer — и только к нему. Само по себе это
различие мелкое, но оно решает, куда класть уборку, поэтому измерено отдельно.
Тот же итератор, но со строкой ПОСЛЕ yield, и тело не возвращается, а
паникует:
4. ПАНИКА ПРИХОДИТ К ИТЕРАТОРУ НЕ ТАК, КАК RETURN
------------------------------------------------------------------
Тот же опыт, что в блоке 3, с двумя отличиями: у итератора
есть строка ПОСЛЕ yield, а тело вместо return паникует.
итератор: открыл ресурс
тело: паникую
итератор: ЗАКРЫЛ ресурс (defer)
вызывающий: поймал панику: паника тела
Строки «ПОСЛЕ yield» в выводе нет. При панике yield не возвращает
управление вовсе — паника разматывает кадр итератора насквозь, и из его кода
выполняются только defer.
Отсюда правило, ради которого этот опыт и поставлен: уборка идёт в defer,
а не в строку после yield. Строка после yield не выполнится при панике
тела; defer выполнится и там и там. И вторая половина того же правила: после
yield вообще стоит держать только выход — при return из тела эта строка
выполняется, и всё, что там стоит дороже проверки булева значения, вы платите
ещё раз.
Четыре ошибки, которые ловит рантайм
У договора «yield вернул false — больше не звать» есть исполнитель. Все
четыре нарушения ловятся во время работы, а не при компиляции, и текст ошибки в
каждом случае свой.
5. ЧЕТЫРЕ ОШИБКИ ИТЕРАТОРА, КОТОРЫЕ ЛОВИТ РАНТАЙМ
------------------------------------------------------------------
yield после break: runtime error: range function continued iteration after function for loop body returned false
yield после паники: runtime error: range function continued iteration after loop body panic
yield после цикла: runtime error: range function continued iteration after whole loop exit
итератор съел панику: runtime error: range function recovered a loop body panic and did not resume panicking
Откуда они берутся, компилятор объясняет там же, где описывает переписывание:
To permit checking that an iterator is well-behaved -- that is, that it does not call the loop body again after it has returned false or after the entire loop has exited (it might retain a copy of the body function, or pass it to another goroutine) -- each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body. (Чтобы можно было проверять, что итератор ведёт себя правильно — то есть не вызывает тело цикла снова после того, как оно вернуло false, или после того, как весь цикл завершился (итератор мог сохранить копию функции тела или передать её в другую горутину), — у каждого сгенерированного цикла есть своя переменная #stateK, по которой проверяются допустимые схемы вызова функции yield для тела цикла.)
То есть у каждого цикла — своя переменная состояния, и каждый вход в тело
сверяется с ней. Это объясняет и первые три текста, и то, почему они разные:
состояние различает «тело сказало стоп», «тело паникнуло» и «цикл вообще
кончился». Сохранённый где-то yield, позванный позже или из другой горутины,
падает предсказуемо и с внятным текстом, а не портит данные молча.
Четвёртая — та, до которой не додумываются. Она не про повторный вызов вовсе:
func bad(yield func(int) bool) {
defer func() { _ = recover() }() // выглядит как разумная защита
yield(0)
}Такой defer пишут, чтобы итератор не уронил программу на своей собственной
ошибке. Но паника из тела цикла проходит через кадр итератора — тело же
вызвано отсюда, — и этот recover ловит её тоже. Снаружи это выглядело бы как
исчезнувшая паника: цикл молча закончился, вызывающая функция поехала дальше,
а panic из её собственного кода нигде не всплыл. Рантайм такого не допускает
и падает сам, с текстом range function recovered a loop body panic and did not resume panicking.
И что с того. Три правила, каждое прямо из этих четырёх строк.
- Не сохраняйте
yield— ни в поле, ни в пакетной переменной, ни в горутине. Он живёт ровно один обход. - Проверяйте результат
yieldи выходите поfalse. Это не стиль, а единственная форма, которая не падает. - Не ставьте в итераторе общий
defer recover(). Если ловить свою панику всё-таки надо, ловите её вокруг своего кода, а не вокругyield, и обязательно поднимайте чужую снова.
Вложенность и метки: не прыжок, а две остановки
break с меткой через два уровня выглядит как прыжок мимо промежуточного кода.
Но прыгать некуда: между уровнями стоят кадры двух итераторов, и обоим надо
вернуть управление.
6. ВЛОЖЕННЫЕ ЦИКЛЫ И BREAK С МЕТКОЙ
------------------------------------------------------------------
Два цикла по функциям, break с меткой из внутреннего:
тело: A1
тело: A2
тело: A3
внутренний итератор: вернулся
тело: B1
тело: B2
тело: break Loop
внутренний итератор: yield вернул false
внутренний итератор: вернулся
внешний итератор: yield вернул false
внешний итератор: вернулся
Читается это так: внутренний yield вернул false, внутренний итератор
доиграл и вышел; тело внешнего цикла тоже вернуло false, внешний итератор
доиграл и вышел. Две остановки подряд, каждая по своему yield.
Заодно видно поведение внутреннего цикла при нормальном ходе: после A3
внутренний итератор вернулся сам, без всякого false, и следующая же строка —
B1. Уборка внутреннего итератора выполняется на каждом шаге внешнего, а
не один раз в конце.
И что с того. break с меткой из вложенного цикла по функциям безопасен:
defer обоих итераторов выполнится. Но помнить стоит и цену — если внутренний
итератор на каждом шаге внешнего открывает соединение, он его на каждом шаге и
закроет, и по коду цикла этого не видно.
iter.Seq — это имя типа функции, а не новая сущность
В язык для всего перечисленного не добавили ничего. Пакет iter содержит два
объявления типов, и оба — просто имена:
7. ITER.SEQ2 И СТАНДАРТНАЯ БИБЛИОТЕКА
------------------------------------------------------------------
Два типа, и оба — просто имена для функций:
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
slices.Sorted(maps.Keys(m)): [груша слива яблоко]
slices.Sorted(maps.Values(m)): [1 2 3]
Метод All() коллекции возвращает iter.Seq2, и по нему
ходят обычным for ... range с двумя переменными:
груша 3
слива 2
яблоко 1
slices.Collect собирает последовательность обратно в срез:
slices.Collect((set{m}).All() по ключам) -> [груша слива яблоко]
slices.Collect(slices.Values([]int{4,1,3})) -> [4 1 3]
slices.All даёт пары «индекс, значение» для среза:
0 a
1 b
2 c
Документация пакета определяет итератор теми же словами, какими описывают обратный вызов:
An iterator is a function that passes successive elements of a sequence to a
callback function, conventionally named yield.
Итератор — это функция, которая передаёт последовательные элементы последовательности в функцию обратного вызова, по соглашению называемую yield.
Стандартная библиотека держится на этом и ничего больше не требует.
maps.Keys и maps.Values отдают iter.Seq по карте, slices.Values — по
срезу, slices.All — пары «индекс, значение» как iter.Seq2. В обратную
сторону работают slices.Collect, который собирает последовательность в срез,
и slices.Sorted, который собирает и сортирует. Именно поэтому
slices.Sorted(maps.Keys(m)) — самый короткий способ обойти карту по
отсортированным ключам, и в нём нет ни одного нового понятия.
И что с того. Раз iter.Seq — это имя типа функции, ваш собственный
итератор ничем не отличается от библиотечного: он подходит в slices.Collect,
в slices.Sorted, в любую чужую функцию, принимающую iter.Seq. Писать его
надо как функцию, а не как объект с состоянием.
И одно соглашение, за которое стоит держаться: метод обхода коллекции
называется All. Так его называют в пакете iter и в стандартной
библиотеке, и вызывающий код от этого читается одинаково у всех:
for k, v := range c.All().
Цена — не одно число, а два
Теперь то, ради чего механизм чаще всего откладывают: «вызов функции на каждый элемент — это же дорого». Ответа два, и они отличаются в шесть раз.
Сначала случай, когда компилятор в точке обхода знает, какая функция придёт.
8. КОГДА КОМПИЛЯТОР ВИДИТ ИТЕРАТОР, ОБХОД СТОИТ КАК ОБЫЧНЫЙ
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range по срезу 4782-4971 0 0 x1.0
range по slices.Values 4108-4242 0 0 x0.9
range по рукописному iter.Seq 4024-4339 0 0 x0.8
iter.Seq параметром функции 4030-4327 0 0 x0.8
обратный вызов, без итератора 4027-4248 0 0 x0.8
Ни одного выделения памяти и то же время, что у обычного range.
Компилятор вложил и итератор, и тело: вызова на элемент не осталось.
Теперь тот же итератор и та же работа, но компилятор функцию не видит: она приходит параметром в функцию, которая не помещается в бюджет вкладывания, или лежит в пакетной переменной.
9. КОГДА НЕ ВИДИТ, ПОЯВЛЯЕТСЯ ВЫЗОВ НА КАЖДЫЙ ЭЛЕМЕНТ
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range по срезу 4716-4931 0 0 x1.0
iter.Seq в функцию без вкладыв. 28820-29810 42 3 x6.0
iter.Seq из пакетной переменной 29615-31949 42 3 x6.2
Та же работа и тот же итератор. Изменилось одно: компилятор больше не
знает, какая функция придёт, и не может её вложить. Появились и вызов
на элемент, и выделения памяти под замыкание.
Эти два блока обязаны читаться вместе. Поодиночке каждый вводит в
заблуждение: по первому итератор бесплатен всегда, по второму дорог всегда.
Верно ни то ни другое. Одна и та же строка for v := range seq стоит либо
ноль, либо вызов на каждый элемент, и решает это не итератор, а то, известна
ли компилятору функция в точке обхода.
Отсюда правило, которое можно применять глазами, без профилировщика.
- Бесплатно там, где компилятор видит функцию: литерал или вызов вроде
slices.Values(s)прямо в заголовке цикла, свой итератор рядом, короткая функция-обёртка, которая целиком вкладывается. - Вызов на элемент и выделения там, где не видит:
iter.Seqпришёл параметром в большую функцию, лежит в переменной, приходит из поля структуры или из интерфейса. Замер закрывает два таких случая из перечисленных — параметр невкладываемой функции и пакетную переменную; остальные — тот же случай по той же причине: компилятор не может назвать функцию по имени.
Практический вывод не «не пользуйтесь итераторами», а «не прячьте итератор за границу, которую компилятор не переступает, в горячем цикле». В холодном коде разница в 25 микросекунд на 10 000 элементов не стоит ни одной перестановки.
Про пары «ключ — значение» отдельного разговора не нужно: iter.Seq2 ведёт
себя так же.
10. ПАРА КЛЮЧ-ЗНАЧЕНИЕ
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range по срезу с индексом 4764-5186 0 0 x1.0
range по slices.All (iter.Seq2) 4752-4950 0 0 x1.0
Про десятую долю, на которую итератор оказался быстрее
В блоке 8 строки с итератором идут чуть ниже обычного range: 4108–4242
против 4782–4971. Диапазоны не перекрылись, и это надо сказать вслух, а не
замолчать.
Из этого не следует, что итератор быстрее обычного range. Это два
разных, но равносильных цикла: одна и та же сумма, разные развёртки, которые
компилятор выбрал на этой машине. Знак такой разницы меняется от версии к
версии и от машины к машине, и строить на нём нечего.
Следует из блока другое, и это как раз содержательно: ожидаемой шестикратности здесь нет, и выделений нет ни у одной строки. Ровно то же время и ноль байт показывает и последняя строка — обход обратным вызовом, без всякого итератора. То есть в этом случае итератор не добавляет ничего: ни времени, ни памяти.
Ловушка замера: на b.Loop итератор всегда дорогой
Этот раздел нужен потому, что читатель после предыдущего пойдёт мерить у себя — и на нынешнем Go напишет замер, который соврёт.
11. ЛОВУШКА ЗАМЕРА: ВНУТРИ B.LOOP ВКЛАДЫВАНИЕ ВЫКЛЮЧЕНО
------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
обычный range, замер на b.Loop 6209-6769 0 0 x1.0
slices.Values, замер на b.Loop 29275-33132 88 4 x4.7
Те же две строки, что в блоке 8, и другой ответ. Причина не в
итераторе, а в замере: компилятор не вкладывает вызовы внутри тела
b.Loop. Это записано в самом компиляторе, в
cmd/compile/internal/inline/interleaved/interleaved.go:
«No inlining nor devirtualization performed on b.Loop body».
b.Loop появился как рекомендуемая замена циклу по b.N — он сам держит
таймер и сам не даёт выбросить тело. Но начиная с Go 1.24 компилятор не
вкладывает вызовы внутри его тела:
No inlining nor devirtualization performed on b.Loop body (Внутри тела b.Loop не выполняется ни вкладывание, ни девиртуализация)
Для обычного кода это мелочь. Для итератора — определяющее обстоятельство: вся
его цена и состоит в том, вложил компилятор функцию или нет. Замер на b.Loop
выключает вкладывание принудительно и потому всегда показывает только
дорогую половину — ту, что в блоке 9.
И что с того. Если вы меряете цену итератора, меряйте её циклом по b.N,
как это сделано в bench/goiter/cost_test.go, и не забудьте -benchmem:
столбец выделений здесь говорит больше, чем наносекунды. А чужой замер,
показывающий, что итераторы в Go дороги в шесть раз, стоит первым делом
проверить на предмет b.Loop в теле.
Что осталось за рамками
Две вещи названы, чтобы вы знали, что они есть, и не искали их выше.
iter.Pull — обратное направление: превращение итератора в пару функций,
которые вы дёргаете сами, когда вам нужно следующее значение. Устройство у него
своё и цена своя; это отдельная тема.
Одноразовые итераторы — те, которые нельзя обойти дважды, потому что за ними стоит расходуемый источник. Здесь их нет ни в одном замере, и все приведённые выводы касаются итераторов, которые можно позвать снова.
Что стоит проверять в своём коде
- Пишете итератор — проверяйте результат
yieldи выходите поfalse. Игнорировать его нельзя: компилируется, но падает на первом жеbreakв чужом цикле. - Уборку кладите в
deferитератора. Она отработает при любом выходе из тела, включаяreturnиз середины и панику. Это главная причина писать итератор, а не обратный вызов. - После
yieldв итераторе ставьте только выход. Всё, что стоит там ещё, выполнится и при выходе тела поreturn. - Не сохраняйте
yield— ни в поле, ни в пакетной переменной, ни в горутине: рантайм это ловит и роняет программу. - Не ставьте в итераторе общий
defer recover(): он съест панику вашего тела, и рантайм об этом сообщит отдельной паникой. - Метод обхода коллекции называйте
Allи возвращайте из негоiter.Seqилиiter.Seq2— тогда ваш тип встанет в те жеslices.Collectиslices.Sorted. - В горячем цикле не прячьте итератор за границу вкладывания: параметр большой функции, пакетная переменная, поле структуры. Там он стоит вызова на каждый элемент и трёх выделений.
- Не меряйте итераторы на
b.Loop. Внутри его тела вкладывание выключено, и ответ всегда будет «дорого».
Как воспроизвести числа
Два скрипта, и они отвечают на разные вопросы. bench/goiter/mechanics.go не
меряет времени вовсе: он печатает порядок вызовов, стек, тексты паник и вывод
стандартной библиотеки — блоки 1–7. bench/goiter/cost_test.go вместе с
bench/goiter/cost.sh меряет цену — блоки 8–11.
Каталог — отдельный модуль Go, поэтому тесты запускаются из него:
go run bench/goiter/mechanics.go
cd bench/goiter && ./cost.sh
cd bench/goiter && go test .
Третья команда — это TestSameWork: она проверяет, что все способы обхода
дают одну и ту же сумму. Без неё замер сравнивал бы разную работу и молчал об
этом: строка, случайно обходящая половину среза, выглядела бы просто быстрой.
cost.sh гоняет бенчмарки чередующимися раундами и печатает диапазон по
раундам, а не лучший результат. Если диапазоны двух строк перекрылись, разницы
между ними нет, сколько бы её ни казалось по одному прогону.
Опубликованный прогон: go1.24.7 linux/amd64, Intel Xeon 2,80 ГГц. Механизм
появился в Go 1.23, а числа сняты на 1.24.7 — на другой версии абсолютные
наносекунды будут другими. Не изменится главное: столбцы Б/оп и выдел.
и то, что разница между блоками 7 и 8 держится на одном обстоятельстве —
видит компилятор функцию или нет. В выводе mechanics.go чисел нет вовсе,
поэтому он воспроизводится дословно.
Чем измерено
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
for x := range f— не сокращение записи. Компилятор превращает тело цикла в отдельную функцию и отдаёт её итератору. Всё остальное следует отсюда.- Итератор вызывает ваше тело, а не наоборот. В стеке видны чередующиеся кадры, и у тела есть собственное имя с суффиксом
-rangeN. break— этоreturn false,continue—return true. Итератор не знает этих слов; всё управление проходит через одно булево значение.returnиз тела не выходит немедленно. Сначалаyieldвозвращаетfalse, итератор доигрывает и отрабатывает свойdefer, и только потом возвращается вызывающая функция. Ради этого итераторы и удобны: уборка вdeferвыполняется при любом выходе из тела, включаяreturnи панику.- Четыре ошибки итератора ловит рантайм, все — во время работы. Самая неочевидная: общий
defer recover()в итераторе съедает панику вашего тела. - Цена — не одно число, а два, и между ними шесть раз. Компилятор видит функцию — 4108–4242 нс и ноль выделений; не видит — 28820–29810 нс, 42 Б и 3 выделения на операцию.
- Мерить это на
b.Loopнельзя: внутри его тела вкладывание выключено, и итератор всегда выходит дорогим.
На самом деле
- Не разворачивает, а выворачивает наизнанку. Спецификация говорит прямо: For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument. Тело цикла становится ОТДЕЛЬНОЙ функцией, которую вызывает итератор, и у неё есть собственное имя: в стеке из тела двух вложенных циклов видны кадры
main.one→main.block1-range2→main.other→main.block1.one.block1-range2-range4— итератор, тело, итератор, тело. Суффикс-rangeNпридумывает компилятор. Из этого одного факта следуют и отложенныйreturn, и четыре паники рантайма, и шестикратная разница в цене. - Не сразу. Порядок печати из прогона: «тело: делаю return», затем «итератор: yield вернул false, возвращаюсь», затем «итератор: ЗАКРЫЛ ресурс (defer)», и только потом функция вернула значение. Иначе быть не может: тело — это функция, из неё нельзя вернуться за вызывающего, а между телом и внешней функцией стоит живой вызов итератора с невыполненными
defer. Поэтомуreturnв теле означает «останови цикл и запомни, что надо выйти»: код выхода уходит в переменную, а сам выход выполняется после того, как итератор вернул управление. - Это ровно та черта, ради которой итераторы и пишут. Раз итератор всегда получает управление обратно — при ЛЮБОМ выходе из тела, включая
returnиз середины и панику, — егоdeferвсегда отрабатывает. Итератор может открыть файл, отдать строки и закрыть файл, а вызывающему коду не надо ни знать про файл, ни закрывать его. Обратный вызов так не умеет: из него нельзя ни выйти наружу, ни прервать обход — возвращать ему нечего. - Это код, который падает. Первый же
breakв чужом цикле даётruntime error: range function continued iteration after function for loop body returned false. Проверок таких четыре, и все — во время работы, а не при компиляции: each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body. Единственная правильная форма —if !yield(v) { return }. - Это самая неочевидная из четырёх ошибок. Паника из ТЕЛА цикла проходит через кадр итератора — тело же вызвано отсюда, — и общий
recoverловит её тоже. Снаружи это выглядело бы как исчезнувшая паника. Рантайм такого не допускает:runtime error: range function recovered a loop body panic and did not resume panicking. Ловить свою панику можно, но вокруг своего кода, а не вокругyield, и чужую надо поднимать снова. - Прыгать некуда: между уровнями стоят кадры двух итераторов. Прогон показывает две остановки подряд: «внутренний итератор: yield вернул false», «внутренний итератор: вернулся», затем «внешний итератор: yield вернул false», «внешний итератор: вернулся». Оба получили
false, оба доиграли, уборка обоих выполнилась. Цена у этого своя, и она не в потерянной уборке, а в обратном: внутренний итератор отрабатывает свойdeferна КАЖДОМ шаге внешнего цикла. - Цена не одна, а две, и между ними шесть раз. Когда компилятор видит функцию в точке обхода:
rangeпоslices.Values— 4108–4242 нс, 0 Б/оп, 0 выделений против 4782–4971 нс у обычногоrangeпо срезу. Когда не видит: 28820–29810 нс, 42 Б/оп и 3 выделения — тот же итератор и та же работа. Решает не механизм, а то, известна ли компилятору функция: литерал илиslices.Values(s)в заголовке цикла — бесплатно; параметр большой функции, пакетная переменная, поле структуры — вызов на элемент. - Сначала посмотрите, нет ли в замере
b.Loop. Начиная с Go 1.24 компилятор не вкладывает вызовы внутри его тела — No inlining nor devirtualization performed on b.Loop body, — и итератор там дорог всегда. Те же две строки, что дали 4782–4971 и 4108–4242 нс в замере поb.N, наb.Loopдают 6209–6769 и 29275–33132 нс, 88 Б/оп и 4 выделения. Разница целиком в способе замера, а не в итераторе. - Это имя типа функции, и больше ничего:
type Seq[V any] func(yield func(V) bool). В язык для механизма не добавили ни одной сущности, поэтому свой итератор ничем не отличается от библиотечного и подходит в те жеslices.Collectиslices.Sorted. Документация пакета определяет его теми же словами, какими описывают обратный вызов: An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.
Что разобрано
- Механизм, которого в языке нет
- Тело цикла — это функция, и вызывает её итератор
- `break` — это `return false`, `continue` — это `return true`
- `return` из тела не выходит немедленно
- Четыре ошибки, которые ловит рантайм
- Вложенность и метки: не прыжок, а две остановки
- `iter.Seq` — это имя типа функции, а не новая сущность
- Цена — не одно число, а два
- Ловушка замера: на `b.Loop` итератор всегда дорогой
- Что осталось за рамками
- Что стоит проверять в своём коде
- Как воспроизвести числа
- Чем измерено
Расхожие заблуждения
for x := range f — синтаксический сахар: компилятор просто разворачивает это в обычный цикл
Не разворачивает, а выворачивает наизнанку. Спецификация говорит прямо: For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument
(Для функции f обход выполняется вызовом f с новой синтезированной функцией yield в качестве аргумента). Тело цикла становится ОТДЕЛЬНОЙ функцией, которую вызывает итератор, и у неё есть собственное имя: в стеке из тела двух вложенных циклов видны кадры main.one → main.block1-range2 → main.other → main.block1.one.block1-range2-range4 — итератор, тело, итератор, тело. Суффикс -rangeN придумывает компилятор. Из этого одного факта следуют и отложенный return, и четыре паники рантайма, и шестикратная разница в цене.
return из тела цикла выходит из функции сразу, как в обычном for
Не сразу. Порядок печати из прогона: «тело: делаю return», затем «итератор: yield вернул false, возвращаюсь», затем «итератор: ЗАКРЫЛ ресурс (defer)», и только потом функция вернула значение. Иначе быть не может: тело — это функция, из неё нельзя вернуться за вызывающего, а между телом и внешней функцией стоит живой вызов итератора с невыполненными defer. Поэтому return в теле означает «останови цикл и запомни, что надо выйти»: код выхода уходит в переменную, а сам выход выполняется после того, как итератор вернул управление.
Отложенный выход — неприятная особенность, о которой надо помнить
Это ровно та черта, ради которой итераторы и пишут. Раз итератор всегда получает управление обратно — при ЛЮБОМ выходе из тела, включая return из середины и панику, — его defer всегда отрабатывает. Итератор может открыть файл, отдать строки и закрыть файл, а вызывающему коду не надо ни знать про файл, ни закрывать его. Обратный вызов так не умеет: из него нельзя ни выйти наружу, ни прервать обход — возвращать ему нечего.
Итератор, который не смотрит на результат yield, — просто неаккуратный код
Это код, который падает. Первый же break в чужом цикле даёт runtime error: range function continued iteration after function for loop body returned false. Проверок таких четыре, и все — во время работы, а не при компиляции: each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body
(у каждого сгенерированного цикла есть своя переменная #stateK, по которой проверяются допустимые схемы вызова функции yield для тела цикла). Единственная правильная форма — if !yield(v) { return }.
defer func() { recover() }() в итераторе — разумная защита от собственных ошибок
Это самая неочевидная из четырёх ошибок. Паника из ТЕЛА цикла проходит через кадр итератора — тело же вызвано отсюда, — и общий recover ловит её тоже. Снаружи это выглядело бы как исчезнувшая паника. Рантайм такого не допускает: runtime error: range function recovered a loop body panic and did not resume panicking. Ловить свою панику можно, но вокруг своего кода, а не вокруг yield, и чужую надо поднимать снова.
break с меткой из вложенного цикла по функциям — прыжок наружу, и уборка внутреннего итератора теряется
Прыгать некуда: между уровнями стоят кадры двух итераторов. Прогон показывает две остановки подряд: «внутренний итератор: yield вернул false», «внутренний итератор: вернулся», затем «внешний итератор: yield вернул false», «внешний итератор: вернулся». Оба получили false, оба доиграли, уборка обоих выполнилась. Цена у этого своя, и она не в потерянной уборке, а в обратном: внутренний итератор отрабатывает свой defer на КАЖДОМ шаге внешнего цикла.
Итератор стоит вызова функции на каждый элемент — это цена механизма
Цена не одна, а две, и между ними шесть раз. Когда компилятор видит функцию в точке обхода: range по slices.Values — 4108–4242 нс, 0 Б/оп, 0 выделений против 4782–4971 нс у обычного range по срезу. Когда не видит: 28820–29810 нс, 42 Б/оп и 3 выделения — тот же итератор и та же работа. Решает не механизм, а то, известна ли компилятору функция: литерал или slices.Values(s) в заголовке цикла — бесплатно; параметр большой функции, пакетная переменная, поле структуры — вызов на элемент.
Замер показал, что итератор в шесть раз дороже обычного цикла, — значит, так и есть
Сначала посмотрите, нет ли в замере b.Loop. Начиная с Go 1.24 компилятор не вкладывает вызовы внутри его тела — No inlining nor devirtualization performed on b.Loop body
(Внутри тела b.Loop не выполняется ни вкладывание, ни девиртуализация), — и итератор там дорог всегда. Те же две строки, что дали 4782–4971 и 4108–4242 нс в замере по b.N, на b.Loop дают 6209–6769 и 29275–33132 нс, 88 Б/оп и 4 выделения. Разница целиком в способе замера, а не в итераторе.
iter.Seq — новая сущность языка, что-то вроде интерфейса итератора
Это имя типа функции, и больше ничего: type Seq[V any] func(yield func(V) bool). В язык для механизма не добавили ни одной сущности, поэтому свой итератор ничем не отличается от библиотечного и подходит в те же slices.Collect и slices.Sorted. Документация пакета определяет его теми же словами, какими описывают обратный вызов: An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield
(Итератор — это функция, которая передаёт последовательные элементы последовательности в функцию обратного вызова, по соглашению называемую yield).
Проверьте себя
Функция содержит цикл по итератору, у итератора есть defer с закрытием ресурса, а в теле цикла стоит return. Что произойдёт раньше — выход из функции или этот defer?
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Спецификация Go — For statements with range clauseОфициальная документация. Место, где сказано, что цикл по функции — это вызов этой функции: «For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument» (Для функции f обход выполняется вызовом f с новой синтезированной функцией yield в качестве аргумента). Оттуда же — обязательство, нарушение которого ловит рантайм: «If the loop body terminates (such as by a break statement), yield returns false and must not be called again» (Если тело цикла завершается (например, оператором break), yield возвращает false и не должен вызываться снова). Слово synthesized здесь ключевое: функцию тела придумывает компилятор, и у неё есть имя, которое видно в стеке.https://go.dev/ref/spec#For_range
- Пакет iterОфициальная документация. Определение итератора, в котором нет ни слова про новую сущность в языке: «An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield» (Итератор — это функция, которая передаёт последовательные элементы последовательности в функцию обратного вызова, по соглашению называемую yield). И договор об одном булевом значении, через которое проходит всё управление циклом: «Yield returns true if the iterator should continue with the next element in the sequence, false if it should stop» (Yield возвращает true, если итератору следует продолжить со следующим элементом последовательности, и false, если следует остановиться). Отсюда же соглашение об именах: метод обхода коллекции называется All.https://pkg.go.dev/iter
- cmd/compile/internal/rangefunc/rewrite.go — как компилятор переписывает циклИсточник. Комментарий в начале файла начинается с самого простого объяснения — «The basic idea is to rewrite `for x := range f { ... }` into `f(func(x T) bool { ... })`» (Основная идея — переписать `for x := range f { ... }` в `f(func(x T) bool { ... })`) — и тут же себя поправляет: «But it's not usually that easy» (Но обычно всё не так просто). Там же названы обе вещи, из-за которых просто не получается. Первая — превращение управляющих слов в булево значение: «If the body contains a "break", that break turns into "return false", to tell f to stop. And if the body contains a "continue", that turns into "return true", to tell f to proceed with the next value» (Если тело содержит break, этот break превращается в return false, чтобы сказать f остановиться. А если тело содержит continue, он превращается в return true, чтобы сказать f перейти к следующему значению). Вторая — переменная состояния у каждого цикла: «each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body» (у каждого сгенерированного цикла есть своя переменная #stateK, по которой проверяются допустимые схемы вызова функции yield для тела цикла). Своего адреса у файла нет — он лежит в GOROOT; ссылка ведёт на его копию в репозитории Go на теге go1.24.7, на котором сняты замеры.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/rangefunc/rewrite.go
- cmd/compile/internal/inline/interleaved/interleaved.go — почему внутри b.Loop ничего не вкладываетсяИсточник. Одна строка, из-за которой любой замер итератора на `b.Loop` показывает только дорогую половину правды: «No inlining nor devirtualization performed on b.Loop body» (Внутри тела b.Loop не выполняется ни вкладывание, ни девиртуализация). Для обычного кода это мелочь, для итератора — определяющее обстоятельство: вся его цена и состоит в том, вложил компилятор функцию или нет. Своего адреса у файла нет; ссылка ведёт на копию в репозитории Go на теге go1.24.7.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/inline/interleaved/interleaved.go
- runtime/panic.go — тексты ошибок итератораИсходный код Go. Здесь лежат все четыре текста, которые печатает рантайм, когда итератор нарушил договор: продолжил обход после false, после паники тела, после выхода из цикла, а также проглотил панику тела и не поднял её снова. Это проверки времени выполнения, а не компиляции, и приведены они в статье дословно из прогона.https://go.dev/src/runtime/panic.go
- The Go Blog — Range Over Function TypesОфициальная документация. Официальное введение в механизм, появившийся в Go 1.23: зачем понадобился общий способ обхода для чужих коллекций и почему выбран не интерфейс, а функция. Статья опирается на него как на постановку задачи; все утверждения о поведении и цене здесь взяты из спецификации, комментариев компилятора и собственных прогонов.https://go.dev/blog/range-functions