Deep Engineering
Средний·Опубликовано·55 МИН

Функции и замыкания в 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.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Функции в Go — значения первого класса? Можно ли сравнить две функции?» — разминка; вторая половина вопроса ловит чаще первой.
  2. «Что такое замыкание?» — ответ «функция, которая помнит окружение» верный, но не говорит главного: помнит переменные, а не значения.
  3. «Что напечатает этот счётчик?» (с кодом) — проверка того же на коде.
  4. «Что напечатают замыкания, созданные в цикле?» — классика, у которой с Go 1.22 два ответа, и выбирает между ними go.mod.
  5. «Когда вычисляются аргументы defer? Что сохраняет f := v.Method?» — где значение всё-таки фиксируется в момент записи.
  6. «f(s...) передаёт копию среза?» — про вариативный параметр.
  7. «Сколько стоит замыкание?» — про кучу и про то, что дорого на самом деле.

Стержень у лестницы один: замыкание захватывает переменную, а не значение; значение фиксируется только там, где язык явно что-то вычисляет — при передаче аргумента, в операторе 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.

— Спецификация Go — Function types
контракт языкаФункциональные типы, замыкания, семантика переменной цикла, вариативные параметры, значения методов и defer — часть спецификации языка; прогоны bench/goclosure их иллюстрируют, а не устанавливают.

Значит, функцию можно положить в переменную, передать параметром и вызвать через параметр:

Фрагмент bench/goclosure/internals.go (функции уровня пакета):

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 вызывает панику во время выполнения.

— Спецификация Go — Calls

Поэтому необязательный обработчик — поле 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.

— Спецификация Go — Comparison operators

Компилятор ловит это сразу, и по той же причине функция не может быть ключом карты:

Вывод 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.

перевод

Функциональные литералы — замыкания: они могут ссылаться на переменные, определённые в объемлющей функции. Тогда эти переменные общие для объемлющей функции и литерала, и они живут, пока к ним есть доступ.

— Спецификация Go — Function literals

Ключевое слово — shared, общие. Не «скопированы в замыкание», а общие. Проверка:

Фрагмент bench/goclosure/internals.go:

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 .:

GO
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)

перевод

У каждой итерации своя отдельная объявленная переменная (или переменные).

— Спецификация Go — For statements

А о прошлом говорит одной фразой: 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 (файл одинаков в обоих модулях):

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:

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, они должны существовать заранее. В этом случае значения итерации присваиваются им, как в операторе присваивания.

— Спецификация Go — For statements with range clause

Фрагмент bench/goclosure/cases/loop12x/assign.go — переменная объявлена до цикла, цикл ей присваивает:

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 значение функции и параметры вызова вычисляются как обычно и сохраняются заново, но сама функция не вызывается.

— Спецификация Go — Defer statements

Значение метода. Выражение 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 вычисляется и сохраняется при вычислении значения метода; сохранённая копия затем служит получателем во всех вызовах, которые могут произойти позже.

— Спецификация Go — Method values

Проверка обоих правил:

Фрагмент bench/goclosure/internals.go:

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 с новым базовым массивом, последовательные элементы которого — фактические аргументы.

— Спецификация Go — Passing arguments to ... parameters

Со срезом и ...:

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. В этом случае новый срез не создаётся.

— Спецификация Go — Passing arguments to ... parameters

Проверка:

Фрагмент bench/goclosure/internals.go:

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; комментарии с номерами строк добавлены здесь:

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)
деталь реализации · Go 1.24Захват по значению — оптимизация компилятора gc, а не правило языка: семантически замыкание всегда захватывает переменную. Условие — escape.go, строки 245–246 на go1.24.7 (261–262 на go1.25.1); вывод -m=2 одинаков на обеих версиях.

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() { ... }() и колбэки, сохранённые в структуры.

деталь реализации · Go 1.24Числа выделений — наблюдение компилятора gc go1.24.7 (на go1.25.1 те же) в конкретном месте вызова, а не контракт языка: спецификация говорит только о времени жизни переменных.

Что «пережило кадр» решается в месте вызова, а не в объявлении функции, видно по одному и тому же 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()» правильный ответ начинается с «где и как её вызвали»: код корректен в любом случае, а размещение и цену определяет компилятор.

Глубже: что такое значение-функция и сколько стоит вызов

деталь реализации · Go 1.24Устройство значения-функции и объекта замыкания — деталь реализации компилятора gc, а не спецификации: cmd/compile/abi-internal.md строки 356–364 и 410, typecheck/func.go строка 94, walk/closure.go строки 15–31 на go1.24.7.

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.

перевод

Объект замыкания начинается с программного счётчика размером в указатель — точки входа функции, — за которым следуют ноль или больше байт с захваченным окружением.

— cmd/compile/abi-internal.md

У обычной функции окружения нет — ноль байт. У замыкания это захваченные переменные; компилятор описывает объект в typecheck/func.go так:

Фрагмент комментария в typecheck/func.go, строка 94:

GO
//	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, go1.24.7 linux/amd64, GOMAXPROCS=2, лучший из 11 кругов testing.Benchmark на машине, которую делили с другими задачами; один вызов на итерацию, addOne и makeAdder помечены //go:noinline, результат пишется в глобальную переменную. Это одна конфигурация: на другой машине, версии и нагрузке кратности могут быть другими — их нужно мерить у себя.

Вывод 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–212,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

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

counter возвращает замыкание над своей переменной n. bump записывает 10 в xs[0]. У T метод Get с получателем-значением. Что напечатает этот код?
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())

Практика · оцените

makeAdder(k) возвращает замыкание func(x int) int { return x + k }; функция не встраивается, результат сохраняется в глобальную переменную. Во сколько раз «создать замыкание через makeAdder(i) и вызвать его» дороже прямого вызова не встраиваемой addOne(i)?
раза

Проверка знаний

Вопрос 1 из 5

x := 1; read := func() int { return x }; x = 2. Что вернёт read()?

Источники и что читать дальше

8 ИСТОЧНИКОВ

  1. Спецификация 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
  2. Спецификация 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
  3. Спецификация 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
  4. Спецификация 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
  5. Спецификация 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
  6. 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=2 capturing by value или capturing by ref — эти строки цитирует урок.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/escape/escape.go#L245-L246
  7. 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
  8. 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