Функции и замыкания в Go: что захватывается и когда
Собеседование по замыканиям идёт лестницей: можно ли сравнить две функции — что такое замыкание — что напечатает счётчик — что увидят замыкания, созданные в цикле — когда вычисляются аргументы defer и что сохраняет значение метода — передаёт ли f(s...) копию — во что обходится замыкание. Прогон отвечает на каждый шаг: замыкание читает присваивание, сделанное после его создания; один и тот же цикл дал c c c в модуле с go 1.21 и a b c с go 1.22; f(s...) записал в срез вызывающего; а вызов замыкания стоил столько же, сколько прямой вызов, — дорого только его создание.
Полное техническое изложение
TL;DR
Функция в Go — значение, а замыкание — функциональный литерал, который
ссылается на переменные объемлющей функции. У значения-функции есть тип
(func(int) int), нулевое значение nil, его кладут в переменную, передают и
возвращают. Вызов nil — паника, а сравнить две функции нельзя: f == g не
собирается, сравнение допустимо только с nil.
Замыкание захватывает переменную, а не её значение. Спецификация говорит
прямо: такие переменные становятся общими для функции и литерала. Поэтому
замыкание из counter() даёт 1 2 3 при трёх вызовах — каждый вызов меняет
одну и ту же n, — у двух замыканий из одного вызова pair() get видит
изменения, сделанные incr, а замыкание, созданное до x = 2, читает 2.
Дальше — то, что отличает понимание от пересказа. Сколько переменных у
цикла, решает строка go в go.mod — и только для переменной, которую
объявляет сам цикл (for _, s := range): один и тот же код на одном
компиляторе go1.24.7 дал c c c в модуле с go 1.21 и a b c с go 1.22, а
цикл, который присваивает заранее объявленной s (for _, s = range), дал
c c c в обоих модулях. Значение
фиксируется только там, где язык что-то вычисляет сразу: аргумент вызова,
аргумент defer, получатель значения метода — g := v.Get; v.n = 2 и g()
вернул 1. f(s...) передаёт сам срез, а не копию: запись внутри видна
снаружи. Если замыкание после оптимизаций уходит наружу, изменяемая
захваченная переменная уходит вместе с ним в кучу — память, которая переживает
вызов функции и освобождается сборщиком мусора: при sinkF = counter() прогон
насчитал два выделения, а тот же counter(), встроенный и использованный на
месте, — ноль. Число выделений — наблюдение компилятора в конкретном месте
вызова, а не свойство функции. В нашем замере (go1.24.7, linux/amd64, лучший
из 11 кругов) вызов замыкания стоил столько же, сколько прямой вызов, а
создание замыкания, уходящего наружу, — в 12,8 раза больше; механизм
объясняет, откуда разница, а кратность на своей машине нужно мерить.
- как объявить функцию, переменную и цикл
for … range, и чем объявление:=отличается от присваивания=; - что параметр — копия аргумента, а указатель ведёт к общей переменной, — это урок об указателях;
- что срез — заголовок над массивом; достаточно уровня «как пользоваться», подробно — в уроке о срезах;
- что метод объявляется с получателем-значением или получателем-указателем — подробно в уроке о получателе метода;
- что
deferоткладывает вызов до выхода из функции — подробно в уроке о defer, panic и recover.
- что такое функциональный литерал, значение метода и вариативный параметр
...T; - что такое стек, куча, escape-анализ и флаг
-gcflags=-m— нужное объясняется по ходу, подробное — в уроке об escape-анализе; Механизм 5 и «Глубже» — уровень Senior, при первом чтении их можно пропустить; - как переменная цикла связана со строкой
goвgo.mod.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Функции в Go — значения первого класса? Можно ли сравнить две функции?» — разминка; вторая половина вопроса ловит чаще первой.
- «Что такое замыкание?» — ответ «функция, которая помнит окружение» верный, но не говорит главного: помнит переменные, а не значения.
- «Что напечатает этот счётчик?» (с кодом) — проверка того же на коде.
- «Что напечатают замыкания, созданные в цикле?» — классика, у которой с
Go 1.22 два ответа, и выбирает между ними
go.mod. - «Когда вычисляются аргументы
defer? Что сохраняетf := v.Method?» — где значение всё-таки фиксируется в момент записи. - «
f(s...)передаёт копию среза?» — про вариативный параметр. - «Сколько стоит замыкание?» — про кучу и про то, что дорого на самом деле.
Стержень у лестницы один: замыкание захватывает переменную, а не значение;
значение фиксируется только там, где язык явно что-то вычисляет — при
передаче аргумента, в операторе defer, при взятии значения метода. Всё, что
в этом уроке выглядит неожиданным, из этой фразы и следует.
База: функция — значение
Спецификация вводит функциональный тип как множество функций с одной сигнатурой и тут же называет его нулевое значение:
A function type denotes the set of all functions with the same parameter and
result types. The value of an uninitialized variable of function type is nil.
Тип функции обозначает множество всех функций с одинаковыми типами параметров и результатов. Значение неинициализированной переменной функционального типа — nil.
Значит, функцию можно положить в переменную, передать параметром и вызвать через параметр:
Фрагмент bench/goclosure/internals.go (функции уровня пакета):
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) }Вывод bench/goclosure/internals.go, раздел 1:
1) ФУНКЦИЯ — ЗНАЧЕНИЕ
─────────────────────
var op func(int) int = inc -> op(41) = 42
apply(double, 5) -> 10
%T op -> func(int) int
unsafe.Sizeof(op) -> 8 байт
var nf func(int) int; nf == nil -> true
nf(1) -> паника: runtime error: invalid memory address or nil pointer dereference
any(inc) == any(inc) -> паника: runtime error: comparing uncomparable type func(int) int
Первые четыре строки — функция как обычное значение: у него есть тип и размер. Восемь байт — размер указателя на amd64; что лежит за этим указателем, разобрано в «Глубже».
Пятая и шестая — нулевое значение. Переменная функционального типа без
присваивания равна nil, и вызвать её нельзя:
Calling a nil function value causes a run-time panic.
Вызов функционального значения nil вызывает панику во время выполнения.
Поэтому необязательный обработчик — поле OnError func(error) в структуре
настроек — проверяют на nil перед вызовом.
Последняя строка — сравнение. Две функции сравнить нельзя вовсе:
Slice, map, and function types are not comparable. However, as a special case,
a slice, map, or function value may be compared to the predeclared identifier
nil.
Типы срезов, карт и функций несравнимы. Однако в виде особого случая значение среза, карты или функции можно сравнить с предобъявленным идентификатором nil.
Компилятор ловит это сразу, и по той же причине функция не может быть ключом карты:
Вывод bench/goclosure/compile.sh (файл cases/funccmp/main.go намеренно не собирается):
funccmp:
./main.go:11:10: invalid operation: f == g (func can only be compared to nil)
./main.go:12:15: invalid map key type func(int) int
Строка f == nil в том же файле (строка 10) ошибки не дала. А если функции
спрятаны в any, компилятор уже не видит типа, и несравнимость всплывает при
выполнении: паника comparing uncomparable type func(int) int из прогона выше.
Этого достаточно для ответа уровня Junior: функция в Go — значение со своим
типом; её можно присвоить, передать, вернуть. Нулевое значение — nil, вызов
nil — паника. Сравнивать функции можно только с nil. Следующий шаг —
функция, которая не просто передана, а создана внутри другой.
Механизм 1: замыкание захватывает переменную, а не значение
Функциональный литерал — функция без имени, записанная прямо в выражении. Спецификация определяет его через переменные, на которые он ссылается:
Function literals are closures: they may refer to variables defined in a
surrounding function. Those variables are then shared between the surrounding
function and the function literal, and they survive as long as they are
accessible.
Функциональные литералы — замыкания: они могут ссылаться на переменные, определённые в объемлющей функции. Тогда эти переменные общие для объемлющей функции и литерала, и они живут, пока к ним есть доступ.
Ключевое слово — shared, общие. Не «скопированы в замыкание», а общие. Проверка:
Фрагмент bench/goclosure/internals.go:
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}
func pair() (incr func(), get func() int) {
n := 0
incr = func() { n++ }
get = func() int { return n }
return
}Вывод bench/goclosure/internals.go, раздел 2:
2) ЗАМЫКАНИЕ ЗАХВАТЫВАЕТ ПЕРЕМЕННУЮ, А НЕ ЗНАЧЕНИЕ
──────────────────────────────────────────────────
c1 := counter(); c1() x3 -> 1 2 3
c2 := counter(); c2() -> 1
incr, get := pair(); incr() x2 -> get() = 2
x := 1; read := ...; x = 2 -> read() = 2
set := func() { x = 10 }; set() -> x = 10
Каждая строка — следствие слова «общие»:
- Счётчик.
n— переменнаяcounter, и замыкание её не копирует, а меняет.counterдавно вернулся, аnжива: к ней есть доступ черезc1. Новый вызовcounter()создаёт новуюn, поэтомуc2()начинает с1. - Два замыкания над одной переменной.
incrиgetсозданы одним вызовомpairи делят однуn: после двухincr()get()даёт2. - Присваивание после создания.
readсоздана приx = 1, а вызвана послеx = 2— и вернула2. Значения1в замыкании нет и не было: есть ссылка наx. - Запись из замыкания.
setприсвоилаx = 10, и объемлющая функция это видит.
Отсюда счётчики, генераторы, мемоизация и обработчики с состоянием — всё это одна идея: переменная, которая живёт дольше вызова, создавшего её, и доступна только через замыкание. Оборотная сторона та же: общую переменную видят все, кто её захватил. Если такие замыкания вызывают из разных горутин без синхронизации, это гонка данных — та, что разобрана в уроке о гонках.
Всё сказанное помещается в короткую программу. Полный файл
bench/goclosure/cases/example/main.go — его можно скопировать в пустой
каталог рядом с go.mod (module example, go 1.24) и запустить командой
go run .:
package main
import "fmt"
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
c1 := counter()
fmt.Println(c1(), c1(), c1())
c2 := counter()
fmt.Println(c2())
var fs []func() string
for _, s := range []string{"a", "b", "c"} {
fs = append(fs, func() string { return s })
}
fmt.Println(fs[0](), fs[1](), fs[2]())
}Вывод bench/goclosure/example.sh:
go1.24.7 linux/amd64 | полный пример: go run .
1 2 3
1
a b c
Последняя строка — цикл из Механизма 2 в модуле с go 1.24.
Ответ на «что такое замыкание», который выдерживает следующий вопрос: функциональный литерал, который ссылается на переменные объемлющей функции. Эти переменные общие для функции и литерала и живут, пока к ним есть доступ: замыкание видит их текущее значение в момент вызова, а не то, что было при создании.
Механизм 2: переменная цикла — одна на цикл или своя на итерацию
Классическая ловушка с замыканиями — цикл. Сколько в нём переменных? С Go 1.22 спецификация отвечает — для переменных, которые объявляет сам цикл:
Each iteration has its own separate declared variable (or variables)
У каждой итерации своя отдельная объявленная переменная (или переменные).
А о прошлом говорит одной фразой: Prior to [Go 1.22], iterations share one set of variables instead of having their own separate variables
(до [Go 1.22] итерации делят один набор переменных, а не имеют каждая свои).
Какая из двух семантик действует, решает версия языка модуля — строка
go в go.mod, — а не версия компилятора. Проверка: один и тот же main.go
в двух модулях, go 1.21 и go 1.22, один компилятор go1.24.7:
Фрагмент bench/goclosure/cases/loop12x/main.go (файл одинаков в обоих модулях):
var names = []string{"a", "b", "c"}
func rangeClosures() []func() string {
var fs []func() string
for _, s := range names {
fs = append(fs, func() string { return s })
}
return fs
}
// пример из раздела For statements спецификации
func specExample() []func() int {
var prints []func() int
for i := 0; i < 5; i++ {
prints = append(prints, func() int { return i })
i++
}
return prints
}Вывод bench/goclosure/loopvar.sh, разделы 1 и 2:
1) МОДУЛЬ С go 1.21 В go.mod
────────────────────────────
замыкания из цикла по срезу: c c c
значение передано параметром: a b c
пример из спецификации: 6 6 6
разных адресов &i за 3 итерации: 1
выделений на цикл из 3 замыканий: 4
2) МОДУЛЬ С go 1.22 В go.mod
────────────────────────────
замыкания из цикла по срезу: a b c
значение передано параметром: a b c
пример из спецификации: 1 3 5
разных адресов &i за 3 итерации: 3
выделений на цикл из 3 замыканий: 3
Строка с адресами — весь механизм. В старом модуле &i за три итерации дал
один адрес: переменная одна, и каждая итерация присваивает ей новое
значение. Три замыкания захватили её одну, а вызваны после цикла — когда в ней
последнее значение. Отсюда c c c. В новом модуле адресов три: у каждой
итерации своя переменная, и каждое замыкание смотрит в свою.
Пример из спецификации тоньше: тело цикла само делает i++. В старом модуле
одна i дошла до 6, и все три замыкания печатают 6. В новом каждая итерация
получает свою i, инициализированную значением прошлой перед i++ в
заголовке, а i++ в теле меняет переменную только своей итерации: 1 3 5 —
ровно то, что обещает спецификация.
Средняя строка — способ зафиксировать значение в любой версии: передать его параметром. Аргумент копируется в параметр при вызове, и у каждого вызова своя переменная:
Фрагмент функции paramClosures из того же main.go:
mk := func(v string) func() string { return func() string { return v } }
for _, s := range names {
fs = append(fs, mk(s))
}Строка s := s в теле цикла делает то же самое — объявляет новую переменную
на каждой итерации. В модулях с go 1.22 и выше она не нужна, если цикл сам
объявляет s через :=; в модулях постарше — это защита, а не мусор.
Оговорка «объявляет сам цикл» — не формальность. Правило о своей переменной на
итерацию спецификация записывает для переменных, объявленных в заголовке
range через :=; про остальные сказано другое:
If the iteration variables are not explicitly declared by the "range" clause,
they must be preexisting. In this case, the iteration values are assigned to
the respective variables as in an assignment statement.
Если переменные итерации не объявлены явно в заголовке range, они должны существовать заранее. В этом случае значения итерации присваиваются им, как в операторе присваивания.
Фрагмент bench/goclosure/cases/loop12x/assign.go — переменная объявлена до
цикла, цикл ей присваивает:
func assignClosures() []func() string {
var s string
var fs []func() string
for _, s = range names {
fs = append(fs, func() string { return s })
}
return fs
}Вывод bench/goclosure/loopvar.sh, раздел 3:
3) ПЕРЕМЕННАЯ ОБЪЯВЛЕНА ДО ЦИКЛА: for _, s = range
──────────────────────────────────────────────────
модуль с go 1.21 в go.mod:
var s string; for _, s = range names: c c c
./assign.go:14:6: assignClosures capturing by ref: s (addr=false assign=true width=16)
./assign.go:14:6: moved to heap: s
модуль с go 1.22 в go.mod:
var s string; for _, s = range names: c c c
./assign.go:14:6: assignClosures capturing by ref: s (addr=false assign=true width=16)
./assign.go:14:6: moved to heap: s
Одна переменная на весь цикл в обоих модулях: = присваивает, а не
объявляет, и строка go в go.mod здесь ничего не меняет.
Практический вывод: «в Go 1.22 это починили» верно лишь наполовину. Модуль,
чей go.mod объявляет 1.21 или ниже, сохраняет старую семантику и на новом
компиляторе, а цикл с = вместо := делит одну переменную в любом модуле. Как это выглядит в параллельных подтестах и что про это говорит
go vet — в уроке о тестировании; там та же
ловушка дала тест, который проверил только последний случай таблицы и прошёл.
Последняя строка прогона — бонус: новая семантика здесь не дороже, а на одно выделение дешевле. Почему — в Механизме 5.
Механизм 3: что вычисляется сразу — аргументы defer и значение метода
Если замыкание читает переменную в момент вызова, то где значение всё-таки фиксируется заранее? Там, где язык вычисляет выражение и сохраняет результат. Таких мест в этой теме два, кроме обычной передачи аргумента.
Аргументы defer. Правило из спецификации:
Each time a "defer" statement executes, the function value and parameters to
the call are evaluated as usual and saved anew but the actual function is not
invoked.
Каждый раз при выполнении оператора defer значение функции и параметры вызова вычисляются как обычно и сохраняются заново, но сама функция не вызывается.
Значение метода. Выражение v.Get без скобок — тоже функция, «привязанная»
к получателю. Спецификация говорит, когда привязывается получатель:
The expression x is evaluated and saved during the evaluation of the method
value; the saved copy is then used as the receiver in any calls, which may be
executed later.
Выражение x вычисляется и сохраняется при вычислении значения метода; сохранённая копия затем служит получателем во всех вызовах, которые могут произойти позже.
Проверка обоих правил:
Фрагмент bench/goclosure/internals.go:
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 } // получатель — указательВывод bench/goclosure/internals.go, раздел 3:
3) ЧТО ВЫЧИСЛЯЕТСЯ СРАЗУ, А ЧТО ПОТОМ
─────────────────────────────────────
x := 1; два defer; x = 2 — что увидели при выходе:
defer func() { record(x) }() -> x = 2
defer record(x) -> x = 1
v := Counter{1}; g := v.Get; v.n = 2 -> g() = 1, v.Get() = 2
p := &Counter{1}; g := p.PGet; p.n = 2 -> g() = 2 (PGet на *Counter)
p := &Counter{1}; g := p.Get; p.n = 2 -> g() = 1 (Get на Counter)
Две группы строк, и граница в них проходит по одному вопросу — что именно вычислено в момент записи:
defer record(x)вычислил аргументxсразу — сохранилась1.defer func() { record(x) }()вычислил только саму функцию, у которой нет аргументов;xона читает при выходе, когда там уже2. Порядок строк обратный порядкуdefer— последний отложенный выполняется первым. Подробно оdefer— в уроке о defer, panic и recover.g := v.Getс получателем-значением скопировалv. Изменениеv.nпосле этого копию не трогает:g()—1, хотяv.Get()уже2.g := p.PGetс получателем-указателем сохранил указатель — копию адреса, которая ведёт к той же переменной. Поэтомуg()видит2.g := p.Get— метод на значении, взятый через указатель. Спецификация уточняет, чтоpt.Mvздесь эквивалентно(*pt).Mv: разыменование и копия — в момент вычисления, иg()вернул1.
Практическое следствие — обработчики и колбэки. http.HandleFunc("/", srv.handle)
или time.AfterFunc(d, c.flush) сохраняют значение метода. Если у handle
получатель-значение, обработчик всю жизнь будет работать с копией srv,
снятой при регистрации. Это ровно правило из урока об
указателях, только копия здесь отложена во
времени.
Механизм 4: вариативный параметр — тот же срез или новый
Внутри функции func f(xs ...int) параметр xs — срез []int. Откуда он
берётся, зависит от того, как функцию вызвали. Спецификация различает два
случая. Со списком аргументов:
Otherwise, the value passed is a new slice of type []T with a new underlying
array whose successive elements are the actual arguments
Иначе передаётся новый срез типа []T с новым базовым массивом, последовательные элементы которого — фактические аргументы.
Со срезом и ...:
If the final argument is assignable to a slice type []T and is followed by ...,
it is passed unchanged as the value for a ...T parameter. In this case no new
slice is created.
Если последний аргумент присваиваем срезу типа []T и за ним следует ..., он передаётся без изменений как значение параметра ...T. В этом случае новый срез не создаётся.
Проверка:
Фрагмент bench/goclosure/internals.go:
func bump(xs ...int) {
if len(xs) > 0 {
xs[0] = 100
}
}
func first(xs ...int) *int { return &xs[0] }
func add9(xs ...int) []int { return append(xs, 9) }Вывод bench/goclosure/internals.go, раздел 4:
4) ВАРИАТИВНЫЙ ПАРАМЕТР: ТОТ ЖЕ СРЕЗ ИЛИ НОВЫЙ
──────────────────────────────────────────────
s := []int{1, 2, 3}; bump(s...) -> s = [100 2 3]
a1 := 1; bump(a1, 2, 3) -> a1 = 1
first(s...) == &s[0] -> true
isNil() -> true
s2 := make([]int, 2, 4); add9(s2...) -> s2[:3] = [0 0 9], len(s2) = 2
bump(s...)записал100вs[0]вызывающего:xsиs— заголовки над одним массивом,first(s...) == &s[0]подтверждает это адресом.bump(a1, 2, 3)получил новый срез из копий аргументов —a1не тронута.- Без аргументов
xsравенnil: так и записано в спецификации. - Последняя строка — следствие посерьёзнее.
add9сделалappendвнутри. Уs2длина 2 и запас ёмкости, поэтомуappendзаписал9в массив вызывающего, за пределы его длины:s2[:3]показывает[0 0 9]. Самs2этого не видит (len(s2) = 2), но следующийappend(s2, …)у вызывающего затрёт эту девятку или будет затёрт ею.
Правило для кода: f(s...) — это передача среза, а не копирование
аргументов. Функция, которая не должна трогать данные вызывающего, копирует
срез сама (slices.Clone). Как устроены заголовок, массив и запас ёмкости — в
уроке о срезах.
Механизм 5: захваченная переменная уходит в кучу вместе с замыканием
Спецификация обещает, что захваченные переменные survive as long as they are accessible
(живут, пока к ним есть доступ),
и ничего не говорит о том, где они лежат: в кадре функции (на стеке) или в
куче. Это решает компилятор. Если замыкание после оптимизаций остаётся
достижимым после возврата из функции, его переменная обязана пережить кадр, и
компилятор переносит её в кучу. Файл ниже собран с -l — без встраивания, —
поэтому возвращённые замыкания в нём уходят наружу. Решение компилятор
называет сам:
Фрагмент bench/goclosure/cases/escape/main.go; комментарии с номерами строк добавлены здесь:
func counter() func() int { // строка 6
n := 0
return func() int {
n++
return n
}
}
func local() int { // строка 15
n := 0
inc := func() { n++ }
inc()
inc()
return n
}
func fixed() func() int { // строка 24
x := 42
return func() int { return x }
}Вывод bench/goclosure/escape.sh:
go1.24.7 linux/amd64 | go build -gcflags='-m -l': решения о замыканиях
./main.go:7:2: moved to heap: n
./main.go:8:9: func literal escapes to heap
./main.go:17:9: func literal does not escape
./main.go:26:9: func literal escapes to heap
./main.go:36:33: t.Get escapes to heap
./main.go:38:10: xs does not escape
./main.go:47:33: ... argument does not escape
Строки 7–8 — counter: и переменная n, и само замыкание уходят в кучу. Строка
17 — local: замыкание вызвано на месте и никуда не ушло, n осталась в
кадре, хотя её меняют. Строки 36–47 — methodValue и sum из того же файла,
к ним вернёмся ниже. Строка 26 — fixed: замыкание ушло, но строки moved to heap: x нет.
Ответ — в отчёте с -m=2:
Вывод bench/goclosure/escape.sh, вторая часть:
-gcflags='-m=2 -l', только строки о захвате:
./main.go:7:2: counter capturing by ref: n (addr=false assign=true width=8)
./main.go:16:2: local capturing by ref: n (addr=false assign=true width=8)
./main.go:25:2: fixed capturing by value: x (addr=false assign=false width=8)
x никто не переприсваивает, и компилятор копирует её в замыкание вместо
ссылки — заметить разницу программа не может, ведь переменная не меняется.
Условие записано в escape.go: Capture by value for variables <= 128 bytes that are never reassigned
(захват по значению для переменных до 128 байт, которые никогда не переприсваиваются).
n в counter меняется (assign=true) — захват по ссылке, и n уезжает в
кучу отдельным объектом.
Это и объясняет последнюю строку Механизма 2: в модуле с go 1.21 переменная
цикла s переприсваивается на каждой итерации — захват по ссылке и
moved to heap: s; с go 1.22 у каждой итерации своя s, которую никто не
меняет, — захват по значению:
Вывод bench/goclosure/loopvar.sh, разделы 1 и 2, строки -m=2:
-gcflags=-m=2, функция stored:
./main.go:58:9: stored capturing by ref: s (addr=false assign=true width=16)
./main.go:58:9: moved to heap: s
-gcflags=-m=2, функция stored:
./main.go:58:9: stored capturing by value: s (addr=false assign=false width=16)
Отсюда 4 и 3: в старом модуле — переменная s в куче плюс три объекта
замыканий, в новом — только три объекта, и копия s лежит в каждом.
Сколько это в выделениях памяти на вызов:
Вывод bench/goclosure/internals.go, раздел 5:
5) ВЫДЕЛЕНИЯ ПАМЯТИ НА ВЫЗОВ
────────────────────────────
замыкание вызвано на месте (inPlace) 0
counter(): n меняется, замыкание возвращено 2
fixed(): x не меняется, замыкание возвращено 1
значение метода gv.Get сохранено 1
sum(1, 2, 3) 0
sum(s...) 0
counter() — два выделения: n и объект замыкания. Здесь результат
counter() сохраняют в глобальную переменную sinkF, то есть он гарантированно
уходит наружу. fixed() — одно: x лежит внутри объекта замыкания. Значение метода, сохранённое в глобальную
переменную, — тоже одно: ему нужно где-то хранить копию получателя
(t.Get escapes to heap в отчёте). Вариативный вызов, чей срез не ушёл
наружу, обходится без кучи (... argument does not escape).
Общее правило — то же, что в уроке об escape-анализе:
захват сам по себе ничего не стоит; стоит то, что замыкание пережило
кадр. Типичные источники таких выделений — go func() { ... }() и
колбэки, сохранённые в структуры.
Что «пережило кадр» решается в месте вызова, а не в объявлении функции, видно
по одному и тому же counter(), вызванному трижды по-разному (counterNI —
его копия с //go:noinline):
Вывод bench/goclosure/internals.go, раздел 6:
6) ВЫДЕЛЕНИЯ У counter() ЗАВИСЯТ ОТ МЕСТА ВЫЗОВА
────────────────────────────────────────────────
c := counter(); c(); c() — на месте 0
sinkF = counter() — сохранено наружу 2
c := counterNI(); c(); c() — noinline 2
Встроенный counter(), чьё замыкание используют на месте и никуда не
отдают, обошёлся без кучи: после встраивания замыкание не уходит наружу. Не
встраиваемая копия — два выделения: внутри неё компилятор не видит, что
результат дальше не уйдёт. Поэтому на вопрос «сколько выделений у
counter()» правильный ответ начинается с «где и как её вызвали»: код
корректен в любом случае, а размещение и цену определяет компилятор.
Глубже: что такое значение-функция и сколько стоит вызов
unsafe.Sizeof(op) дал 8 байт: значение-функция — указатель. Описание
внутреннего ABI компилятора (cmd/compile/abi-internal.md, строки 356–359 на
go1.24.7) говорит, на что он указывает:
A closure object begins with a pointer-sized program counter representing the
entry point of the function, followed by zero or more bytes containing the
closed-over environment.
Объект замыкания начинается с программного счётчика размером в указатель — точки входа функции, — за которым следуют ноль или больше байт с захваченным окружением.
У обычной функции окружения нет — ноль байт. У замыкания это захваченные
переменные; компилятор описывает объект в typecheck/func.go так:
Фрагмент комментария в typecheck/func.go, строка 94:
// clos = &struct{F uintptr; X0 *int; X1 *string}{func.1, &i, &s}Поле F — код литерала, дальше — захваченные переменные: по ссылке
(указатель, как *int здесь) или по значению (копия). При вызове адрес
объекта кладётся в регистр контекста замыкания — на amd64 это RDX (строка
410 того же документа), — и тело литерала достаёт захваченное оттуда.
Отсюда два вывода, которые проверяются замером. Первый: обычная функция,
положенная в переменную, — то же значение-функция без захваченных данных, и
вызов замыкания должен стоить примерно столько же. Второй: дорого не
вызвать, а создать замыкание, которое уходит наружу, — это выделение
объекта в куче. А литерал, который вызывают прямо на месте (func() { … }()),
компилятор переписывает в обычный вызов с захваченными переменными в
аргументах; в комментарии к directClosureCall сказано, что так This avoids allocation of a closure object
(не выделяется объект замыкания).
У inPlace в прогоне тоже ноль выделений, но по другой причине: замыкание
сохранено в переменную и никуда не уходит (func literal does not escape у
такой же local), поэтому его объект остаётся в кадре.
Вывод bench/goclosure/practice.go, задача 2:
addOne(i) прямой вызов 1.63 нс выделений 0 кратность 1.00
viaVar(i) функция в переменной 1.86 нс выделений 0 кратность 1.14
viaClo(i) замыкание в переменной 1.73 нс выделений 0 кратность 1.06
makeAdder(i)(i) создать и вызвать 20.82 нс выделений 1 кратность 12.77
addOne помечена //go:noinline, так что прямой вызов — настоящий вызов, а не
встроенное сложение. Вызов функции через переменную и вызов замыкания через
переменную от прямого не отличаются в пределах шума: 1,86 и 1,73 нс против
1,63. А создать замыкание makeAdder(i) и сохранить его наружу — 20,82 нс и
одно выделение, в 12,8 раза дороже прямого вызова.
Сводка, которую удобно держать в голове вместо «замыкания медленные»:
| операция | выделений | цена относительно прямого вызова |
|---|---|---|
| вызвать замыкание, которое уже есть | 0 | ≈1 (1,06 в записи) |
| вызвать замыкание, которое не уходит наружу | 0 | объект остаётся в кадре |
| создать замыкание, которое уходит наружу | 1–2 | 12,8 в этом замере, с одним выделением |
Числа — наблюдение этой конфигурации; они отвечают на вопрос «бывает ли по-разному», а не «как будет в вашем коде»: дорогим замыкание становится тогда, когда его создают в горячем цикле и отпускают наружу, — это и стоит искать профилем.
Как отвечать на собеседовании
Короткий ответ: функция в Go — значение: у неё есть тип, нулевое значение
nil, её передают и возвращают; сравнивать можно только с nil. Замыкание —
функциональный литерал, который ссылается на переменные объемлющей функции, и
эти переменные общие: замыкание видит их текущее значение в момент вызова, а
не копию на момент создания.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «что напечатает счётчик» — одна переменная на вызов. «Каждый вызов
counter() создаёт свою n, замыкание её меняет: три вызова — 1 2 3, новый
счётчик начинает с 1. Два замыкания из одного вызова делят одну
переменную».
На «замыкания в цикле» — назовите go.mod. «С Go 1.22 у каждой итерации
своя переменная, но только если её объявляет сам цикл (:=) и только в
модулях, чей go.mod объявляет 1.22 и выше; for _, s = range с заранее
объявленной s даёт c c c и там. Один
и тот же цикл на go1.24.7 дал c c c с go 1.21 и a b c с go 1.22. В
старом коде значение передают параметром или пишут s := s».
На «когда фиксируется значение» — где язык вычисляет сразу. «Аргументы
defer вычисляются в момент defer, тело замыкания — при выходе. Значение
метода v.Get с получателем-значением копирует v при взятии: после
v.n = 2 оно вернуло 1. С получателем-указателем копируется адрес, и
изменения видны».
На «f(s...) копирует?» — нет. «Передаётся тот же срез: запись в xs[0]
видна вызывающему, а append внутри при запасе ёмкости пишет в его массив.
Новый срез создаётся только для списка аргументов f(a, b, c)».
На «сколько стоит» — создание, а не вызов. «В нашем замере вызов замыкания
стоил столько же, сколько прямой вызов. Дороже создать замыкание, которое после
оптимизаций уходит наружу: его переменные и сам объект уезжают в кучу —
moved to heap: n, два выделения у counter(), чей результат сохранили в
глобальную переменную; встроенный и использованный на месте counter() дал
ноль. Сколько это в разах на вашей машине — надо мерить».
Дальше спросят
Как в старом модуле дать каждой горутине в цикле своё значение?
Передать значение параметром: go func(v string) { use(v) }(s) — аргумент
вычисляется в момент запуска горутины и копируется в её параметр. Или
объявить новую переменную в теле: s := s. В прогоне параметр дал a b c в
обоих модулях — и с go 1.21, и с go 1.22.
Лучшее решение для живого кода — поднять строку go в go.mod до 1.22 или
выше: тогда новая семантика действует во всём модуле. Перед этим стоит
понимать, что меняется поведение всех циклов модуля, в том числе тех, что
случайно полагались на общую переменную. Как такую ошибку ловит go vet в
подтестах — в уроке о тестировании.
Чем значение метода v.Get отличается от выражения метода Counter.Get?
Значение метода v.Get привязывает получатель: это функция func() int, и
v в ней уже скопирован. Выражение метода Counter.Get получатель не
привязывает: это функция func(Counter) int, и получатель передают первым
аргументом. В спецификации: given f := T.Mv, f is invoked as f(t, 7) not t.f(7)
(при f := T.Mv функцию вызывают как f(t, 7), а не t.f(7)).
Отсюда и выбор. Нужна функция, которая работает с одним конкретным получателем, — значение метода, с копией в момент взятия. Нужна функция, которую можно применить к разным получателям (например, в таблице обработчиков), — выражение метода.
Почему функцию нельзя сделать ключом карты?
Потому что ключ карты должен быть сравнимым, а функции несравнимы — кроме
сравнения с nil. Компилятор отвечает invalid map key type func(int) int.
Если нужно «множество обработчиков» — ключом делают что-то сравнимое: имя,
число, указатель на структуру, в которой лежит функция. Сравнение через any
не выход: в прогоне any(inc) == any(inc) дало панику
comparing uncomparable type func(int) int.
Частые заблуждения
Замыкание запоминает значение переменной в момент создания
Оно ссылается на саму переменную. Замыкание, созданное при x = 1 и вызванное после x = 2, вернуло 2. Значение фиксируется только там, где язык что-то вычисляет сразу: аргумент вызова, аргумент defer, получатель значения метода.
С Go 1.22 переменная цикла в замыканиях больше не ловушка
Только для переменной, которую объявляет сам цикл (:=), и только в модулях, чей go.mod объявляет 1.22 и выше. Тот же цикл на компиляторе go1.24.7 в модуле с go 1.21 дал c c c, и у &i за три итерации был один адрес; а for _, s = range с заранее объявленной s дал c c c и с go 1.22.
Две функции можно сравнить, если это одна и та же функция
f == g не собирается: func can only be compared to nil. Через интерфейс сравнение собирается, но падает: comparing uncomparable type func(int) int.
f(s...) передаёт в функцию копию элементов
Передаётся сам срез: bump(s...) изменил s[0] вызывающего на 100, и &xs[0] == &s[0]. Новый срез создаётся только при вызове со списком аргументов.
Значение метода v.Get видит последующие изменения v
Если получатель — значение, нет: v копируется в момент взятия, и после v.n = 2 g() вернул 1. С получателем-указателем копируется адрес, и изменения видны: 2.
Любое замыкание выделяет память в куче
Только ушедшее наружу после оптимизаций. Замыкание, вызванное на месте, — 0 выделений, func literal does not escape. counter(), чей результат сохранили в глобальную переменную, — 2 выделения, а тот же counter(), встроенный и использованный на месте, — 0. Это наблюдение компилятора go1.24.7, а не гарантия языка.
Вызов замыкания медленнее вызова обычной функции
В нашем замере — нет: 1,73 нс против 1,63 нс прямого вызова, в пределах шума. Дороже создать замыкание, которое уходит наружу: 20,82 нс и одно выделение. Это одна конфигурация (go1.24.7, linux/amd64, //go:noinline, глобальный приёмник результата) — на своей машине нужно мерить.
История версий
Прогоны урока — go1.24.7 и go1.25.1, linux/amd64; сверено 14.10.2026. На эту дату вышли Go 1.26 и Go 1.27 — урок их не измерял.
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| go 1.21 в go.mod | Переменная цикла одна на весь цикл: на компиляторе go1.24.7 три замыкания из цикла по срезу дали c c c, пример из спецификации — 6 6 6, у &i — один адрес. Проверено прогоном bench/goclosure/loopvar.sh. | значение для замыкания передают параметром или пишут s := s |
| go 1.22 в go.mod | У каждой итерации своя переменная: a b c, 1 3 5, три адреса. Решает строка go в go.mod, а не версия компилятора. | замыкание в цикле видит значение своей итерации |
| любая, for _, s = range | Переменная объявлена до цикла, цикл ей присваивает: c c c и в модуле с go 1.21, и с go 1.22. Правило о своей переменной на итерацию относится только к переменным, которые объявляет сам цикл. | одна переменная на цикл при любой строке go |
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
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 }
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())Практика · оцените
Проверка знаний
x := 1; read := func() int { return x }; x = 2. Что вернёт read()?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Функция в Go — значение, а замыкание — функциональный литерал, который ссылается на переменные объемлющей функции. У значения-функции есть тип (
func(int) int), нулевое значениеnil, его кладут в переменную, передают и возвращают. Вызовnil— паника, а сравнить две функции нельзя:f == gне собирается, сравнение допустимо только сnil. - Замыкание захватывает переменную, а не её значение. Спецификация говорит прямо: такие переменные становятся общими для функции и литерала. Поэтому замыкание из
counter()даёт1 2 3при трёх вызовах — каждый вызов меняет одну и ту жеn, — у двух замыканий из одного вызоваpair()getвидит изменения, сделанныеincr, а замыкание, созданное доx = 2, читает2. - Дальше — то, что отличает понимание от пересказа. Сколько переменных у цикла, решает строка
goвgo.mod— и только для переменной, которую объявляет сам цикл (for _, s := range): один и тот же код на одном компиляторе go1.24.7 далc c cв модуле сgo 1.21иa b cсgo 1.22, а цикл, который присваивает заранее объявленнойs(for _, s = range), далc c cв обоих модулях. Значение фиксируется только там, где язык что-то вычисляет сразу: аргумент вызова, аргументdefer, получатель значения метода —g := v.Get; v.n = 2иg()вернул1.f(s...)передаёт сам срез, а не копию: запись внутри видна снаружи. Если замыкание после оптимизаций уходит наружу, изменяемая захваченная переменная уходит вместе с ним в кучу — память, которая переживает вызов функции и освобождается сборщиком мусора: приsinkF = counter()прогон насчитал два выделения, а тот жеcounter(), встроенный и использованный на месте, — ноль. Число выделений — наблюдение компилятора в конкретном месте вызова, а не свойство функции. В нашем замере (go1.24.7, linux/amd64, лучший из 11 кругов) вызов замыкания стоил столько же, сколько прямой вызов, а создание замыкания, уходящего наружу, — в 12,8 раза больше; механизм объясняет, откуда разница, а кратность на своей машине нужно мерить.
На самом деле
- Оно ссылается на саму переменную. Замыкание, созданное при
x = 1и вызванное послеx = 2, вернуло2. Значение фиксируется только там, где язык что-то вычисляет сразу: аргумент вызова, аргументdefer, получатель значения метода. - Только для переменной, которую объявляет сам цикл (
:=), и только в модулях, чейgo.modобъявляет 1.22 и выше. Тот же цикл на компиляторе go1.24.7 в модуле сgo 1.21далc c c, и у&iза три итерации был один адрес; аfor _, s = rangeс заранее объявленнойsдалc c cи сgo 1.22. f == gне собирается:func can only be compared to nil. Через интерфейс сравнение собирается, но падает:comparing uncomparable type func(int) int.- Передаётся сам срез:
bump(s...)изменилs[0]вызывающего на100, и&xs[0] == &s[0]. Новый срез создаётся только при вызове со списком аргументов. - Если получатель — значение, нет:
vкопируется в момент взятия, и послеv.n = 2g()вернул1. С получателем-указателем копируется адрес, и изменения видны:2. - Только ушедшее наружу после оптимизаций. Замыкание, вызванное на месте, — 0 выделений,
func literal does not escape.counter(), чей результат сохранили в глобальную переменную, — 2 выделения, а тот жеcounter(), встроенный и использованный на месте, — 0. Это наблюдение компилятора go1.24.7, а не гарантия языка. - В нашем замере — нет: 1,73 нс против 1,63 нс прямого вызова, в пределах шума. Дороже создать замыкание, которое уходит наружу: 20,82 нс и одно выделение. Это одна конфигурация (go1.24.7, linux/amd64,
//go:noinline, глобальный приёмник результата) — на своей машине нужно мерить.
По версиям
- go 1.21 в go.mod
- Переменная цикла одна на весь цикл: на компиляторе go1.24.7 три замыкания из цикла по срезу дали
c c c, пример из спецификации —6 6 6, у&i— один адрес. Проверено прогономbench/goclosure/loopvar.sh.< - go 1.22 в go.mod
- У каждой итерации своя переменная:
a b c,1 3 5, три адреса. Решает строкаgoвgo.mod, а не версия компилятора.< - любая, for _, s = range
- Переменная объявлена до цикла, цикл ей присваивает:
c c cи в модуле сgo 1.21, и сgo 1.22. Правило о своей переменной на итерацию относится только к переменным, которые объявляет сам цикл.<
Что разобрано
- Что здесь на самом деле спрашивают
- База: функция — значение
- Механизм 1: замыкание захватывает переменную, а не значение
- Механизм 2: переменная цикла — одна на цикл или своя на итерацию
- Механизм 3: что вычисляется сразу — аргументы defer и значение метода
- Механизм 4: вариативный параметр — тот же срез или новый
- Механизм 5: захваченная переменная уходит в кучу вместе с замыканием
- Глубже: что такое значение-функция и сколько стоит вызов
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- История версий
- Практика
- Проверка знаний
Источники и что читать дальше
8 ИСТОЧНИКОВ
- Спецификация Go — Function types, Calls, Comparison operatorsОфициальная документация. Нулевое значение: «The value of an uninitialized variable of function type is nil» (Значение неинициализированной переменной функционального типа — nil). Вызов nil: «Calling a nil function value causes a run-time panic» (Вызов функционального значения nil вызывает панику во время выполнения). Сравнение: «Slice, map, and function types are not comparable. However, as a special case, a slice, map, or function value may be compared to the predeclared identifier nil» (Типы срезов, карт и функций несравнимы. Однако в виде особого случая значение среза, карты или функции можно сравнить с предобъявленным идентификатором nil). Сверено по doc/go_spec.html дистрибутивов go1.24.7 и go1.25.1.https://go.dev/ref/spec#Function_types
- Спецификация Go — Function literalsОфициальная документация. Определение замыкания: «Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible» (Функциональные литералы — замыкания: они могут ссылаться на переменные, определённые в объемлющей функции. Тогда эти переменные общие для объемлющей функции и литерала, и они живут, пока к ним есть доступ).https://go.dev/ref/spec#Function_literals
- Спецификация Go — For statementsОфициальная документация. Новая семантика: «Each iteration has its own separate declared variable (or variables)» (У каждой итерации своя отдельная объявленная переменная (или переменные)) — с пометкой Go 1.22. Старая: «Prior to [Go 1.22], iterations share one set of variables instead of having their own separate variables» (До [Go 1.22] итерации делят один набор переменных, а не имеют каждая свои). Пример спецификации печатает 1 3 5 и 6 6 6 соответственно; прогон
bench/goclosure/loopvar.shвоспроизводит оба вывода. Новое правило — для переменных, объявленных в заголовке через :=; про остальные (раздел For statements with range clause): «If the iteration variables are not explicitly declared by the "range" clause, they must be preexisting. In this case, the iteration values are assigned to the respective variables as in an assignment statement» (Если переменные итерации не объявлены явно в заголовке range, они должны существовать заранее. В этом случае значения итерации присваиваются им, как в операторе присваивания); раздел 3 loopvar.sh: for _, s = range дал c c c и с go 1.21, и с go 1.22. Сверено 14.10.2026.https://go.dev/ref/spec#For_clause - Спецификация Go — Passing arguments to ... parametersОфициальная документация. Вызов со списком аргументов: «the value passed is a new slice of type []T with a new underlying array whose successive elements are the actual arguments» (передаётся новый срез типа []T с новым базовым массивом, последовательные элементы которого — фактические аргументы). Вызов с s...: «If the final argument is assignable to a slice type []T and is followed by ..., it is passed unchanged as the value for a ...T parameter. In this case no new slice is created» (Если последний аргумент присваиваем срезу типа []T и за ним следует ..., он передаётся без изменений как значение параметра ...T. В этом случае новый срез не создаётся).https://go.dev/ref/spec#Passing_arguments_to_..._parameters
- Спецификация Go — Method values и Defer statementsОфициальная документация. Значение метода: «The expression x is evaluated and saved during the evaluation of the method value; the saved copy is then used as the receiver in any calls, which may be executed later» (Выражение x вычисляется и сохраняется при вычислении значения метода; сохранённая копия затем служит получателем во всех вызовах, которые могут произойти позже). Defer: «Each time a "defer" statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked» (Каждый раз при выполнении оператора defer значение функции и параметры вызова вычисляются как обычно и сохраняются заново, но сама функция не вызывается).https://go.dev/ref/spec#Method_values
- cmd/compile/internal/escape/escape.go — захват по значениюИсходный код Go. Строки 245–246 на go1.24.7 (261–262 на go1.25.1): комментарий «Capture by value for variables <= 128 bytes that are never reassigned» (захват по значению для переменных до 128 байт, которые никогда не переприсваиваются) и
n.SetByval(!loc.addrtaken && !loc.reassigned && n.Type().Size() <= 128). Строка 259 (go1.24.7) печатает при -m=2capturing by valueилиcapturing by ref— эти строки цитирует урок.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/escape/escape.go#L245-L246 - cmd/compile/abi-internal.md — значение-функция во внутреннем ABIИсходный код Go. Строки 356–359 на go1.24.7 и go1.25.1: «A func value (e.g.,
var x func()) is a pointer to a closure object. A closure object begins with a pointer-sized program counter representing the entry point of the function, followed by zero or more bytes containing the closed-over environment» (Значение-функция (например, var x func()) — указатель на объект замыкания. Объект замыкания начинается с программного счётчика размером в указатель — точки входа функции, — за которым следуют ноль или больше байт с захваченным окружением). Строка 410: на amd64 регистр контекста замыкания — RDX.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/abi-internal.md - cmd/compile/internal/typecheck/func.go и walk/closure.go — как устроено замыканиеИсходный код Go. typecheck/func.go, строка 94 на go1.24.7 и go1.25.1: замыкание создаётся как
clos = &struct{F uintptr; X0 *int; X1 *string}{func.1, &i, &s}— указатель на код и захваченные переменные. walk/closure.go, строки 15–31: прямой вызов функционального литерала переписывается в обычный вызов с захваченными переменными в аргументах — «This avoids allocation of a closure object» (так не выделяется объект замыкания). runtime/runtime2.go, строки 174–177 на go1.24.7 (173–176 на go1.25.1):type funcval struct { fn uintptr; ... }.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/typecheck/func.go#L88-L102