Deep Engineering

ЗАМЕР

bench/goclosure/internals.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.

Скрипт

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

// Функции и замыкания в Go: что захватывается и когда.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. На собеседовании про замыкания обычно звучит одна
// фраза — «замыкание захватывает переменные из окружения», — и почти все
// вопросы дальше проверяют, понял ли кандидат слово «переменные». Не
// значения: переменную, которую видят и меняют все, кто её захватил. Отсюда
// следуют счётчик-генератор, переменная цикла, уход в кучу — и обратные
// случаи, где значение всё-таки фиксируется в момент вычисления: аргументы
// defer, значение метода, параметр.
//
// ПЯТЬ СЮЖЕТОВ:
//
//  1. Функция — значение. У неё есть тип, нулевое значение nil, размер
//     указателя; вызов nil — паника; сравнить две функции нельзя.
//  2. Замыкание захватывает переменную. Счётчик-генератор, два замыкания
//     над одной переменной, изменение снаружи после создания замыкания.
//  3. Что вычисляется сразу. Аргументы defer — в момент defer, тело
//     замыкания — при выходе. Значение метода x.M копирует получатель-
//     значение в момент вычисления.
//  4. Вариативный параметр. f(s...) передаёт тот же срез, f(a, b, c)
//     создаёт новый.
//  5. Выделения. Сколько стоит замыкание, вызванное на месте, возвращённое,
//     значение метода и вариативный вызов — в выделениях памяти на вызов.
//  6. Добавлено после внешнего аудита 14.10.2026: число выделений у
//     counter() зависит от того, где и как её вызвали. Встроенный вызов,
//     чей результат не уходит наружу, и вызов не встраиваемой копии
//     counter — разные числа для одного и того же исходного кода.
//
// Переменная цикла — в loopvar.sh (два модуля с разной строкой go в
// go.mod), решения компилятора — в escape.sh, ошибки компиляции — в
// compile.sh.
//
// ЗАПУСК:
//
//	go run bench/goclosure/internals.go
package main

import (
	"fmt"
	"runtime"
	"strings"
	"testing"
	"unsafe"
)

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

// try печатает результат или текст паники.
func try(label string, f func() string) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("  %-34s -> паника: %v\n", label, r)
		}
	}()
	fmt.Printf("  %-34s -> %s\n", label, f())
}

// -------------------------------------------------- 1. функция — значение

func inc(x int) int    { return x + 1 }
func double(x int) int { return x * 2 }

func apply(f func(int) int, x int) int { return f(x) }

// ------------------------------------------ 2. захватывается переменная

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

// pair — два замыкания над одной переменной.
func pair() (incr func(), get func() int) {
	n := 0
	incr = func() { n++ }
	get = func() int { return n }
	return
}

// --------------------------------------------- 3. что вычисляется сразу

var log []string

func record(who string, x int) { log = append(log, fmt.Sprintf("%-32s -> x = %d", who, x)) }

func deferDemo() {
	x := 1
	defer record("defer record(x)", x)
	defer func() { record("defer func() { record(x) }()", x) }()
	x = 2
}

type Counter struct{ n int }

func (c Counter) Get() int   { return c.n } // получатель — значение
func (c *Counter) PGet() int { return c.n } // получатель — указатель

// ---------------------------------------------- 4. вариативный параметр

func bump(xs ...int) {
	if len(xs) > 0 {
		xs[0] = 100
	}
}

func isNil(xs ...int) bool { return xs == nil }

func first(xs ...int) *int { return &xs[0] }

func add9(xs ...int) []int { return append(xs, 9) }

// ------------------------------------------------------------ 5. выделения

var (
	sinkF   func() int
	sinkInt int
	gv      = Counter{n: 7}
)

//go:noinline
func inPlace() int {
	n := 0
	f := func() { n++ }
	f()
	f()
	return n
}

//go:noinline
func fixed() func() int {
	x := 42
	return func() int { return x }
}

//go:noinline
func sum(xs ...int) int {
	s := 0
	for _, x := range xs {
		s += x
	}
	return s
}

