Цикл по функции в 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 в качестве аргумента.
То есть цикл по функции — это вызов этой функции. А тело цикла компилятор
превращает в отдельную функцию и передаёт её в f аргументом. Эту функцию и
называют 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
итератор: значения кончились, возвращаюсь сам
Строки чередуются: тело выполнилось между двумя строками итератора. Так бывает только в одном случае — если тело вызвал сам итератор.
Управление проходит через одно значение
Тело стало функцией, а функция умеет только вернуть значение. Значит, всё
управление циклом придётся уложить в возвращаемое значение. Вот что видит
итератор при трёх разных телах (bench/goiter/mechanics.go, блок 2):
| что сделало тело | что увидел итератор на шагах 0, 1, 2 |
|---|---|
| доработало до конца | true, true, true |
continue | true, true, true |
break | false на шаге 0, дальше шагов нет |
Первые две строки совпали: в выводе прогона эти группы строк одинаковы
дословно. Для итератора 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 в нём
возвращает только из него самого. В этом и разница, и она не косметическая.
А вот тут есть тонкость, которая решает, куда класть уборку. При 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().
Цена: ноль или в шесть раз
Обычный довод против итераторов — «вызов функции на каждый элемент дорог». Ответа два, и их надо читать вместе.
Когда компилятор видит, какая функция придёт (bench/goiter/cost_test.go,
блок 8):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range по срезу | 4782–4971 | 0 | 0 | 1,0 |
range по slices.Values | 4108–4242 | 0 | 0 | 0,9 |
range по рукописному iter.Seq | 4024–4339 | 0 | 0 | 0,8 |
iter.Seq параметром функции | 4030–4327 | 0 | 0 | 0,8 |
| обратный вызов, без итератора | 4027–4248 | 0 | 0 | 0,8 |
Когда не видит (bench/goiter/cost_test.go, блок 9):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range по срезу | 4716–4931 | 0 | 0 | 1,0 |
iter.Seq в функцию без вкладывания | 28820–29810 | 42 | 3 | 6,0 |
iter.Seq из пакетной переменной | 29615–31949 | 42 | 3 | 6,2 |
Итератор один и тот же, работа одна и та же. Изменилось одно: во втором случае компилятор не знает, какая функция придёт, и не может её вложить. Появились и вызов на элемент, и выделения памяти.
Правило, которое можно проверить на глаз. Итератор бесплатен там, где
компилятор видит функцию в точке обхода: slices.Values(s) прямо в заголовке
цикла, свой итератор рядом, короткая обёртка. Он стоит вызова на элемент там,
где функция приходит параметром большой функции, из переменной или из поля
структуры.
Вывод не «не пользуйтесь итераторами», а «в горячем цикле не прячьте итератор за границу вкладывания».
Честно про десятую долю. В блоке 8 строки с итератором вышли чуть быстрее
обычного range, и диапазоны не перекрылись. Из этого не следует, что итератор
быстрее: это два равносильных цикла, которые компилятор развернул по-разному, и
знак такой разницы меняется от версии к версии. Следует другое — ожидаемой
шестикратности здесь нет вовсе.
Не меряйте это на b.Loop
Если пойдёте проверять цену у себя, знайте про ловушку
(bench/goiter/cost_test.go, блок 11):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range, замер на b.Loop | 6209–6769 | 0 | 0 | 1,0 |
slices.Values, замер на b.Loop | 29275–33132 | 88 | 4 | 4,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()в итераторе съедает панику вашего тела. - Цена — не одно число, а два: как у обычного
rangeили вшестеро больше. Если компилятор видит функцию — 4108–4242 нс и ноль выделений; если не видит — 28820–29810 нс против 4716–4931 у обычногоrangeв том же замере, 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
Тело стало функцией, а функция умеет только вернуть значение. Значит, всё
управление циклом надо уместить в возвращаемое значение, а оно у функции одно.
Вот что видит итератор при трёх разных телах (bench/goiter/mechanics.go,
блок 2):
| что сделало тело | что увидел итератор на шагах 0, 1, 2 |
|---|---|
| доработало до конца | true, true, true |
continue | true, true, true |
break | false на шаге 0, дальше шагов нет |
Первые две строки совпадают: в выводе прогона эти группы строк одинаковы
дословно. С точки зрения итератора 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 в нём возвращает только из него самого, а прервать
обход он может, лишь если ему дали что вернуть, как bool в sync.Map.Range. Итератор
позволяет и то и другое и при этом не отдаёт наружу владение ресурсом. Ради
этого механизм и устроен так, как устроен, и именно это теряется, когда его
называют сахаром.
Паника приходит к итератору не так, как 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().
Цена — не одно число, а два
Теперь то, из-за чего механизм чаще всего откладывают: «вызов функции на каждый
элемент — это же дорого». Ответа два: как у обычного range или вшестеро больше.
Сначала случай, когда компилятор в точке обхода знает, какая функция придёт.
Здесь нет ни одного выделения памяти, а время то же, что у обычного range:
компилятор вложил и итератор, и тело, и вызова на элемент не осталось
(bench/goiter/cost_test.go, блок 8):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range по срезу | 4782–4971 | 0 | 0 | 1,0 |
range по slices.Values | 4108–4242 | 0 | 0 | 0,9 |
range по рукописному iter.Seq | 4024–4339 | 0 | 0 | 0,8 |
iter.Seq параметром функции | 4030–4327 | 0 | 0 | 0,8 |
| обратный вызов, без итератора | 4027–4248 | 0 | 0 | 0,8 |
Теперь тот же итератор и та же работа, но компилятор функцию не видит: она
приходит параметром в функцию, которая не помещается в бюджет вкладывания, или
лежит в пакетной переменной. Вложить её компилятор не может, и появляются и
вызов на каждый элемент, и выделения памяти под замыкание (bench/goiter/cost_test.go,
блок 9):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range по срезу | 4716–4931 | 0 | 0 | 1,0 |
iter.Seq в функцию без вкладывания | 28820–29810 | 42 | 3 | 6,0 |
iter.Seq из пакетной переменной | 29615–31949 | 42 | 3 | 6,2 |
Эти два блока обязаны читаться вместе. Поодиночке каждый вводит в
заблуждение: по первому итератор бесплатен всегда, по второму дорог всегда.
Верно ни то ни другое. Одна и та же строка for v := range seq стоит либо
ноль, либо вызов на каждый элемент, и решает это не итератор, а то, известна
ли компилятору функция в точке обхода.
Отсюда правило, которое можно проверить на глаз, без профилировщика.
- Бесплатно там, где компилятор видит функцию: литерал или вызов вроде
slices.Values(s)прямо в заголовке цикла, свой итератор рядом, короткая функция-обёртка, которая целиком вкладывается. - Вызов на элемент и выделения там, где не видит:
iter.Seqпришёл параметром в большую функцию, лежит в переменной, приходит из поля структуры или из интерфейса. Замер покрывает два из этих случаев — параметр невкладываемой функции и пакетную переменную; остальные устроены так же и по той же причине: компилятор не может назвать функцию по имени.
Практический вывод не «не пользуйтесь итераторами», а «в горячем цикле не прячьте итератор за границу, которую компилятор не переступает». В холодном коде разница в 25 микросекунд на 10 000 элементов не стоит ни одной перестановки.
Пары «ключ — значение» отдельного разговора не требуют: iter.Seq2 ведёт
себя так же (bench/goiter/cost_test.go, блок 10):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range по срезу с индексом | 4764–5186 | 0 | 0 | 1,0 |
range по slices.All (iter.Seq2) | 4752–4950 | 0 | 0 | 1,0 |
Про десятую долю, на которую итератор оказался быстрее
В блоке 8 строки с итератором показывают чуть меньшее время, чем обычный
range: 4108–4242 против 4782–4971. Диапазоны не перекрылись, и это надо сказать вслух, а не
замолчать.
Из этого не следует, что итератор быстрее обычного range. Это два
разных, но равносильных цикла: одна и та же сумма, разные развёртки, которые
компилятор выбрал на этой машине. Знак такой разницы меняется от версии к
версии и от машины к машине, и строить на нём нечего.
Следует из блока другое, и это как раз содержательно: ожидаемой шестикратности здесь нет, и выделений нет ни у одной строки. Ровно то же время и ноль байт показывает и последняя строка — обход обратным вызовом, без всякого итератора. То есть в этом случае итератор не добавляет ничего: ни времени, ни памяти.
Ловушка замера: на b.Loop итератор всегда дорогой
Этот раздел нужен потому, что читатель после предыдущего пойдёт мерить у себя —
и на нынешнем Go напишет замер, который соврёт. Те же две строки, что в
блоке 8, дают другой ответ, и причина не в итераторе, а в замере
(bench/goiter/cost_test.go, блок 11):
| способ | нс/оп | Б/оп | выдел. | кратно |
|---|---|---|---|---|
обычный range, замер на b.Loop | 6209–6769 | 0 | 0 | 1,0 |
slices.Values, замер на b.Loop | 29275–33132 | 88 | 4 | 4,7 |
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 — на другой версии абсолютные
наносекунды будут другими. Не изменится главное: столбцы Б/оп и выдел.
и то, что разница между блоками 8 и 9 держится на одном обстоятельстве —
видит компилятор функцию или нет. В выводе mechanics.go чисел нет вовсе,
поэтому он воспроизводится дословно.
Чем измерено
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
for x := range f— не сокращение записи. Компилятор превращает тело цикла в отдельную функцию и отдаёт её итератору. Всё остальное следует отсюда.- Итератор вызывает ваше тело, а не наоборот. В стеке видны чередующиеся кадры, и у тела есть собственное имя с суффиксом
-rangeN. break— этоreturn false,continue—return true. Итератор не знает этих слов; всё управление проходит через одно булево значение.returnиз тела не выходит немедленно. Сначалаyieldвозвращаетfalse, итератор доигрывает и отрабатывает свойdefer, и только потом возвращается вызывающая функция. Ради этого итераторы и удобны: уборка вdeferвыполняется при любом выходе из тела, включаяreturnи панику.- Четыре ошибки итератора ловит рантайм, все — во время работы. Самая неочевидная: общий
defer recover()в итераторе съедает панику вашего тела. - Цена — не одно число, а два: как у обычного
rangeили вшестеро больше. Если компилятор видит функцию — 4108–4242 нс и ноль выделений; если не видит — 28820–29810 нс против 4716–4931 у обычногоrangeв том же замере, 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или вшестеро больше. Когда компилятор видит функцию в точке обхода:rangeпоslices.Values— 4108–4242 нс, 0 Б/оп, 0 выделений против 4782–4971 нс у обычногоrangeпо срезу. Когда не видит: 28820–29810 нс против 4716–4931 у обычногоrangeв том же замере, 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` итератор всегда дорогой
- Что осталось за рамками
- Что стоит проверять в своём коде
- Как воспроизвести числа
- Чем измерено
Унести в работу
- Читать
breakкакreturn false, аcontinueкакreturn true: у тела цикла есть только одно булево значение на всё управление. - Помнить, что
returnиз тела не выходит сразу: сначала доигрывает итератор и отрабатывают егоdefer— поэтому открытый им файл закроется при любом выходе. - Не ставить
defer func() { recover() }()в итераторе: четыре ошибки итератора ловит рантайм во время работы, и это одна из них. - Мерить цену в своём месте вызова: она либо нулевая, либо шестикратная, и решает это не итератор, а то, видит ли компилятор функцию в точке обхода.
Расхожие заблуждения
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 или вшестеро больше. Когда компилятор видит функцию в точке обхода: range по slices.Values — 4108–4242 нс, 0 Б/оп, 0 выделений против 4782–4971 нс у обычного range по срезу. Когда не видит: 28820–29810 нс против 4716–4931 у обычного range в том же замере, 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 { ... }intof(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