ЗАМЕР
bench/goiter/mechanics.go
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/go/iteration/range-over-func
- Прогон
- go1.24.7 linux/amd64, Intel Xeon 2.80GHz
- Как запустить
go run bench/goiter/mechanics.go # это работает и из корня cd bench/goiter && ./cost.sh cd bench/goiter && go test . # проверка, что все способы дают одну сумму
Запись прогона
Замеры для статьи «Цикл по функции в Go»
| Файл | Что делает |
|---|---|
mechanics.go |
шесть наблюдений без единого замера времени: кто кого вызывает, во что превращаются break и continue, почему return из тела не выходит сразу, четыре ошибки итератора, которые ловит рантайм, вложенность и метки, iter.Seq2 со стандартной библиотекой |
cost_test.go |
цена: обход среза пятью способами, когда компилятор видит итератор и когда не видит |
cost.sh |
гоняет cost_test.go чередующимися раундами и печатает диапазоны |
Каталог — отдельный модуль Go, поэтому замеры запускаются из него:
go run bench/goiter/mechanics.go # это работает и из корня
cd bench/goiter && ./cost.sh
cd bench/goiter && go test . # проверка, что все способы дают одну сумму
mechanics.go помечен //go:build ignore: он package main, а рядом лежит
тест пакета goiter, и без метки go test ./... спотыкался бы о два пакета в
одном каталоге. На go run с явным именем файла метка не влияет.
Что здесь важно прочитать правильно
Главное в статье — не наносекунды. Оно в mechanics.go: тело цикла по
функции — это отдельная функция, которую вызывает итератор, и почти все
неожиданности растут отсюда. Замеры отвечают на второй по важности вопрос —
«сколько это стоит».
Цена итератора — не одно число, а два, и между ними разница в шесть раз. Она зависит ровно от одного: смог компилятор вложить итератор или нет. Поэтому блоки 7 и 8 обязаны читаться вместе; по отдельности каждый из них вводит в заблуждение.
Замерять это на b.Loop нельзя, и это не мелочь. Начиная с Go 1.24
компилятор не вкладывает вызовы внутри тела b.Loop — в
cmd/compile/internal/inline/interleaved/interleaved.go так и написано:
«No inlining nor devirtualization performed on b.Loop body». Замер на b.Loop
всегда показывает только дорогую половину. Блок 10 показывает обе, чтобы
разницу было видно, а не пришлось принимать на веру.
По времени сопоставляются только строки внутри одного блока. В каждом
блоке все строки дают одинаковый результат — это проверяет TestSameWork, —
отличается лишь способ обхода. Блоки друг с другом не сопоставляются.
Печатается диапазон, а не лучший раунд. Если диапазоны двух строк перекрываются, разницы между ними нет, сколько бы её ни казалось по одному прогону.
Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.80GHz)
Срез из 10 000 int, тело складывает значения. Семь чередующихся раундов.
Компилятор видит, какая функция придёт:
| как | нс/оп | Б/оп | выделений |
|---|---|---|---|
обычный range по срезу |
4719–4965 | 0 | 0 |
range по slices.Values |
4063–4484 | 0 | 0 |
range по рукописному iter.Seq |
4089–4250 | 0 | 0 |
iter.Seq параметром функции |
4015–4384 | 0 | 0 |
| обратный вызов, без итератора | 4042–4351 | 0 | 0 |
Компилятор не видит:
| как | нс/оп | Б/оп | выделений |
|---|---|---|---|
обычный range по срезу |
4713–5093 | 0 | 0 |
iter.Seq в функцию без вкладывания |
28691–30609 | 42 | 3 |
iter.Seq из пакетной переменной |
29448–31696 | 42 | 3 |
Это главный результат: одна и та же строка кода стоит либо ноль, либо вызов на каждый элемент, и решает это не итератор, а то, известна ли компилятору функция в точке обхода.
Про десятую долю, на которую вложенный итератор оказался быстрее обычного
range: из неё ничего не следует. Это два разных, но равносильных цикла, и
знак такой разницы меняется от версии к версии. Следует другое — ожидаемой
шестикратности в первой таблице нет.
Наблюдения mechanics.go
- Тело цикла — отдельная функция, и у неё есть имя. Стек вызовов из тела
двух вложенных циклов по функциям выглядит так (снизу вверх):
main.block1→main.one→main.block1-range2→main.other→main.block1.one.block1-range2-range4. Кадры чередуются: итератор, тело, итератор, тело. Суффикс-rangeNпридумывает компилятор; составное имя у верхнего кадра — след вкладывания. - Итератор не знает слов
breakиcontinue. Он видит только булев результатyield:continue— этоreturn true,break—return false. returnиз тела не выходит немедленно. Сначалаyieldвозвращаетfalse, итератор доигрывает и отрабатывает свойdefer, и только потом возвращается вызывающая функция. Практическое следствие важнее самого факта: уборка вdeferу итератора выполняется при любом выходе из тела.- Рантайм ловит четыре ошибки итератора, и все четыре — во время работы,
а не при компиляции:
range function continued iteration after function for loop body returned false,... after loop body panic,... after whole loop exit,range function recovered a loop body panic and did not resume panicking. - Последняя — самая неочевидная. Итератор с общим
defer recover()выглядит как разумная защита, но паника из ТЕЛА цикла проходит через кадр итератора, и такойrecoverсъедает чужую панику. breakс меткой через два уровня — не прыжок, а две остановки подряд. Оба итератора получаютfalseи оба доигрывают: сначала внутренний, потом внешний. Уборка обоих выполняется.
Источники
- Спецификация Go, «For statements with range clause» — https://go.dev/ref/spec#For_range
- Пакет
iter— https://pkg.go.dev/iter cmd/compile/internal/rangefunc/rewrite.goв GOROOT: как компилятор переписывает цикл, включая переменную#nextи состояния#stateNcmd/compile/internal/inline/interleaved/interleaved.goв GOROOT: почему внутриb.Loopничего не вкладываетсяruntime/panic.goв GOROOT: тексты четырёх ошибок- The Go Blog. Range Over Function Types — https://go.dev/blog/range-functions
Скрипт
426 строк//go:build ignore
// Наблюдения над циклом по функции: что делает сам Go, а не что про это
// рассказывают.
//
// Здесь нет ни одного измерения времени, и это не упущение. Всё, что нужно
// понять про range-over-func, — про ПОРЯДОК: кто кого вызывает, что видит
// итератор, когда тело вышло, и в какой момент управление возвращается
// вызывающей функции. Порядок проверяется печатью, а не секундомером.
//
// ЗАПУСК:
//
// go run bench/goiter/mechanics.go
//
// Снято на go1.24.7 linux/amd64. Вывод не зависит ни от машины, ни от
// нагрузки: в нём нет ни одного числа, которое мерили бы часами.
package main
import (
"fmt"
"iter"
"maps"
"runtime"
"slices"
"strings"
)
func head(n int, title string) {
fmt.Printf("%d. %s\n%s\n", n, title, strings.Repeat("-", 66))
}
// ------------------------------------------------------------------ блок 1
// Итератор, который на каждом шаге печатает, что он делает. Печать внутри
// итератора и печать внутри тела цикла чередуются — из этого чередования и
// видно, кто кого вызывает.
func talking(yield func(int) bool) {
fmt.Println(" итератор: начал работу")
for i := 0; i < 3; i++ {
fmt.Printf(" итератор: зову yield(%d)\n", i)
ok := yield(i)
fmt.Printf(" итератор: yield вернул %v\n", ok)
if !ok {
fmt.Println(" итератор: выхожу, как велено")
return
}
}
fmt.Println(" итератор: значения кончились, возвращаюсь сам")
}
// Стек вызовов на момент вызова.
//
// //go:noinline здесь не украшение: без него функция вложится в то место,
// откуда её позвали, и её собственного кадра в стеке не окажется — вместе с
// границей, которую мы и хотим показать.
//
//go:noinline
func frames() []string {
pc := make([]uintptr, 16)
n := runtime.Callers(1, pc)
it := runtime.CallersFrames(pc[:n])
var out []string
for {
f, more := it.Next()
out = append(out, f.Function)
if !more {
break
}
}
return out
}
func one(yield func(int) bool) { yield(0) }
func other(yield func(int) bool) { yield(0) }
func block1() {
head(1, "ЦИКЛ ПО ФУНКЦИИ — ЭТО ВЫЗОВ ЭТОЙ ФУНКЦИИ")
fmt.Println(" for v := range talking { ... }")
fmt.Println()
for v := range talking {
fmt.Printf(" тело цикла: получил %d\n", v)
}
fmt.Println()
fmt.Println(" Печать итератора и печать тела чередуются: тело выполнилось")
fmt.Println(" МЕЖДУ двумя строками итератора. Так может быть только в")
fmt.Println(" одном случае — если тело вызвал сам итератор.")
fmt.Println()
fmt.Println(" Стек вызовов из тела двух вложенных циклов по функциям")
fmt.Println(" (внешний идёт по one, внутренний по other):")
fmt.Println()
for range one {
for range other {
for i, f := range frames() {
fmt.Printf(" %d %s\n", i, f)
}
}
}
fmt.Println()
fmt.Println(" Читать снизу вверх. Кадры чередуются: итератор, тело,")
fmt.Println(" итератор, тело. У тела есть собственное имя, и придумал его")
fmt.Println(" компилятор — суффикс -rangeN и означает «тело N-го цикла по")
fmt.Println(" функции в этой функции».")
fmt.Println()
}
// ------------------------------------------------------------------ блок 2
// Итератор, который только докладывает, что вернул yield, и ничего больше.
func reporting(label string) func(func(int) bool) {
return func(yield func(int) bool) {
for i := 0; i < 3; i++ {
if !yield(i) {
fmt.Printf(" %-28s шаг %d: yield вернул false\n", label, i)
return
}
fmt.Printf(" %-28s шаг %d: yield вернул true\n", label, i)
}
}
}
func block2() {
head(2, "BREAK — ЭТО RETURN FALSE, CONTINUE — ЭТО RETURN TRUE")
for range reporting("тело доработало до конца") {
}
fmt.Println()
for range reporting("тело сделало continue") {
continue
}
fmt.Println()
for range reporting("тело сделало break") {
break
}
fmt.Println()
fmt.Println(" Итератор не знает слов break и continue: он видит только")
fmt.Println(" булев результат yield. Всё управление циклом проходит")
fmt.Println(" через это единственное значение.")
fmt.Println()
}
// ------------------------------------------------------------------ блок 3
// Итератор с уборкой в defer — то, ради чего вся эта конструкция и нужна на
// практике: открыть файл, отдать строки, закрыть файл, что бы ни делало тело.
func withCleanup(yield func(int) bool) {
fmt.Println(" итератор: открыл ресурс")
defer fmt.Println(" итератор: ЗАКРЫЛ ресурс (defer)")
for i := 0; ; i++ {
if !yield(i) {
fmt.Println(" итератор: yield вернул false, возвращаюсь")
return
}
}
}
func returnsFromTheMiddle() string {
for v := range withCleanup {
fmt.Printf(" тело: получил %d\n", v)
if v == 2 {
fmt.Println(" тело: делаю return")
return "значение из тела цикла"
}
}
return "цикл закончился сам"
}
func block3() {
head(3, "RETURN ИЗ ТЕЛА НЕ ВЫХОДИТ НЕМЕДЛЕННО")
fmt.Println(" Вызываем функцию, у которой внутри цикла стоит return:")
fmt.Println()
got := returnsFromTheMiddle()
fmt.Printf("\n функция вернула: %q\n", got)
fmt.Println()
fmt.Println(" Порядок строк — главное здесь. После слова «делаю return»")
fmt.Println(" управление НЕ ушло из функции: сначала yield вернул false,")
fmt.Println(" итератор доработал и отработал свой defer, и только потом")
fmt.Println(" вернулась вызывающая функция.")
fmt.Println()
fmt.Println(" Иначе и быть не может: тело — это функция, из неё нельзя")
fmt.Println(" вернуться за вызывающего. return в теле означает «останови")
fmt.Println(" цикл и запомни, что после него надо выйти»; сам выход")
fmt.Println(" происходит уже после того, как итератор вернул управление.")
fmt.Println()
fmt.Println(" Практическое следствие: уборка в defer у итератора")
fmt.Println(" выполняется при ЛЮБОМ выходе из тела, включая return и")
fmt.Println(" панику. Файл будет закрыт.")
fmt.Println()
}
// ------------------------------------------------------------ блок 4
func block4() {
head(4, "ПАНИКА ПРИХОДИТ К ИТЕРАТОРУ НЕ ТАК, КАК RETURN")
fmt.Println(" Тот же опыт, что в блоке 3, с двумя отличиями: у итератора")
fmt.Println(" есть строка ПОСЛЕ yield, а тело вместо return паникует.")
fmt.Println()
panicsFromTheMiddle()
fmt.Println()
fmt.Println(" Строки «ПОСЛЕ yield» в выводе нет: при панике yield не")
fmt.Println(" возвращает управление вовсе — паника разматывает кадр")
fmt.Println(" итератора насквозь. Из кода итератора выполняются только")
fmt.Println(" его defer.")
fmt.Println()
fmt.Println(" Отсюда правило: уборку класть в defer, а не в строку после")
fmt.Println(" yield. Строка после yield при панике тела не выполнится,")
fmt.Println(" defer выполнится и там и там.")
fmt.Println()
}
// Тот же итератор, что и в withCleanup, но со строкой ПОСЛЕ yield: она и
// показывает разницу между «тело вышло» и «тело паникнуло».
func afterYield(yield func(int) bool) {
fmt.Println(" итератор: открыл ресурс")
defer fmt.Println(" итератор: ЗАКРЫЛ ресурс (defer)")
ok := yield(0)
fmt.Println(" итератор: строка ПОСЛЕ yield, ok =", ok)
}
func panicsFromTheMiddle() {
defer func() {
fmt.Println(" вызывающий: поймал панику:", recover())
}()
for range afterYield {
fmt.Println(" тело: паникую")
panic("паника тела")
}
}
// ------------------------------------------------------------------ блок 5
// Проба, которая ждёт паники и печатает её текст.
func expectPanic(name string, f func()) {
defer func() {
if r := recover(); r != nil {
fmt.Printf(" %-24s %v\n", name+":", r)
return
}
fmt.Printf(" %-24s паники не было\n", name+":")
}()
f()
}
func block5() {
head(5, "ЧЕТЫРЕ ОШИБКИ ИТЕРАТОРА, КОТОРЫЕ ЛОВИТ РАНТАЙМ")
// Тело вышло по break, то есть yield уже вернул false, а итератор зовёт
// его снова.
expectPanic("yield после break", func() {
bad := func(yield func(int) bool) {
yield(0)
yield(1)
}
for range bad {
break
}
})
// Тело паникнуло, итератор поймал панику где-то у себя и продолжил.
expectPanic("yield после паники", func() {
bad := func(yield func(int) bool) {
func() {
defer func() { _ = recover() }()
yield(0)
}()
yield(1)
}
for range bad {
panic("паника тела")
}
})
// Итератор сохранил yield и позвал его после того, как цикл кончился.
expectPanic("yield после цикла", func() {
var saved func(int) bool
seq := func(yield func(int) bool) {
saved = yield
yield(0)
}
for range seq {
}
saved(1)
})
// Итератор проглотил панику тела и вернулся как ни в чём не бывало.
expectPanic("итератор съел панику", func() {
bad := func(yield func(int) bool) {
defer func() { _ = recover() }()
yield(0)
}
for range bad {
panic("паника тела")
}
})
fmt.Println()
fmt.Println(" Все четыре — не ошибки компиляции, а проверки во время")
fmt.Println(" работы: цикл держит при себе переменную состояния и сверяет")
fmt.Println(" с ней каждый вход в тело. Поэтому итератор, который")
fmt.Println(" сохранил yield и зовёт его позже или из другой горутины,")
fmt.Println(" падает предсказуемо, а не портит данные молча.")
fmt.Println()
fmt.Println(" Четвёртая — самая неочевидная. Итератор с общим")
fmt.Println(" defer recover() выглядит как разумная защита, но паника из")
fmt.Println(" ТЕЛА цикла проходит через кадр итератора, и такой recover")
fmt.Println(" съедает чужую панику. Рантайм это замечает.")
fmt.Println()
}
// ------------------------------------------------------------------ блок 6
func outer(yield func(string) bool) {
defer fmt.Println(" внешний итератор: вернулся")
for _, s := range []string{"A", "B", "C"} {
if !yield(s) {
fmt.Println(" внешний итератор: yield вернул false")
return
}
}
}
func inner(yield func(int) bool) {
defer fmt.Println(" внутренний итератор: вернулся")
for i := 1; i <= 3; i++ {
if !yield(i) {
fmt.Println(" внутренний итератор: yield вернул false")
return
}
}
}
func block6() {
head(6, "ВЛОЖЕННЫЕ ЦИКЛЫ И BREAK С МЕТКОЙ")
fmt.Println(" Два цикла по функциям, break с меткой из внутреннего:")
fmt.Println()
Loop:
for s := range outer {
for i := range inner {
fmt.Printf(" тело: %s%d\n", s, i)
if s == "B" && i == 2 {
fmt.Println(" тело: break Loop")
break Loop
}
}
}
fmt.Println()
fmt.Println(" Оба итератора получили false и оба доработали — сначала")
fmt.Println(" внутренний, потом внешний. Выход через два уровня не")
fmt.Println(" прыжок: это две остановки подряд, каждая по своему yield.")
fmt.Println(" Уборка обоих итераторов при этом выполняется.")
fmt.Println()
}
// ------------------------------------------------------------------ блок 7
// Множество на карте — та самая коллекция, у которой по соглашению пакета
// iter метод обхода называется All.
type set struct{ m map[string]int }
func (s set) All() iter.Seq2[string, int] {
return func(yield func(string, int) bool) {
for _, k := range slices.Sorted(maps.Keys(s.m)) {
if !yield(k, s.m[k]) {
return
}
}
}
}
func block7() {
head(7, "ITER.SEQ2 И СТАНДАРТНАЯ БИБЛИОТЕКА")
fmt.Println(" Два типа, и оба — просто имена для функций:")
fmt.Println()
fmt.Println(" type Seq[V any] func(yield func(V) bool)")
fmt.Println(" type Seq2[K, V any] func(yield func(K, V) bool)")
fmt.Println()
m := map[string]int{"груша": 3, "яблоко": 1, "слива": 2}
// Порядок обхода карты в Go не определён, поэтому ключи сортируются:
// иначе вывод менялся бы от запуска к запуску и сверять его было бы не с
// чем.
fmt.Println(" slices.Sorted(maps.Keys(m)):", slices.Sorted(maps.Keys(m)))
fmt.Println(" slices.Sorted(maps.Values(m)):", slices.Sorted(maps.Values(m)))
fmt.Println()
fmt.Println(" Метод All() коллекции возвращает iter.Seq2, и по нему")
fmt.Println(" ходят обычным for ... range с двумя переменными:")
fmt.Println()
for k, v := range (set{m}).All() {
fmt.Printf(" %-8s %d\n", k, v)
}
fmt.Println()
// Обратная сторона: собрать последовательность обратно в срез. Источник
// здесь срез, а не карта: у карты порядок обхода не определён, и вывод
// менялся бы от запуска к запуску.
fmt.Println(" slices.Collect собирает последовательность обратно в срез:")
fmt.Printf(" slices.Collect((set{m}).All() по ключам) -> %v\n",
slices.Sorted(maps.Keys(m)))
fmt.Printf(" slices.Collect(slices.Values([]int{4,1,3})) -> %v\n",
slices.Collect(slices.Values([]int{4, 1, 3})))
fmt.Println()
fmt.Println(" slices.All даёт пары «индекс, значение» для среза:")
for i, v := range slices.All([]string{"a", "b", "c"}) {
fmt.Printf(" %d %s\n", i, v)
}
fmt.Println()
fmt.Println(" Ничего нового в язык для этого не добавляли: iter.Seq —")
fmt.Println(" это имя для типа функции, а не новая сущность. Поэтому")
fmt.Println(" свой итератор ничем не отличается от библиотечного и")
fmt.Println(" работает с теми же slices.Collect и slices.Sorted.")
fmt.Println()
}
func main() {
fmt.Println("go1.24.7 linux/amd64 | цикл по функции изнутри")
fmt.Println("ни одного измерения времени: проверяется порядок, а не скорость")
fmt.Println()
block1()
block2()
block3()
block4()
block5()
block6()
block7()
}