Deep Engineering
Продвинутый·Опубликовано·50 МИН

Цикл по функции в Go: тело — это функция, которую вызывает итератор, и отсюда всё остальное

`for x := range f` выглядит сахаром над обычным циклом. Это не сахар: тело становится отдельной функцией, `break` превращается в `return false`, а `return` из тела не выходит немедленно — и ровно поэтому `defer` внутри итератора отрабатывает при любом выходе. Четыре ошибки итератора ловит рантайм, а обход стоит либо ноль, либо вызов на каждый элемент, и решает это не итератор, а то, видит ли компилятор функцию в точке обхода.

Полное техническое изложение

TL;DR

for x := range f — не сокращение записи. Компилятор превращает тело цикла в отдельную функцию и отдаёт её итератору. Всё остальное следует отсюда.

  • Итератор вызывает ваше тело, а не наоборот. В стеке видны чередующиеся кадры, и у тела есть собственное имя с суффиксом -rangeN.
  • break — это return false, continuereturn 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 в качестве аргумента.

Спецификация Go, For statements with range clause

Синтезированная — то есть придуманная компилятором. Придумывает он её из вашего тела цикла. Комментарий в rewrite.go начинается ровно с этого: The basic idea is to rewrite (Основная идея — переписать)

GO
for x := range f {
	...
}

в

GO
f(func(x T) bool {
	...
})

— и следующей же строкой себя поправляет: But it's not usually that easy. (Но обычно всё не так просто.)

контракт языкаcmd/compile/internal/rangefunc/rewrite.go, go1.24.7

Дальше — по одному месту, где «не так просто», на раздел. Каждое из них что-нибудь меняет в том, как вы напишете код.

Тело цикла — это функция, и вызывает её итератор

Проверяется это без всякой теории: пусть итератор печатает, что он делает, а тело — что получило.

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
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Ни одного измерения времени: проверяется порядок печати и состав стека. Вывод не зависит ни от машины, ни от нагрузки.

Стек читается снизу вверх, и кадры в нём идут парами: 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
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Три одинаковых итератора и три разных тела; печатает итератор, а не тело.

Первые две группы совпадают дословно. С точки зрения итератора 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 перейти к следующему значению.)
контракт языкаcmd/compile/internal/rangefunc/rewrite.go, go1.24.7

Ту же вещь с другой стороны — со стороны того, кто итератор пишет, — формулирует пакет iter:

Yield returns true if the iterator should continue with the next element in the sequence, false if it should stop.

перевод

Yield возвращает true, если итератору следует продолжить со следующим элементом последовательности, и false, если следует остановиться.

Пакет iter

И что с того. Если вы пишете итератор, у вас нет права игнорировать результат yield. Строка

GO
for _, v := range s {
	yield(v)   // результат выброшен — итератор сломан
}

компилируется и выглядит безобидно, но означает «продолжать обход, что бы тело ни ответило». Первый же break в чужом цикле превратит это в панику — какую именно, показано двумя разделами ниже. Правильная форма ровно одна:

GO
for _, v := range s {
	if !yield(v) {
		return   // тело сказало «хватит» — обход обязан прекратиться
	}
}

return из тела не выходит немедленно

Здесь механизм перестаёт быть безобидной перестановкой кода. Тело — это функция; из функции нельзя вернуться за того, кто её вызвал. А return в теле цикла обязан выйти из внешней функции — той, где стоит цикл.

3. RETURN ИЗ ТЕЛА НЕ ВЫХОДИТ НЕМЕДЛЕННО
------------------------------------------------------------------
  Вызываем функцию, у которой внутри цикла стоит return:

    итератор: открыл ресурс
    тело: получил 0
    тело: получил 1
    тело: получил 2
    тело: делаю return
    итератор: yield вернул false, возвращаюсь
    итератор: ЗАКРЫЛ ресурс (defer)

  функция вернула: "значение из тела цикла"
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Итератор открывает ресурс и закрывает его в defer; тело выходит по return на третьем значении. Содержателен порядок строк, и только он.

