MEASUREMENT
bench/goclosure/practice.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/golang/closures
- How to run it
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
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для урока «Функции и замыкания в 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.
Script
147 lines//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(" а не наносекунды.")
}