1. init() у Go: що це і чому з ним треба бути обережними
Коли ви пишете перші програми, хочеться, щоб усе запускалося само собою: імпортували пакет — і він ніби вже підготувався, налаштувався та почав працювати. У Go для цього є спеціальна функція init(). Вона викликається автоматично, без вашої прямої участі, і спрацьовує до main(). Це зручно, але й небезпечно: init() легко перетворює програму на набір сюрпризів.
Сигнатура init() дуже сувора: жодних параметрів і жодного значення, що повертається. Тобто вона виглядає так:
package main
func init() {
// автоматична ініціалізація
}
Саме через автоматичність до init() ставляться насторожено: вона робить роботу неявною. Це й є ключове слово лекції: неявність.
З погляду механізму init() — частина процесу ініціалізації пакетів: рантайм Go спершу ініціалізує пакет, включно з установленням глобальних змінних і викликами init(), а вже потім переходить до main().
Порядок виконання init(): проста часова шкала
Дуже хочеться думати, що Go читає файли згори вниз і робить усе рівно так, як ви це написали. Усередині одного файла часто так і здається, але на рівні пакетів і кількох файлів картина трохи складніша. Тому корисно тримати в голові просту шкалу: що виконується раніше, що пізніше і на що можна спиратися без ризику.
У спрощеному вигляді порядок такий:
- спочатку ініціалізуються імпортовані пакети: їхні змінні та їхні init(),
- потім ініціалізується поточний пакет,
- потім запускається main() з package main.
Ось ілюстрація, навмисне груба, щоб її було легко тримати в памʼяті без конспекту:
flowchart TD
A[Імпортовані пакети] --> B[Ініціалізація змінних рівня пакета]
B --> C["Виклики init() у поточному пакеті"]
C --> D["main()"]
На старті вам важливо запамʼятати одне: init() завжди виконується перед main() (якщо пакет узагалі бере участь у збірці).
Коротка демонстрація:
package main
import "fmt"
func init() {
fmt.Println("init") // init
}
func main() {
fmt.Println("main") // main
}
Це передбачувано й корисно для розуміння, але далі починаються тонкощі.
Якщо у вас кілька init() (а Go це дозволяє), то всі вони виконаються до main(). Але ось на якому саме рядку й у якому порядку між файлами — це вже не те, на що варто спиратися під час проєктування логіки. Якщо ви будуєте програму так, що «в init() файла A обов’язково має виконатися раніше, ніж init() файла B», ви майже напевно виростите майбутній баг — просто поки що він ще маленький і милий.
Побічні ефекти й чому init() їх підсилює
Словосполучення «побічний ефект» звучить як попередження в інструкції до ліків: «можливі сонливість, раптове бажання переписати проєкт на Rust і непереносність глобальних змінних». Але по суті все простіше: побічний ефект — це коли функція робить щось окрім обчислення результату. Наприклад, пише у файл, змінює глобальну змінну, друкує на екран, реєструє обробник десь «усередині пакета».
init() майже завжди про побічні ефекти, тому що вона нічого не повертає. Вона не може сказати: «Я успішно ініціалізувалася» або «Я не змогла прочитати конфігурацію, тримайте помилку». Тому типовий стиль «зроби щось важливе, а якщо не вийшло — поверни error» у init() не вкладається.
Звідси два типові шляхи, обидва неприємні.
Перший шлях — «мовчки проковтнути проблему». Наприклад, ви намагалися підготувати значення, не вийшло, і ви залишили все як є. Потім у main() воно раптом "" або 0, а ви сидите й думаєте, хто вкрав ваші дані.
Другий шлях — «панікувати». Тобто в init() роблять panic(...), щоб програма просто впала під час запуску. Іноді це допустимо, але часто це перетворює і досвід користувача, і ваш досвід налагодження на драму без антракту.
Порівняймо два підходи на дуже простому прикладі. Припустимо, ми хочемо один раз обчислити «префікс» для виводу.
Варіант із init() (прихована ініціалізація):
package main
import "fmt"
var prefix string
func init() {
prefix = ">> "
}
func main() {
fmt.Println(prefix + "привіт") // >> привіт
}
А тепер варіант із явною ініціалізацією (одразу видно, де це відбувається):
package main
import "fmt"
func main() {
prefix := ">> "
fmt.Println(prefix + "привіт") // >> привіт
}
На такому іграшковому прикладі різниця здається кумедною. Але в реальному проєкті «префікс» швидко перетворюється на «конфігурацію», «під’єднання», «реєстрацію», «кеш», «таблицю кодувань»… І ось тут init() починає робити код «магічним».
2. Коли init() допустимий і чим замінити
Коли init() справді допустимий
Повністю забороняти init() було б дивно: у мові він є не просто так. Питання не в тому, чи використовувати його, а в тому, що саме туди класти, щоб потім не плакати.
Нормальна зона застосування init() на цьому етапі — це маленьке налаштування, яке:
- не може завершитися звичайною помилкою (або падіння справді коректне),
- не залежить від введення користувача, файлів і мережі,
- не ховає бізнес-логіку запуску застосунку.
Найзрозуміліший приклад — підготовка генератора псевдовипадкових чисел для внутрішніх потреб: не для криптографії, а просто щоб згенерувати «випадковий ID» або перемішати список.
Давайте вбудуємо це в навчальний консольний застосунок. Нехай у нас є мініпрограма, яка читає числа й друкує просту статистику — суму та максимум. Додамо «випадковий ID запуску», щоб розрізняти запуски, суто для демонстрації.
package main
import (
"fmt"
"math/rand"
"time"
)
var runID int
func init() {
runID = rand.New(rand.NewSource(time.Now().UnixNano())).Intn(1000)
}
func main() {
fmt.Println("ID запуску:", runID) // приклад: 123
}
Це допустимо як демонстрація механіки, але зверніть увагу: ми вже додали time, ми вже створили глобальну змінну і ми вже сховали сенс «звідки береться runID». Це не злочин, але це технічний борг, про який доведеться пам’ятати.
Якщо ви відчуваєте, що init() починає розростатися — з умовами, обробкою помилок, читанням звідкись, — це майже завжди знак: час зупинитися й зробити явну ініціалізацію.
Надаємо перевагу явній ініціалізації: initApp() викликається з main()
Коли програма маленька, init() здається зручним: «ну я один раз налаштую — і все». Але щойно у вас з’являється кілька кроків запуску, хочеться мати один прозорий маршрут: що відбувається спочатку, що потім, де обробити помилку і де вивести зрозуміле повідомлення.
Тому хороший навчальний підхід — і в реальних проєктах він теж трапляється часто — це зробити явну функцію ініціалізації, наприклад initApp(), і викликати її в main().
Уявімо, що наш «міністатистичний калькулятор» має мати префікс для виводу та стартовий час. Зробімо це явно:
package main
import (
"fmt"
"time"
)
func initApp() (string, time.Time) {
prefix := "stats: "
startedAt := time.Now()
return prefix, startedAt
}
func main() {
prefix, startedAt := initApp()
fmt.Println(prefix, "запущено о", startedAt.Format(time.Kitchen))
}
Так, коду стало на кілька рядків більше. Зате тепер:
- видно, що саме ініціалізується,
- видно, де це відбувається,
- можна легко додати обробку помилок через (T, error), не перетворюючи все на panic.
Якби initApp() могла помилитися, ми б зробили так:
package main
import (
"errors"
"fmt"
)
func initApp() (string, error) {
prefix := "stats: "
if prefix == "" {
return "", errors.New("порожній префікс")
}
return prefix, nil
}
func main() {
prefix, err := initApp()
if err != nil {
fmt.Println("помилка ініціалізації:", err)
return
}
fmt.Println(prefix + "готово") // stats: готово
}
Зверніть увагу: це цілком у нашому стилі в межах уже пройденого матеріалу — звичайна функція, звичайний if err != nil { ... }, жодної магії. І саме тому явна ініціалізація майже завжди виграє у init() в прикладному коді.
3. Аналогія з класами
Якщо ви прийшли в Go з Java/Kotlin/C#/C++, мозок майже автоматично намагається зіставити знайомі конструкції. І це нормально: так швидше вчитися. Найкорисніша й досить чесна аналогія на цьому етапі така: пакет у Go часто читається як «клас із набором статичних членів».
Чим це схоже.
- Синтаксис виду «імʼя.імʼя» нагадує Class.method.
Ми пишемо fmt.Println, json.Marshal, http.ListenAndServe. У мовах із класами це дуже нагадує Console.WriteLine(...) або Math.Max(...) — виклик статичної функції. - Усередині пакета можуть жити спільні речі рівня пакета.
У пакета є функції та змінні рівня пакета. Це схоже на статичні поля та методи: одна спільна сутність на весь модуль. - init() нагадує статичний конструктор.
У Java можна зустріти static { ... }, у C# — статичний конструктор, у Kotlin — init { ... } у object/class. Сенс схожий: щойно модуль або клас підключили — щось автоматично виконалося.
Ось де важлива межа: це аналогія для читання коду, але не буквальна рівність.
Головна відмінність: пакет — не клас
У пакета немає екземплярів, немає this, немає «обʼєкта пакета». Пакет — це насамперед:
- простір імен — група пов’язаних імен,
- межа видимості — що видно всередині пакета і що експортується назовні,
- межа ініціалізації — саме на рівні пакетів працює init().
А «справжня ООП-поведінка» в Go з’являється не через пакети, а через типи (struct) і їхні методи. Тому правильна ментальна модель така:
- pkg.Name — це «ось публічна річ із простору імен pkg»;
- методи «як у класах» — це не pkg.method, а value.Method() (і ми до цього дійдемо в темі методів).
Тобто пакет допомагає організувати код, але «обʼєктність» у Go живе в типах, а не в пакетах.
Чому саме в темі init() ця аналогія особливо корисна
Тому що init() — це той момент, де новачок з ООП-мов очікує звичної поведінки: «якщо я імпортував, значить пакет сам усе налаштував — і це нормально». І так, Go справді це дозволяє. Але тут же з’являється ключова відмінність Go-стилю: у Go не люблять приховану магію запуску, бо вона робить поведінку менш передбачуваною.
Уявіть два світи:
- Світ «як у підручнику з ООП»: ви бачите Class.doSomething(), і десь «усередині» клас сам себе підготував — і це часто сприймається як норма.
- Світ Go: коли ви бачите import "pkg", ви хочете бути впевненими, що імпорт означає «я використовую імена цього пакета», а не «я запускаю приховану логіку».
init() якраз порушує це очікування: сам факт імпорту може змінювати поведінку програми. І це починає бути проблемою, коли проєкт трохи виростає.
init() як статичний конструктор: схожість і небезпека
Якщо тримати аналогію, то init() — це щось на кшталт «static init», але з підсилювачем побічних ефектів:
- воно запускається автоматично,
- запускається до main,
- запускається у момент ініціалізації пакета (а пакет може бути імпортований «просто тому, що залежність підтягнулася»).
І ось чому Go-розробники ставляться до цього обережно: якщо ви робите важливу роботу в init(), то ви створюєте прихований сценарій запуску, який не видно в main().
4. Типові помилки під час роботи з init() і побічними ефектами
Помилка №1: робити в init() «головну логіку запуску».
Іноді новачки — і, якщо чесно, навіть досвідчені розробники, коли втомилися, — починають тягнути в init() усе підряд: і друк привітання, і налаштування змінних, і читання даних, і навіть мініалгоритми. У підсумку main() стає порожнім «перехідником», а зрозуміти, що відбувається під час старту, можна лише розкопавши весь пакет. Набагато надійніше тримати важливу послідовність кроків запуску в main() і викликати явні функції ініціалізації.
Помилка №2: намагатися обробляти помилки в init() як у звичайній функції.
init() не повертає error, тому люди або ігнорують помилку й отримують дивні нулі та порожні рядки, або влаштовують panic на рівному місці. Якщо ініціалізація може не вдатися, краще зробити initApp() (T, error) і обробити err звичним if err != nil { ... }, щоб програма завершувалася зрозуміло й передбачувано.
Помилка №3: залежати від порядку init() у різних файлах одного пакета.
Навіть якщо «у мене працює», це не означає, що це хороший контракт. Порядок ініціалізації між файлами — не те, на чому варто будувати логіку програми. Якщо вам потрібен порядок, зробіть його явним: викличте initA(), потім initB() з одного місця, і тоді порядок буде рівно таким, як ви написали.
Помилка №4: використовувати порожній імпорт без розуміння, який саме ефект ви хочете.
import _ "..." виглядає маленьким, але він може ввімкнути великий і прихований механізм. Якщо ви не можете в двох реченнях пояснити, що саме дає цей імпорт, значить, ви поки що не повинні його писати. Для навчальних задач і простих застосунків майже завжди достатньо звичайних імпортів і явної ініціалізації.
Помилка №5: зберігати змінюваний стан на рівні пакета, бо «так зручно для init».
Часто init() тягне за собою глобальні змінні: «у init() я заповнюю var cfg». Потім cfg змінюють із різних функцій, і програма стає схожою на детектив: «хто змінив cfg.Timeout і чому він тепер 0?». Краще тримати стан локально й передавати його через параметри та результати функцій — так ви самі бачите потік даних, а не шукаєте його з ліхтариком.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