Порядок строк здесь — весь результат. После «делаю 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 и не должен вызываться снова.

Спецификация Go, For statements with range clause

Обратите внимание на слово terminates: break назван примером, а не единственным случаем. return из тела — такое же завершение, и приходит к итератору тем же способом: как false из yield.

И что с того, а это здесь сильнее самого факта. Отсроченный выход означает, что итератор всегда доигрывает свои defer. Не «обычно», не «если тело вело себя прилично» — всегда, при любом выходе из тела. А раз так, уборку внутри итератора можно делать так же, как в обычной функции:

GO
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
			}
		}
	}
}

Вызывающему коду не надо ни закрывать файл, ни знать, что он есть:

GO
for line := range lines("access.log") {
	if strings.Contains(line, "panic") {
		return line   // файл закроется, хотя закрывать его никто не просил
	}
}

Это и есть причина, по которой итераторы удобнее обратных вызовов: обратный вызов не может ни прервать обход, ни выйти из внешней функции — возвращать ему нечего, — а итератор и то и другое разрешает, не отдавая наружу владение ресурсом. Ради этого механизм и устроен так, как устроен, и именно эта черта пропадает, когда его называют сахаром.

Паника приходит к итератору не так, как return

Слово «всегда» выше относится к defer — и только к нему. Само по себе это различие мелкое, но оно решает, куда класть уборку, поэтому измерено отдельно. Тот же итератор, но со строкой ПОСЛЕ yield, и тело не возвращается, а паникует:

4. ПАНИКА ПРИХОДИТ К ИТЕРАТОРУ НЕ ТАК, КАК RETURN
------------------------------------------------------------------
  Тот же опыт, что в блоке 3, с двумя отличиями: у итератора
  есть строка ПОСЛЕ yield, а тело вместо return паникует.

    итератор: открыл ресурс
    тело: паникую
    итератор: ЗАКРЫЛ ресурс (defer)
    вызывающий: поймал панику: паника тела
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Итератор тот же, что в блоке 3, но с печатью после yield; тело вместо return паникует. Содержателен состав строк, а не их число.

Строки «ПОСЛЕ 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
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Четыре заведомо неправильных итератора; тексты напечатаны так, как их вернул recover. Тексты живут в runtime/panic.go и от машины не зависят.

Откуда они берутся, компилятор объясняет там же, где описывает переписывание:

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 для тела цикла.)
контракт языкаcmd/compile/internal/rangefunc/rewrite.go, go1.24.7

То есть у каждого цикла — своя переменная состояния, и каждый вход в тело сверяется с ней. Это объясняет и первые три текста, и то, почему они разные: состояние различает «тело сказало стоп», «тело паникнуло» и «цикл вообще кончился». Сохранённый где-то yield, позванный позже или из другой горутины, падает предсказуемо и с внятным текстом, а не портит данные молча.

Четвёртая — та, до которой не додумываются. Она не про повторный вызов вовсе:

GO
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
    внешний итератор: вернулся
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Оба итератора печатают своё возвращение из defer, поэтому в выводе видно не только то, что они остановились, но и то, что уборка выполнилась.

Читается это так: внутренний 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
наблюдение замераbench/goiter/mechanics.go, go1.24.7 linux/amd64. Ключи карты отсортированы намеренно: порядок обхода карты в Go не определён, и без сортировки вывод менялся бы от запуска к запуску.

Документация пакета определяет итератор теми же словами, какими описывают обратный вызов:

An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.

перевод

Итератор — это функция, которая передаёт последовательные элементы последовательности в функцию обратного вызова, по соглашению называемую yield.

Пакет iter

Стандартная библиотека держится на этом и ничего больше не требует. 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.
  Компилятор вложил и итератор, и тело: вызова на элемент не осталось.
наблюдение замераbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2,80 ГГц. Срез из 10 000 int, тело складывает значения, семь чередующихся раундов по секунде; в таблице диапазон по раундам. Все строки блока делают одну и ту же работу — это проверяет TestSameWork. Строки разных блоков между собой не сравниваются.

