сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Ошибки и рантайм Go

defer в Go

defer: порядок и момент вычисления

Открыл файл - тут же напиши defer f.Close(), и освобождение больше не потеряется ни на одном из семи путей выхода из функции. Механизм на вид тривиальный, но у него три особенности, на которых регулярно ломаются: порядок выполнения, момент вычисления аргументов и доступ к именованным результатам.

Стержень: отложенные вызовы выполняются в обратном порядке, аргументы вычисляются в момент объявления, а замыкание видит переменные в момент выполнения.

// Формулировки: «в каком порядке выполнятся defer?», «что напечатает defer, если переменную изменить после него?», «зачем defer в цикле - плохо?»

Порядок обратный, и это логично

Отложенные вызовы складываются в стек и выполняются в обратном порядке. Проверено на цикле из трёх итераций: печатается 2, 1, 0.

Порядок не прихоть: ресурсы обычно захватывают вложенно. Открыл соединение, начал транзакцию, взял блокировку - освобождать надо в обратной последовательности, иначе получится, что транзакция закрывается после соединения.

Отложенный вызов сработает при любом выходе из функции: обычный return, ранний return из проверки ошибки, паника. Именно поэтому его ставят сразу после успешного захвата ресурса, а не в конце функции.

// Тонкость: defer привязан к функции, а не к блоку. Отложенный вызов внутри if или for выполнится не по выходу из блока, а по выходу из всей функции - об этом следующая карточка.

Момент вычисления: аргументы против замыкания

Аргументы отложенного вызова вычисляются в момент объявления defer, а не в момент выполнения. Проверено: переменная равна 1, ставим defer с ней, потом меняем на 99 - печатается 1. Логика в том, что defer запоминает конкретный вызов с конкретными значениями.

Замыкание ведёт себя противоположно. defer func() { fmt.Println(x) }() печатает 99: тело выполняется в конце, и переменная к тому моменту уже другая. Разница - в том, что именно отложено: готовый вызов с вычисленными аргументами или функция, которая всё вычислит потом.

Из этой пары складываются обе типичные ошибки. Хотел замерить длительность, а записал время начала как аргумент. Или наоборот - рассчитывал на зафиксированное значение, а замыкание подставило последнее.

// Мнемоника простая: defer со скобками аргументов - снимок сейчас, defer с телом функции - взгляд потом.

x := 1
defer fmt.Println("аргумент:", x)      // напечатает 1 - снимок в момент defer
defer func() { fmt.Println("замыкание:", x) }()  // напечатает 99
x = 99

Именованный результат и правильный Close

Отложенная функция видит именованные результаты и может их менять. Проверено: функция возвращает 1 и nil, а defer подменяет их на 42 и ошибку - наружу уходит именно подменённое.

Это единственный способ поймать ошибку закрытия. При записи в файл Close возвращает ошибку - именно там всплывают проблемы сброса буфера, и потерять её означает потерять данные молча. Приём: именованный результат err и defer, который подставляет ошибку закрытия, если основной ошибки не было.

На чтении Close обычно игнорируют осознанно, и это нормально: терять там нечего. Разница между «игнорирую, потому что подумал» и «игнорирую, потому что не знал» - один комментарий в коде.

// Другая сторона той же медали: не пиши в defer то, что молча меняет результат неочевидным образом. Отложенная функция, переписывающая ошибку без объяснения, - находка для того, кто будет отлаживать это через год.

func write(name string) (err error) {
    f, err := os.Create(name)
    if err != nil { return err }
    defer func() {
        if cerr := f.Close(); err == nil {
            err = cerr        // не теряем ошибку закрытия
        }
    }()
    _, err = f.WriteString("данные")
    return err
}

Цикл и os.Exit

defer выполняется по выходу из функции, а не из итерации цикла. Значит defer f.Close() внутри цикла по десяти тысячам файлов означает десять тысяч открытых дескрипторов до конца функции - и почти наверняка ошибку «too many open files».

Лечение стандартное: тело итерации выносят в отдельную функцию, тогда defer срабатывает на каждой. Либо закрывают явно в конце итерации, без defer.

Второе исключение - os.Exit. Он завершает процесс немедленно, отложенные вызовы не выполняются вообще. То же самое с log.Fatal, который внутри вызывает os.Exit: файлы не закроются, буферы не сбросятся, временные каталоги останутся.

// Поэтому log.Fatal уместен разве что в main при старте. В библиотечном коде он превращает любую ошибку в мгновенную смерть процесса мимо всей логики завершения, которую кто-то старательно писал.

Как отвечать: «Что напечатает defer, если переменную изменить после него?»

Зависит от того, что именно отложено. Если это вызов с аргументом - defer fmt.Println(x) - аргумент вычисляется в момент объявления, то есть будет напечатано старое значение. Я проверял: x равен 1, ставим defer, меняем на 99, печатается 1. Если же отложено замыкание - defer func() { fmt.Println(x) }() - тело выполнится в конце функции и увидит 99. Мнемоника: со скобками аргументов это снимок сейчас, с телом функции - взгляд потом. Раз уж речь о defer, назову ещё три вещи, которые обычно спрашивают следом. Порядок обратный, стеком: три defer в цикле напечатают 2, 1, 0 - и это правильно, потому что ресурсы захватывают вложенно. Отложенная функция видит именованные результаты и может их подменить - на этом строится правильное закрытие файла на запись, где нельзя потерять ошибку Close. И defer привязан к функции, а не к блоку, поэтому defer внутри длинного цикла копит дескрипторы до конца функции; лечится выносом тела итерации в отдельную функцию.