// counterNI — тот же counter, но компилятору запрещено её встраивать.
//
//go:noinline
func counterNI() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	fmt.Printf("%s %s/%s | функции и замыкания\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)

	show("1) ФУНКЦИЯ — ЗНАЧЕНИЕ")
	var op func(int) int = inc
	fmt.Printf("  var op func(int) int = inc         -> op(41) = %d\n", op(41))
	fmt.Printf("  apply(double, 5)                   -> %d\n", apply(double, 5))
	fmt.Printf("  %%T op                              -> %T\n", op)
	fmt.Printf("  unsafe.Sizeof(op)                  -> %d байт\n", unsafe.Sizeof(op))
	var nf func(int) int
	fmt.Printf("  var nf func(int) int; nf == nil    -> %v\n", nf == nil)
	try("nf(1)", func() string { return fmt.Sprint(nf(1)) })
	f, g := any(inc), any(inc)
	try("any(inc) == any(inc)", func() string { return fmt.Sprint(f == g) })
	fmt.Println("  Функция — значение: её кладут в переменную, передают и")
	fmt.Println("  возвращают. Нулевое значение — nil, и вызов nil — паника.")
	fmt.Println("  f == g не собирается (compile.sh); через интерфейс — паника.")

	show("2) ЗАМЫКАНИЕ ЗАХВАТЫВАЕТ ПЕРЕМЕННУЮ, А НЕ ЗНАЧЕНИЕ")
	c1 := counter()
	a, b, c := c1(), c1(), c1()
	c2 := counter()
	fmt.Printf("  c1 := counter(); c1() x3           -> %d %d %d\n", a, b, c)
	fmt.Printf("  c2 := counter(); c2()              -> %d\n", c2())
	incr, get := pair()
	incr()
	incr()
	fmt.Printf("  incr, get := pair(); incr() x2     -> get() = %d\n", get())
	x := 1
	read := func() int { return x }
	x = 2
	fmt.Printf("  x := 1; read := ...; x = 2         -> read() = %d\n", read())
	set := func() { x = 10 }
	set()
	fmt.Printf("  set := func() { x = 10 }; set()    -> x = %d\n", x)
	fmt.Println("  У каждого вызова counter своя n; incr и get делят одну n;")
	fmt.Println("  read видит присваивание, сделанное после её создания.")

	show("3) ЧТО ВЫЧИСЛЯЕТСЯ СРАЗУ, А ЧТО ПОТОМ")
	deferDemo()
	fmt.Println("  x := 1; два defer; x = 2 — что увидели при выходе:")
	for _, l := range log {
		fmt.Println("    " + l)
	}
	v := Counter{n: 1}
	getV := v.Get
	v.n = 2
	fmt.Printf("  v := Counter{1};  g := v.Get;   v.n = 2 -> g() = %d, v.Get() = %d\n", getV(), v.Get())
	p := &Counter{n: 1}
	pget := p.PGet
	pgetV := p.Get
	p.n = 2
	fmt.Printf("  p := &Counter{1}; g := p.PGet;  p.n = 2 -> g() = %d   (PGet на *Counter)\n", pget())
	fmt.Printf("  p := &Counter{1}; g := p.Get;   p.n = 2 -> g() = %d   (Get на Counter)\n", pgetV())
	fmt.Println("  Аргументы defer и получатель значения метода вычисляются")
	fmt.Println("  сразу и сохраняются копией. Тело замыкания читает переменную")
	fmt.Println("  тогда, когда выполняется.")

	show("4) ВАРИАТИВНЫЙ ПАРАМЕТР: ТОТ ЖЕ СРЕЗ ИЛИ НОВЫЙ")
	s := []int{1, 2, 3}
	bump(s...)
	fmt.Printf("  s := []int{1, 2, 3}; bump(s...)       -> s = %v\n", s)
	a1 := 1
	bump(a1, 2, 3)
	fmt.Printf("  a1 := 1; bump(a1, 2, 3)               -> a1 = %d\n", a1)
	fmt.Printf("  first(s...) == &s[0]                  -> %v\n", first(s...) == &s[0])
	fmt.Printf("  isNil()                               -> %v\n", isNil())
	s2 := make([]int, 2, 4)
	r := add9(s2...)
	fmt.Printf("  s2 := make([]int, 2, 4); add9(s2...)  -> s2[:3] = %v, len(s2) = %d\n", s2[:3], len(s2))
	_ = r
	fmt.Println("  f(s...) передаёт сам срез: запись в xs[0] видна снаружи, и")
	fmt.Println("  append с запасом ёмкости пишет в массив вызывающего.")
	fmt.Println("  f(a, b, c) собирает новый срез из копий аргументов.")

	show("5) ВЫДЕЛЕНИЯ ПАМЯТИ НА ВЫЗОВ")
	allocs := func(label string, f func()) {
		fmt.Printf("  %-44s %.0f\n", label, testing.AllocsPerRun(1000, f))
	}
	allocs("замыкание вызвано на месте (inPlace)", func() { sinkInt = inPlace() })
	allocs("counter(): n меняется, замыкание возвращено", func() { sinkF = counter() })
	allocs("fixed(): x не меняется, замыкание возвращено", func() { sinkF = fixed() })
	allocs("значение метода gv.Get сохранено", func() { sinkF = gv.Get })
	allocs("sum(1, 2, 3)", func() { sinkInt = sum(1, 2, 3) })
	allocs("sum(s...)", func() { sinkInt = sum(s...) })
	fmt.Println("  counter — две: переменная n и само замыкание. fixed — одна:")
	fmt.Println("  x никто не меняет, и она копируется в замыкание (escape.sh).")

	show("6) ВЫДЕЛЕНИЯ У counter() ЗАВИСЯТ ОТ МЕСТА ВЫЗОВА")
	allocs("c := counter(); c(); c() — на месте", func() {
		c := counter()
		c()
		sinkInt = c()
	})
	allocs("sinkF = counter() — сохранено наружу", func() { sinkF = counter() })
	allocs("c := counterNI(); c(); c() — noinline", func() {
		c := counterNI()
		c()
		sinkInt = c()
	})
	fmt.Println("  Исходный код counter один и тот же. Встроенный вызов, чьё")
	fmt.Println("  замыкание не уходит наружу, обошёлся без кучи; сохранённый")
	fmt.Println("  наружу результат и не встраиваемая копия — по два выделения.")
	fmt.Println("  Число выделений — наблюдение этой версии компилятора в этом")
	fmt.Println("  месте вызова, а не свойство функции.")
}