1. Ресурс у Go: будь-який сценарій «взяв → поверни»
Коли кажуть «ресурс», зазвичай уявляється файл на диску. Але на практиці ресурс — це будь-яка річ, для якої існує договір володіння: ви щось «взяли», і доки не «повернули», воно вважається зайнятим, заблокованим або споживає обмежений ліміт. Якщо про це забути, система може поводитися дивно, гальмувати або просто впасти в найбільш незручний момент — зазвичай просто перед дедлайном.
У Go до ресурсів часто належать файлові дескриптори (їх потрібно Close()), мережеві з’єднання (також Close()), блокування (Unlock()), а інколи навіть «тимчасова оренда» чогось у пам’яті. Загальна ідея одна: ресурс легко отримати, але закрити або звільнити його потрібно гарантовано, навіть якщо посеред функції ви зробили return через помилку.
Тут defer працює як стікер на моніторі: «Не забудь вимкнути світло, коли підеш».
2. Базовий протокол: отримав → перевірив err → поставив defer
Головна пастка новачка в тому, що defer виглядає як «щось, що я напишу потім». А правильна звичка — навпаки: щойно ви успішно отримали ресурс, одразу ставте defer на його звільнення, поки ще пам’ятаєте, що ресурс узагалі з’явився.
Є майже ритуальна послідовність:
- отримати ресурс і помилку
- якщо є помилка — вийти
- якщо все добре — поставити defer на звільнення
- продовжувати основну роботу
Мініскелет — абстрактний, щоб усе краще склалося в голові:
package main
import "fmt"
func doSomething() (err error) {
res, err := acquire()
if err != nil {
return err
}
defer release(res)
fmt.Println("work with resource")
return nil
}
func acquire() (int, error) { return 1, nil }
func release(_ int) {}
func main() {
_ = doSomething()
}
Зверніть увагу на важливий психологічний момент: defer ставиться не в кінці, а поруч із місцем, де ресурс зʼявився.
3. Close: чому забути закрити — справді проблема
Файли, з’єднання та інші речі, які мають метод Close(), у Go зазвичай повертають error. Навіть якщо ви ще не заглиблювалися у файлову систему, сам принцип уже потрібен: відкрив — закрий.
Покажу два варіанти — ненадійний і нормальний.
Ненадійний (приклад навчальний — не копіюйте так у життя):
package main
import (
"fmt"
"os"
)
func readFirstByte(path string) (byte, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
var buf [1]byte
_, err = f.Read(buf[:])
f.Close() // якщо раніше станеться return, файл не закриється
if err != nil {
return 0, err
}
return buf[0], nil
}
func main() {
b, err := readFirstByte("data.txt")
fmt.Println(b, err)
}
Нормальний варіант — закриття гарантовано відбудеться при будь-якому виході з функції:
package main
import (
"fmt"
"os"
)
func readFirstByte(path string) (byte, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
var buf [1]byte
_, err = f.Read(buf[:])
if err != nil {
return 0, err
}
return buf[0], nil
}
func main() {
b, err := readFirstByte("data.txt")
fmt.Println(b, err)
}
Модель мислення тут дуже проста: defer f.Close() перетворює «мені треба пам’ятати про закриття» на «закриється сам при виході».
4. Unlock: той самий ресурс, тільки про «доступ»
Коли ви бачите Lock(), думайте: «я взяв монопольний доступ». Поки ви не зробите Unlock(), інші частини програми — або інші горутини, але до них ми поки не заглиблюємося — можуть чекати. Навіть якщо ви ще не пишете конкурентний код, сам патерн Lock/Unlock корисно розуміти як приклад ресурсу, який не можна забувати звільняти.
Класичний патерн виглядає так:
mu.Lock()
defer mu.Unlock()
Зробімо мінімальний приклад. Тут конкуренції немає, але патерн читається природно:
package main
import (
"fmt"
"sync"
)
var mu sync.Mutex
var counter int
func incSafely() {
mu.Lock()
defer mu.Unlock()
counter++
fmt.Println("counter =", counter) // counter = 1 (потім 2, 3...)
}
func main() {
incSafely()
incSafely()
}
Чому defer доречний саме тут? Бо додайте в середину функції три перевірки, два ранні return, і шанс забути Unlock() різко зросте. defer прибирає цей ризик.
5. Помилки під час звільнення ресурсів: що робити, якщо Close повернув помилку
Тепер найцікавіше — і, мабуть, найпрактичніше: інколи звільнення ресурсу саме може завершитися помилкою.
mu.Unlock() помилок не повертає — тут обговорювати нічого. А от Close() дуже часто повертає error. І це не формальність: наприклад, під час запису даних помилка може спливти саме в момент закриття — буфер дописується, система нарешті каже: «Ой, місця немає», — і все.
Проблема в тому, що defer f.Close() виглядає гарно, але куди подіти помилку з Close()?
Є кілька стратегій, і важливо розуміти їхній сенс, а не заучувати «магічний рядок».
Проста стратегія: ігнорувати помилку Close
Іноді це допустимо. Наприклад, ви читали файл, а не писали його, і закриття майже ніколи не впливає на коректність результату. Тоді можна чесно зробити так:
defer func() { _ = f.Close() }()
Але важливо: це усвідомлене рішення. Не «я не знав, що робити, тож _ =».
Практична стратегія: якщо основної помилки немає — повернути помилку Close
Тобто Close() стає «помилкою за замовчуванням», але не перекриває основну.
Для цього зручно використовувати іменований err, тому що defer може змінювати іменований результат прямо перед поверненням.
Приклад:
package main
import (
"fmt"
"os"
)
func readSomething(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// Уявімо, що тут робота, яка може повернути помилку
return nil
}
func main() {
fmt.Println(readSomething("data.txt"))
}
Ідея проста: якщо все було добре, але закриття не вдалося, ми хочемо про це повідомити.
«Чесна» стратегія: повернути обидві помилки через errors.Join
Якщо у вас уже є помилка з основної роботи, а Close() теж не спрацював, вибір «яку помилку залишити» завжди трохи сумний. errors.Join дозволяє не обирати: повертаємо обидві. Це дуже в дусі Go: не ховаємо проблеми під килим.
package main
import (
"errors"
"fmt"
"os"
)
func doWork(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
}()
// Нехай основна робота «падає»
return errors.New("work failed")
}
func main() {
fmt.Println(doWork("data.txt"))
}
Тут errors.Join(err, cerr) акуратно обробить навіть випадок, коли err == nil (тоді повернеться просто cerr). Це зручно: можна не писати зайві if.
Як обрати стратегію: короткий орієнтир
Коли ви в реальному проєкті вирішуєте, що робити з помилкою Close, корисно мати в голові не «рецепт», а критерій. Тримайте компактну таблицю-орієнтир.
| Ситуація | Що зазвичай робимо | Чому |
|---|---|---|
| Читали дані, результат уже отримано, Close() майже не впливає | Іноді ігноруємо Close() | Помилка закриття рідко важлива для логіки читання |
| Писали дані, і успішність операції важлива | Помилку Close() не можна втрачати | Помилка може означати, що дані не збереглися |
| Є помилка основної роботи і є помилка Close() | Часто errors.Join | Щоб не «обирати, кого втратити» |
Чому deferred-cleanup має бути «коротким і нудним»
Іноді в defer хочеться засунути половину програми: логування, ретраї, «а давай ще тут перевіримо…». Не треба.
defer добрий як невелика гарантія: закрити, розблокувати, відпустити. Чим складніший cleanup, тим вищий шанс, що він сам почне падати непередбачувано, і ви отримаєте «помилку під час обробки помилки під час закриття обробки помилки».
Хороша звичка: deferred-код має бути коротким, зрозумілим і не змінювати зовнішній стан без крайньої потреби. Якщо потрібно багато логіки — краще винести її в окрему функцію і викликати через defer (і все одно тримати її простою).
6. Defer у циклі: «не баг, але пастка»
Дуже типовий сюрприз: людина пише defer усередині циклу й думає, що закриття відбудеться «в кінці ітерації». Ні: закриття відбудеться в кінці функції.
Дивіться:
package main
import "fmt"
func demo() {
for i := 0; i < 3; i++ {
defer fmt.Println("close resource", i)
fmt.Println("work", i)
}
}
func main() {
demo()
}
// work 0
// work 1
// work 2
// close resource 2
// close resource 1
// close resource 0
Іноді це нормально (наприклад, ви справді хочете закрити все наприкінці). Але якщо у циклі ви відкриваєте багато ресурсів, defer усередині циклу може призвести до того, що ресурси лишатимуться відкритими надто довго.
Якщо вам потрібно закривати все наприкінці кожної ітерації, зазвичай роблять так: виносять тіло ітерації в окрему функцію і ставлять defer всередині неї — тоді «кінець ітерації» збігається з «виходом із функції».
package main
import "fmt"
func oneIteration(i int) {
defer fmt.Println("cleanup", i)
fmt.Println("work", i)
}
func main() {
for i := 0; i < 3; i++ {
oneIteration(i)
}
}
// work 0
// cleanup 0
// work 1
// cleanup 1
// work 2
// cleanup 2
7. Приклад: експорт звіту з коректним defer і Close
Уявімо, що наш навчальний консольний застосунок уже вміє тримати список задач у пам’яті (наприклад, []string) і друкувати їх. Додамо експорт звіту у файл як навчальну ілюстрацію володіння ресурсом і коректного закриття. Ми не заглиблюємося у файлову систему — нас цікавить тільки патерн: відкрив → defer → обробив помилку Close().
Функція експорту (зверніть увагу на іменований err і errors.Join):
package main
import (
"errors"
"fmt"
"os"
)
func exportTasks(path string, tasks []string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create report %q: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, fmt.Errorf("close report %q: %w", path, cerr))
}
}()
for i, t := range tasks {
_, werr := fmt.Fprintf(f, "%d) %s\n", i+1, t)
if werr != nil {
return fmt.Errorf("write report %q: %w", path, werr)
}
}
return nil
}
А ось мінімальний main, який викликає експорт:
package main
import "fmt"
func main() {
tasks := []string{"купити молоко", "прочитати про defer", "не панікувати"}
err := exportTasks("tasks.txt", tasks)
fmt.Println("export err:", err) // export err: <nil> (якщо все добре)
}
Чому цей приклад особливо вдалий саме для сьогоднішньої теми:
Він показує, що звільнення ресурсу — це частина контракту функції. Ми не сподіваємося, що ОС сама «якось закриє файл». Ми закриваємо його гарантовано і не втрачаємо помилку закриття, якщо вона трапилася.
8. Типові помилки
Помилка №1: ставити defer до перевірки err.
Дуже поширений баг виглядає так: ви відкрили ресурс, не перевірили помилку, поставили defer, а потім з’ясувалося, що ресурсу насправді немає. У найкращому разі це призведе до паніки або помилки «nil pointer», у гіршому — до дивної поведінки. Правильний порядок майже завжди один: спочатку if err != nil { return ... }, і лише потім defer Close/Unlock.
Помилка №2: думати, що defer у циклі спрацьовує «в кінці ітерації».
defer прив’язаний до виходу з функції, а не до блоку циклу. Тому у великих циклах легко випадково накопичити сотні відкладених закриттів і тримати ресурси відкритими набагато довше, ніж ви очікували. Якщо потрібен ресурс на одну ітерацію, виносьте ітерацію в окрему функцію.
Помилка №3: втрачати помилку Close() «за звичкою».
Коли ви записуєте дані, помилка Close() може бути реальним сигналом, що запис не завершився коректно. Ігнорувати її — все одно що сказати користувачу «файл збережено», навіть не перевіривши. Якщо операція важлива, використовуйте іменований err та акуратно додавайте помилку закриття через errors.Join або хоча б повертайте cerr, коли основної помилки немає.
Помилка №4: у defer робити «половину бізнес-логіки» і намагатися продовжувати роботу після критичного збою.
defer — не місце для складних сценаріїв і точно не місце, де ви «лагодите все на ходу». Cleanup має бути передбачуваним і коротким: закрити, розблокувати, звільнити. Якщо cleanup падає — ви зазвичай повідомляєте про це назовні через err, а не намагаєтеся «продовжити, ніби нічого не сталося».
Помилка №5: забувати, що Unlock() — це теж звільнення ресурсу.
Іноді люди ставляться до Unlock() як до «необов’язкової дужки», особливо якщо поки що не використовують горутини. Але блокування — це ресурс доступу. Не звільнили — значить, хтось потім чекатиме. Патерн Lock(); defer Unlock() — один із найчитабельніших і найбезпечніших способів писати захищену ділянку коду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