15 відібраних питань для співбесіди Middle Go backend розробників, яким потрібно пояснювати практичні компроміси, конкурентність та поведінку сервісів.
1Поясніть, чим по-різному працює nil для вказівників, зрізів, мап, каналів, функцій та інтерфейсів у Go.
У Go nil — це нульове значення для вказівників, зрізів, мап, каналів, функцій та інтерфейсів, але операції з такими значеннями nil відрізняються залежно від типу. Вказівник nil можна порівняти з nil, але його розіменування викликає panic. Зріз nil має довжину та ємність 0; по ньому можна ітерувати через range і до нього можна додавати елементи через append. З мапи nil можна читати та ітерувати через range, але запис у неї викликає panic. Надсилання в канал nil або отримання з нього блокується назавжди, а закриття каналу nil викликає panic. Виклик функції nil викликає panic. Інтерфейс є nil лише тоді, коли в нього немає динамічного типу й динамічного значення; інтерфейс, що містить типізоване значення nil, наприклад вказівник nil, сам по собі не є nil.
2Що таке порівнювані типи в Go і як правила порівнюваності впливають на ключі map, перевірку на рівність та обмеження узагальнень (generics)?
Порівнювані типи в Go — це типи, значення яких можна порівнювати за допомогою `==` та `!=`. Базові типи, вказівники, канали, інтерфейси, а також структури й масиви, чиї поля або елементи є порівнюваними, також є порівнюваними; зрізи (slices), відображення (maps) та функції не є порівнюваними, за винятком порівняння з `nil`. Ключі `map` повинні бути порівнюваними. Перевірка на рівність підпорядковується правилам порівняння типу, а порівняння інтерфейсів залежить від динамічних конкретних значень; якщо порівнюваний інтерфейс містить непорівнюване динамічне значення, під час порівняння виникає паніка (panic). В узагальненнях наперед оголошене обмеження `comparable` дозволяє порівнювати параметри типу за допомогою `==`/`!=` та використовувати їх як ключі `map`.
3Як Go представляє байти, руни та текст у кодуванні UTF-8, і чому значення len(s) може відрізнятися від кількості символів, які бачить користувач?
У Go `byte` є псевдонімом для `uint8` і представляє один сирий байт, тоді як `rune` є псевдонімом для `int32` і представляє кодову точку (code point) Unicode. Рядок (`string`) — це послідовність байтів лише для читання, зазвичай це текст у кодуванні UTF-8, але він може містити будь-які довільні байти. Функція `len(s)` повертає кількість байтів, а не рун чи видимих користувачеві символів. Індексація рядка повертає байт; ітерація по рядку за допомогою `range` декодує UTF-8 і видає індекси байтів разом із рунами. Значення `len(s)` може відрізнятися від кількості видимих символів, оскільки UTF-8 може використовувати кілька байтів на одну кодову точку, а також тому, що один видимий користувачеві символ може складатися з кількох кодових точок, як-от комбіновані знаки (combining marks) або послідовності емодзі.
4Чим псевдоніми типів (type aliases) відрізняються від визначених типів (defined types), і коли слід використовувати кожен із них?
Визначений тип (defined type), наприклад `type UserID int64`, створює новий окремий тип із базовим типом `int64`. Його не можна вільно присвоювати змінним типу `int64` без перетворення, і він може мати власні методи. Псевдонім типу (type alias), наприклад `type UserID = int64`, є просто іншою назвою для того ж самого типу, тому ідентичність типу та можливість присвоювання зберігаються. Використовуйте визначені типи для моделювання предметної області, забезпечення типобезпеки та додавання методів; псевдоніми використовуйте переважно для рефакторингу, міграції або забезпечення сумісності без введення нового типу.
5Як Go обробляє спеціальні значення чисел з рухомою комою і які підводні камені щодо перевірки на рівність мають значення у бекенд-системах?
Типи `float32` та `float64` у Go використовують поведінку у стилі стандарту IEEE-754, включаючи спеціальні значення, такі як позитивна/негативна нескінченність (infinity) та NaN (Not a Number). Для `float64` доступні такі допоміжні функції, як `math.Inf`, `math.IsInf`, `math.NaN` та `math.IsNaN`. NaN не дорівнює нічому, навіть самому собі, тому `x == x` повертає хибність, якщо `x` є NaN. Точна рівність обчислених значень з рухомою комою також є ризикованою, оскільки через округлення та обмеження точності математично рівні значення можуть відрізнятися; використовуйте допуски (tolerances), що відповідають предметній області, або уникайте використання типів з рухомою комою для точних бізнес-значень, таких як гроші. Числа з рухомою комою дозволені як ключі для мап (map), але ключі зі значенням NaN є проблематичними, оскільки пошук у мапі залежить від рівності, а NaN не вважається рівним навіть самому собі.
6Опишіть вбудовування структур (struct embedding) у Go та те, як поводяться підвищені (promoted) поля і методи.
Вбудовування структур у Go означає оголошення поля за його типом без явного вказування імені поля, наприклад `type User struct { Person }`. Вбудоване значення залишається реальним полем, доступним як `u.Person`, але його експортовані/доступні поля та методи можуть бути підвищені (promoted), щоб викликаюча сторона могла писати селектори на кшталт `u.Name` або `u.Greet()` як скорочення замість звернення через вбудоване поле. Вбудовування — це композиція, а не класичне успадкування: зовнішній тип не стає автоматично підтипом вбудованого типу. Якщо підвищені селектори конфліктують, Go не намагається вгадати; неоднозначні імена мають бути уточнені (qualified), інакше вони недоступні для вибору через зовнішнє значення.
7Опишіть, як повторне взяття зрізу та присвоєння можуть призвести до того, що кілька зрізів спільно використовують один базовий масив, і які помилки це може спричинити.
Значення зрізу — це заголовок, що вказує всередину базового масиву. Присвоєння зрізу або передача його у функцію копіює лише цей заголовок, а не елементи. Повторне взяття зрізу створює інший заголовок, що вказує на діапазон того самого базового масиву. Тому кілька зрізів можуть посилатися на одне сховище: зміна елемента через один зріз може бути видима через інший, а append до одного зрізу може перезаписати дані, видимі іншому, якщо ще є запас ємності. Серед помилок — несподівані мутації, пошкоджені результати, утримання великих базових масивів через малі підзрізи та гонки даних, коли такі спільні посилання використовують одночасно. Щоб уникнути небажаного спільного використання, зробіть захисну копію за допомогою copy або append([]T(nil), s...), або обмежте ємність повним виразом зрізу перед append.
8Поясніть концептуально зростання зрізу під час виконання `append` та вплив повторних перерозподілів пам'яті на продуктивність.
Коли `append` додає елементи до зрізу, він записує їх у наявний базовий масив (backing array), якщо зріз має достатню місткість (capacity). Якщо місткості недостатньо, Go виділяє пам'ять для більшого базового масиву, копіює наявні елементи, записує нові елементи та повертає заголовок зрізу (slice header), який вказує на нове сховище. Точна політика зростання залежить від реалізації, але концептуально місткість зростає достатньо, щоб зробити повторні виклики `append` амортизовано ефективними. Повторні перерозподіли пам'яті все одно споживають ресурси процесора на копіювання, створюють додаткові виділення пам'яті, збільшують навантаження на GC (Garbage Collector) і можуть порушити спільне використання пам'яті зі старими псевдонімами зрізу. Якщо ви знаєте очікуваний розмір, попередньо виділяйте пам'ять за допомогою `make([]T, 0, n)` при формуванні через `append` або `make([]T, n)` при заповненні за індексом, щоб зменшити кількість перерозподілів.
9Що таке патерни реєстрації під час ініціалізації в Go, і які ризики пов'язані з побічними ефектами порожнього імпорту (blank import) та глобальними реєстрами?
Патерн реєстрації під час ініціалізації полягає в тому, що пакет реєструє реалізацію у спільному реєстрі з функції `init`. Порожній імпорт (blank import), наприклад `_ "example.com/driver"`, часто використовується для імпорту пакета лише заради його побічних ефектів, змушуючи його функцію `init` виконуватися навіть тоді, коли немає посилань на жодні експортовані імена. Це поширене явище для точок розширення драйверів, кодеків, плагінів, метрик або серіалізаторів. Ризики включають приховані залежності та побічні ефекти під час запуску, глобальний змінний стан, дублювання або залежну від порядку реєстрацію, складнішу ізоляцію тестів та менш явне зв'язування залежностей. Цей підхід слід використовувати усвідомлено, чітко документувати, а його недоліки часто згладжуються за допомогою явної реєстрації, ідемпотентних та потокобезпечних реєстрів, або реєстрів, які можна впроваджувати чи скидати (injectable/resettable) для потреб тестування.
10Як defer, panic та іменовані значення, що повертаються (named return values), взаємодіють під час реалізації очищення ресурсів, яке може змінювати повернені помилки?
Відкладені функції (`defer`) виконуються після присвоєння значень, які повертаються, але до того, як функція поверне керування тому, хто її викликав (caller). Завдяки цьому відкладене замикання (closure) може зчитувати або змінювати іменовані значення, наприклад, іменовану змінну `err`. Це часто використовується для додавання помилок очищення від `Close`, `Commit` або подібних операцій до помилки, що повертається; в ідеалі зберігаючи основну помилку, а не перезаписуючи її. Під час розгортання стека (unwinding) при `panic`, відкладені функції продовжують виконуватися; відкладена функція може відновити роботу (`recover`) та встановити іменоване значення, що повертається, але це має обмежуватися навмисними межами перехоплення паніки. Будьте обережні, щоб не перекрити (shadow) іменовану змінну повернення, таку як `err`, оскільки в цьому випадку `defer` може спостерігати або змінювати зовсім іншу змінну, ніж планувалося.
11Як працюють `errors.Is`, `errors.As` та `%w` у ланцюжках обгортання помилок у Go?
`fmt.Errorf` із `%w` створює нову помилку, яка обгортає базову помилку, додаючи при цьому контекст. Обгортки надають доступ до базових помилок через функцію `Unwrap`, утворюючи ланцюжок або дерево, яке стандартна бібліотека може перевіряти. `errors.Is(err, target)` перевіряє, чи відповідає `err` або будь-яка обгорнута нею помилка цільовій помилці. `errors.As(err, &target)` перевіряє, чи можна привести `err` або будь-яку обгорнуту нею помилку до цільового типу, і зберігає відповідне значення у наданий вказівник.
12Що таке сигнальні помилки (sentinel errors) та які їхні компроміси порівняно з користувацькими типізованими помилками або багатшими доменними моделями помилок?
Сигнальна помилка (sentinel error) — це іменоване значення помилки, часто змінна на рівні пакета, наприклад `var ErrNotFound = errors.New("not found")`, яка використовується для представлення певної умови, яку викликаюча сторона може перевірити, зазвичай за допомогою `errors.Is`, якщо можливе обгортання помилок. Сигнальні помилки прості та корисні для широких стабільних категорій, але експортовані сигнальні помилки стають частиною API (Application Programming Interface) і можуть жорстко прив'язати клієнтів до конкретних значень. Користувацькі типізовані помилки можуть містити структуровані поля, і їх можна знайти за допомогою `errors.As`. Багатші доменні моделі помилок класифікують збої за типом/кодом і можуть включати безпечні повідомлення або метадані, що корисно, коли клієнтам потрібна стабільна поведінка поза межами одного фіксованого значення помилки.
13Як Go-беккенд має перетворювати внутрішні помилки на корисні відповіді для клієнта, водночас надаючи операторам придатні для діагностики сигнали?
Go-беккенд повинен перетворювати внутрішні помилки на межі застосунку або транспортного рівня на стабільні, безпечні для клієнта категорії та відповіді. Ці категорії мають відповідати належним кодам стану HTTP (HyperText Transfer Protocol) або еквівалентним статусам транспортного рівня, з безпечними повідомленнями та машинозчитуваними кодами, а не необробленими внутрішніми помилками. Оператори при цьому повинні отримувати діагностичну інформацію через структуровані журнали, трасування, метрики, ідентифікатори кореляції/запиту та збережені першопричини. Ведення журналу зазвичай найкраще виконувати один раз на межі, яка має контекст запиту, щоб уникнути як пропущених збоїв, так і зашумлених дубльованих записів.
14Як правильно реалізувати власні типи помилок у Go, і як `errors.Join` впливає на перевірку та обробку помилок у шляхах очищення (cleanup paths)?
Власний тип помилки в Go задовольняє інтерфейс `error`, реалізуючи метод `Error() string`. Він також може містити структуровані поля та реалізовувати `Unwrap() error`, щоб надати доступ до першопричини. Вибір між методами (receivers) для вказівника чи значення має значення: метод для вказівника означає, що лише `*T` задовольняє `error`, тоді як метод для значення зазвичай означає, що як `T`, так і `*T` задовольняють його. Це впливає на копіювання та на те, який тип код, що викликає функцію, повинен використовувати з `errors.As`. Функція `errors.Join` об'єднує кілька помилок в одну; `errors.Is` та `errors.As` перевіряють вкладені об'єднані помилки. Це корисно, коли потрібно повернути помилку як від основної операції, так і від операції очищення або відкладеної (deferred) операції, не втративши при цьому жодної з них.
15Як `select` працює з каналами, включно з вибором готового до виконання блоку, блоком `default` та операціями, що можна скасувати (cancellable operations)?
`select` очікує на виконання кількох операцій з каналами і запускає той `case`, де операція надсилання або отримання може бути продовжена. Якщо жоден канал не готовий, `select` блокується, хіба що існує блок `default`; `default` виконується миттєво лише тоді, коли жодна канальна операція не може продовжити роботу, що є корисним для неблокуючих спроб надсилання або отримання. Якщо готові відразу кілька варіантів, Go обирає один із них псевдовипадковим чином, а не за порядком у вихідному коді. До операцій з каналами, що можна скасувати, зазвичай додають `case`, який отримує дані з `ctx.Done()`, завдяки чому горутина може припинити очікування у разі скасування контексту або вичерпання часу очікування (timeout).