Deep Engineering

ЗАМЕР

bench/goclosure/practice.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/closures
Как запустить
go run bench/goclosure/internals.go
bash bench/goclosure/loopvar.sh
bash bench/goclosure/escape.sh
bash bench/goclosure/compile.sh
bash bench/goclosure/example.sh
go run bench/goclosure/practice.go

Запись прогона

Замеры для урока «Функции и замыкания в Go»

Файл Что делает
internals.go пять наблюдений: функция как значение (тип, nil, размер, паника при вызове nil и при сравнении через интерфейс); замыкание захватывает переменную (счётчик-генератор, два замыкания над одной переменной, присваивание после создания замыкания); что вычисляется сразу (аргументы defer, получатель значения метода) и что потом (тело замыкания); вариативный параметр — f(s...) против f(a, b, c); выделения памяти на вызов
loopvar.sh запускает один и тот же main.go в двух модулях — cases/loop121 (go 1.21 в go.mod) и cases/loop122 (go 1.22) — и печатает решение компилятора -m=2 о захвате переменной цикла; раздел 3 (добавлен после аудита 14.10.2026) — assign.go: for _, s = range с заранее объявленной s
example.sh полный короткий пример урока: cases/example/main.go со своим go.mod, запуск go run . (добавлен после аудита 14.10.2026)
escape.sh собирает cases/escape с -gcflags='-m -l' и -m=2: moved to heap: n, func literal escapes to heap, func literal does not escape, захват по ссылке и по значению
compile.sh собирает cases/funccmp и печатает ошибки компилятора: f == g и карта с ключом-функцией
cases/*/ подопытные программы к скриптам, у каждой свой go.mod; funccmp намеренно не собирается; в loop121 и loop122 кроме main.go лежит assign.go — отдельным файлом, чтобы номера строк main.go, на которые ссылается урок, не сдвинулись
practice.go источник ответов двух практических задач урока: вывод программы со счётчиком, f(s...) и значением метода; цена вызова и создания замыкания против прямого вызова
go run bench/goclosure/internals.go
bash bench/goclosure/loopvar.sh
bash bench/goclosure/escape.sh
bash bench/goclosure/compile.sh
bash bench/goclosure/example.sh
go run bench/goclosure/practice.go

Записи прогонов — в runs/, go1.24.7 linux/amd64, 2 ядра. internals.go, loopvar.sh, escape.sh, compile.sh и example.sh запускались и на go1.25.1: вывод тот же с точностью до номера версии в первой строке, поэтому отдельных записей для них нет. Время в practice.go — лучший из 11 кругов testing.Benchmark на машине, которую делили с другими задачами; это одна конфигурация (linux/amd64, 2 ядра, //go:noinline, глобальный приёмник результата), и ни наносекунды, ни кратности не обещаны для другой машины или версии. Строка «устойчивы кратности, а не наносекунды» в runs/practice.txt — вывод прогона до аудита 14.10.2026; замер не перезапускался, чтобы не загружать общую машину, а урок эту формулировку больше не повторяет. Числа выделений — наблюдение компилятора go1.24.7/go1.25.1 в конкретном месте вызова, а не контракт языка (раздел 6 internals.go).

Что получилось

Функция — значение. %T у op — func(int) int, unsafe.Sizeof(op) — 8 байт. Нулевое значение — nil; вызов nf(1) — паника runtime error: invalid memory address or nil pointer dereference. f == g не собирается: func can only be compared to nil; сравнение через интерфейс — паника comparing uncomparable type func(int) int.

Замыкание захватывает переменную. c1() трижды — 1 2 3, новый counter() начинает с 1. Два замыкания из pair() делят одну переменную: после двух incr() get() даёт 2. Замыкание, созданное до x = 2, читает 2.

Что вычисляется сразу. defer record(x) при выходе видит x = 1 (аргумент вычислен в момент defer), defer func() { record(x) }() — x = 2. Значение метода v.Get с получателем-значением сохранило копию: после v.n = 2 оно возвращает 1. С получателем-указателем (p.PGet) — 2; а p.Get через указатель копирует *p и возвращает 1.

Переменная цикла — строка go в go.mod, если цикл объявляет её сам. Один и тот же main.go, один компилятор: с go 1.21 три замыкания из цикла по срезу дают c c c, пример из спецификации — 6 6 6, у &i за три итерации один адрес; с go 1.22 — a b c, 1 3 5, три адреса. Значение, переданное параметром, даёт a b c в обоих модулях, а for _, s = range с заранее объявленной s — c c c в обоих (раздел 3). Выделений на цикл из трёх сохранённых замыканий: 4 и 3 — в старом модуле s захвачена по ссылке и уходит в кучу (moved to heap: s), в новом захвачена по значению.

Вариативный параметр. bump(s...) записал 100 в s[0] вызывающего, first(s...) == &s[0] — true; bump(a1, 2, 3) переменную a1 не тронул. append внутри функции при запасе ёмкости записал 9 в массив вызывающего: s2[:3] = [0 0 9] при len(s2) = 2.

Выделения. Замыкание, вызванное на месте, — 0; counter() — 2 (переменная n и замыкание); fixed(), чья x не меняется, — 1, потому что x захвачена по значению (fixed capturing by value: x); сохранённое значение метода — 1; sum(1, 2, 3) и sum(s...) — 0. Раздел 6: тот же counter(), встроенный и использованный на месте, — 0 выделений; сохранённый в глобальную переменную — 2; не встраиваемая копия counterNI(), даже использованная на месте, — 2.

Цена. Вызов замыкания через переменную — 1,73 нс против 1,63 нс прямого вызова, кратность 1,06: в пределах шума. Создать замыкание, которое уходит наружу, и вызвать его — 20,82 нс и одно выделение, в 12,8 раза дороже прямого вызова. Кратности печатаются с двумя знаками, а отношение «создать и вызвать / прямой» — ещё и округлённым до одного знака: это ответ задачи Estimate.

Скрипт

147 строк
//go:build ignore

// Две задачи урока про функции и замыкания: обе отвечают прогоном.
//
// ЗАДАЧА 1 — ПРЕДСКАЗАТЬ ВЫВОД. Два места, где «замыкание захватывает
// значение» даёт неверный ответ, и одно, где верный: счётчик-генератор
// (у каждого вызова counter своя переменная, и замыкание меняет её);
// f(s...) передаёт тот же срез, и запись внутри видна снаружи; значение
// метода с получателем-значением копирует получатель в момент вычисления.
//
// ЗАДАЧА 2 — ОЦЕНИТЬ КРАТНОСТЬ. Сколько стоит вызов замыкания по сравнению с
// вызовом обычной функции — и сколько стоит СОЗДАТЬ замыкание, которое уходит
// наружу. Ответ нужен не ради наносекунд: он показывает, что дорог не вызов
// через значение-функцию, а выделение памяти под захваченное.
//
// ЗАМЕР. testing.Benchmark, лучший из 11 кругов. Машину делят с другими
// задачами, поэтому абсолютные наносекунды шумят от прогона к прогону;
// печатаются и сравниваются кратности к прямому вызову.
//
// ЗАПУСК:
//
//	go run bench/goclosure/practice.go
package main

import (
	"fmt"
	"runtime"
	"testing"
)

// ---------------------------------------------------------------- задача 1

func counter() func() int {
	n := 0
	return func() int { n++; return n }
}

func bump(xs ...int) { xs[0] = 10 }

type T struct{ n int }

func (t T) Get() int { return t.n }

func task1() {
	a, b := counter(), counter()
	a()
	a()
	fmt.Println(a(), b())
	s := []int{1, 2, 3}
	bump(s...)
	fmt.Println(s)
	v := T{n: 1}
	f := v.Get
	v.n = 2
	fmt.Println(f(), v.Get())
}

// ---------------------------------------------------------------- задача 2

//go:noinline
func addOne(x int) int { return x + 1 }

//go:noinline
func makeAdder(k int) func(int) int {
	return func(x int) int { return x + k }
}

var (
	sink   int
	viaVar func(int) int // обычная функция в переменной
	viaClo func(int) int // замыкание в переменной
	sinkF  func(int) int
)

type result struct {
	ns     float64
	allocs int64
}

// best — лучший из 11 кругов: минимум отсекает помехи соседей по машине, но
// не делает число свойством языка.
func best(f func(b *testing.B)) result {
	lo := result{ns: 1e18}
	for round := 0; round < 11; round++ {
		r := testing.Benchmark(func(b *testing.B) {
			b.ReportAllocs()
			f(b)
		})
		if ns := float64(r.T.Nanoseconds()) / float64(r.N); ns < lo.ns {
			lo = result{ns: ns, allocs: r.AllocsPerOp()}
		}
	}
	return lo
}

func main() {
	fmt.Println("ЗАДАЧА 1 — предсказать вывод")
	fmt.Println()
	task1()

	fmt.Println()
	fmt.Println("ЗАДАЧА 2 — цена: вызов замыкания и создание замыкания")
	fmt.Println()
	viaVar = addOne
	viaClo = makeAdder(1)

	direct := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sink = addOne(i)
		}
	})
	fvar := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sink = viaVar(i)
		}
	})
	clo := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sink = viaClo(i)
		}
	})
	create := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sinkF = makeAdder(i)
			sink = sinkF(i)
		}
	})

	row := func(label string, r result) {
		fmt.Printf("  %-40s %6.2f нс   выделений %d   кратность %5.2f\n", label, r.ns, r.allocs, r.ns/direct.ns)
	}
	row("addOne(i)        прямой вызов", direct)
	row("viaVar(i)        функция в переменной", fvar)
	row("viaClo(i)        замыкание в переменной", clo)
	row("makeAdder(i)(i)  создать и вызвать", create)
	fmt.Println()
	fmt.Printf("  вызов замыкания / прямой вызов          %5.2f\n", clo.ns/direct.ns)
	fmt.Printf("  создать и вызвать / прямой вызов        %5.2f\n", create.ns/direct.ns)
	fmt.Printf("  создать и вызвать / вызов замыкания     %5.2f\n", create.ns/clo.ns)
	fmt.Printf("  создать и вызвать / прямой, округлённо  %5.1f\n", create.ns/direct.ns)
	fmt.Println()
	fmt.Printf("  Лучший из 11 кругов. %s %s/%s, GOMAXPROCS=%d\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
	fmt.Println("  Машину делили с другими задачами: устойчивы кратности,")
	fmt.Println("  а не наносекунды.")
}