JavaRush /Курси /Go SELF /init() і побічні ефек...

init() і побічні ефекти

Go SELF
Рівень 10 , Лекція 4
Відкрита

1. init() у Go: що це і чому з ним треба бути обережними

Коли ви пишете перші програми, хочеться, щоб усе запускалося само собою: імпортували пакет — і він ніби вже підготувався, налаштувався та почав працювати. У Go для цього є спеціальна функція init(). Вона викликається автоматично, без вашої прямої участі, і спрацьовує до main(). Це зручно, але й небезпечно: init() легко перетворює програму на набір сюрпризів.

Сигнатура init() дуже сувора: жодних параметрів і жодного значення, що повертається. Тобто вона виглядає так:

package main

func init() {
	// автоматична ініціалізація
}

Саме через автоматичність до init() ставляться насторожено: вона робить роботу неявною. Це й є ключове слово лекції: неявність.

З погляду механізму init() — частина процесу ініціалізації пакетів: рантайм Go спершу ініціалізує пакет, включно з установленням глобальних змінних і викликами init(), а вже потім переходить до main().

Порядок виконання init(): проста часова шкала

Дуже хочеться думати, що Go читає файли згори вниз і робить усе рівно так, як ви це написали. Усередині одного файла часто так і здається, але на рівні пакетів і кількох файлів картина трохи складніша. Тому корисно тримати в голові просту шкалу: що виконується раніше, що пізніше і на що можна спиратися без ризику.

У спрощеному вигляді порядок такий:

  1. спочатку ініціалізуються імпортовані пакети: їхні змінні та їхні init(),
  2. потім ініціалізується поточний пакет,
  3. потім запускається 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() на цьому етапі — це маленьке налаштування, яке:

  1. не може завершитися звичайною помилкою (або падіння справді коректне),
  2. не залежить від введення користувача, файлів і мережі,
  3. не ховає бізнес-логіку запуску застосунку.

Найзрозуміліший приклад — підготовка генератора псевдовипадкових чисел для внутрішніх потреб: не для криптографії, а просто щоб згенерувати «випадковий 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 часто читається як «клас із набором статичних членів».

Чим це схоже.

  1. Синтаксис виду «імʼя.імʼя» нагадує Class.method.
    Ми пишемо fmt.Println, json.Marshal, http.ListenAndServe. У мовах із класами це дуже нагадує Console.WriteLine(...) або Math.Max(...) — виклик статичної функції.
  2. Усередині пакета можуть жити спільні речі рівня пакета.
    У пакета є функції та змінні рівня пакета. Це схоже на статичні поля та методи: одна спільна сутність на весь модуль.
  3. 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?». Краще тримати стан локально й передавати його через параметри та результати функцій — так ви самі бачите потік даних, а не шукаєте його з ліхтариком.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