Теперь тот же итератор и та же работа, но компилятор функцию не видит: она приходит параметром в функцию, которая не помещается в бюджет вкладывания, или лежит в пакетной переменной.

9. КОГДА НЕ ВИДИТ, ПОЯВЛЯЕТСЯ ВЫЗОВ НА КАЖДЫЙ ЭЛЕМЕНТ
------------------------------------------------------------------
                                     нс/оп         Б/оп   выдел.  кратно
  обычный range по срезу             4716-4931     0      0       x1.0
  iter.Seq в функцию без вкладыв.    28820-29810   42     3       x6.0
  iter.Seq из пакетной переменной    29615-31949   42     3       x6.2

  Та же работа и тот же итератор. Изменилось одно: компилятор больше не
  знает, какая функция придёт, и не может её вложить. Появились и вызов
  на элемент, и выделения памяти под замыкание.
наблюдение замераbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2,80 ГГц. Тот же срез и то же тело, что в блоке 7. Вкладывание запрещено пометкой //go:noinline у принимающей функции и тем, что итератор лежит в пакетной переменной. Абсолютные наносекунды зависят от машины; кратность между строками одного блока — нет.

Эти два блока обязаны читаться вместе. Поодиночке каждый вводит в заблуждение: по первому итератор бесплатен всегда, по второму дорог всегда. Верно ни то ни другое. Одна и та же строка 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
наблюдение замераbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2,80 ГГц. Работа здесь другая, чем в блоках 7 и 8 (тело умножает индекс на значение), поэтому с ними эти строки не сравниваются — сравниваются только между собой.

Про десятую долю, на которую итератор оказался быстрее

В блоке 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».
наблюдение замераbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2,80 ГГц. Те же две строки, что в блоке 8, отличается только способ замера: b.Loop вместо цикла по b.N. Сравниваются они друг с другом, а не с блоком 7.

b.Loop появился как рекомендуемая замена циклу по b.N — он сам держит таймер и сам не даёт выбросить тело. Но начиная с Go 1.24 компилятор не вкладывает вызовы внутри его тела:

No inlining nor devirtualization performed on b.Loop body (Внутри тела b.Loop не выполняется ни вкладывание, ни девиртуализация)
контракт языкаcmd/compile/internal/inline/interleaved/interleaved.go, go1.24.7

Для обычного кода это мелочь. Для итератора — определяющее обстоятельство: вся его цена и состоит в том, вложил компилятор функцию или нет. Замер на 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 — синтаксический сахар: компилятор просто разворачивает это в обычный цикл

На самом деле

Не разворачивает, а выворачивает наизнанку. Спецификация говорит прямо: For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument (Для функции f обход выполняется вызовом f с новой синтезированной функцией yield в качестве аргумента). Тело цикла становится ОТДЕЛЬНОЙ функцией, которую вызывает итератор, и у неё есть собственное имя: в стеке из тела двух вложенных циклов видны кадры main.onemain.block1-range2main.othermain.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).

Проверьте себя

Вопрос 1 из 5

Функция содержит цикл по итератору, у итератора есть defer с закрытием ресурса, а в теле цикла стоит return. Что произойдёт раньше — выход из функции или этот defer?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Спецификация 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
  2. Пакет 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
  3. 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
  4. 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
  5. runtime/panic.go — тексты ошибок итератораИсходный код Go. Здесь лежат все четыре текста, которые печатает рантайм, когда итератор нарушил договор: продолжил обход после false, после паники тела, после выхода из цикла, а также проглотил панику тела и не поднял её снова. Это проверки времени выполнения, а не компиляции, и приведены они в статье дословно из прогона.https://go.dev/src/runtime/panic.go
  6. The Go Blog — Range Over Function TypesОфициальная документация. Официальное введение в механизм, появившийся в Go 1.23: зачем понадобился общий способ обхода для чужих коллекций и почему выбран не интерфейс, а функция. Статья опирается на него как на постановку задачи; все утверждения о поведении и цене здесь взяты из спецификации, комментариев компилятора и собственных прогонов.https://go.dev/blog/range-functions