Ответ разводит два случая с проверенными значениями, даёт мнемонику и добавляет три смежных факта, которые всё равно спросят следующими.

На чём валят

  • Ждут прямого порядка выполнения отложенных вызовов - он обратный.
  • Путают снимок аргумента и замыкание: одно печатает старое значение, другое новое.
  • Ставят defer Close внутри цикла и упираются в лимит открытых файлов.
  • Теряют ошибку Close при записи, потому что не используют именованный результат.
  • Рассчитывают на defer после os.Exit или log.Fatal - они не выполняются вовсе.
  • Ставят defer до проверки ошибки открытия и закрывают то, что не открылось.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.

  1. #go_defer1 / 5
    i := 1; defer fmt.Println(i); i = 99. Что напечатает этот defer при выходе?
    A)1 — аргументы defer вычисляются в момент объявления
    B)99 — defer видит финальное значение переменной при выходе из функции
    C)Оба значения по очереди: сперва 1, затем 99
    D)Ничего: defer с изменённой переменной молча пропускается
    показать ответ и разбор
    +A)1 — аргументы defer вычисляются в момент объявления

    // разбор: Аргументы отложенного вызова вычисляются В МОМЕНТ выполнения оператора defer, а не когда defer сработает. Поэтому fmt.Println(i) «замораживает» i=1, и последующее i=99 на вывод не влияет — напечатает 1. Нужно позднее значение — оборачивают в замыкание: defer func(){ fmt.Println(i) }(). Спот-чек: напечатало 1.

  2. #go_defer2 / 5
    func f() (n int) { defer func(){ n *= 2 }(); n = 5; return n }. Что вернёт f()?
    A)5 — оператор return зафиксировал значение ещё до выполнения defer
    B)10 — defer через замыкание правит именованный результат после return
    C)0 — defer сбрасывает результат к нулевому значению
    D)Ошибка компиляции: изменять именованный результат в defer запрещено
    показать ответ и разбор
    +B)10 — defer через замыкание правит именованный результат после return

    // разбор: return n у функции с ИМЕНОВАННЫМ результатом сначала присваивает n возвращаемое значение, затем выполняются defer'ы — и defer через замыкание видит и меняет n (n *= 2 → 10) уже после return, но до фактической отдачи вызывающему. Поэтому f() вернёт 10. Этим, например, дополняют возвращаемую ошибку в defer. Спот-чек: 10.

  3. #go_defer3 / 5
    В цикле по тысяче файлов внутри одной функции пишем defer f.Close() после открытия. Что не так?
    A)Ничего: defer закрывает файл сразу в конце каждой итерации цикла
    B)Close вызовется дважды на файл — в defer и позже при сборке мусора
    C)Компилятор запретит оператор defer внутри тела цикла
    D)Все Close отложатся до конца ФУНКЦИИ — тысяча дескрипторов будет висеть
    показать ответ и разбор
    +D)Все Close отложатся до конца ФУНКЦИИ — тысяча дескрипторов будет висеть

    // разбор: defer привязан к выходу из ФУНКЦИИ, а не из итерации цикла. Тысяча defer f.Close() накопится и выполнится разом в конце функции — всё это время дескрипторы открыты, можно упереться в лимит ОС (too many open files). Лечение: вынести тело в отдельную функцию (тогда defer сработает на каждой итерации) или закрывать явно в конце итерации, без defer.

  4. #go_defer4 / 5
    Выполнится ли отложенный вызов, если функция завершается паникой?
    A)Нет, если паника произошла до того, как выполнилась строка с defer
    B)Нет: паника прерывает функцию немедленно, минуя отложенные вызовы
    C)Да, но только если в этой же функции вызван recover
    D)Да: defer срабатывает и при обычном возврате, и при раскрутке из-за паники
    показать ответ и разбор
    +D)Да: defer срабатывает и при обычном возврате, и при раскрутке из-за паники

    // разбор: Паника раскручивает стек, вызывая отложенные функции каждого кадра, — на этом и держится recover, который иначе негде было бы вызвать. Именно поэтому освобождение ресурсов пишут через defer: файл закроется и при обычном возврате, и при аварии. Не выполнится defer только в одном случае: если до строки с ним управление не дошло.

  5. #go_defer5 / 5
    Отменяет ли os.Exit(1) выполнение отложенных вызовов?
    A)Нет: перед завершением рантайм выполнит все зарегистрированные defer
    B)Да: процесс завершается сразу, отложенные вызовы не выполняются
    C)Нет: defer выполнятся, но только в главной горутине программы
    D)Да, но буферы записи будут сброшены на диск автоматически
    показать ответ и разбор
    +B)Да: процесс завершается сразу, отложенные вызовы не выполняются

    // разбор: os.Exit обрывает процесс немедленно: ни defer, ни сброс буферов не происходят. Отсюда типичная потеря данных, когда файл писали через буферизованный writer и вышли по ошибке — содержимое не доехало на диск. Идиома простая: из main возвращаются нормально, а завершение с кодом делают в тонкой обёртке, где никаких defer уже нет.

дальше

Теорию прочитали. Навык ставится повторением

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