1. Вступ
Коли програма поводиться дивно, рука так і тягнеться до старого доброго: «Давайте надрукуємо змінні». І це нормально: налагодження — це, по суті, розмова з програмою, тільки відповідає вона числами, рядками й іноді панікою. Але є нюанс: друк і логування — не одне й те саме. Іноді вам потрібен одноразовий «щуп» у конкретному місці, а іноді — акуратна «траса» подій, за якою можна відновити хід виконання.
У цій лекції розберемо два інструменти, які зовні схожі, бо обидва виводять текст, але розв’язують різні задачі: printf-debugging через fmt.Printf і логування через пакет log. І головне — навчимося обирати доречний інструмент у конкретний момент, щоб не потонути у власних повідомленнях.
2. Printf-debugging: швидкий «ліхтарик» у коді
Printf-debugging — це коли ви тимчасово додаєте fmt.Printf/fmt.Println, щоб перевірити гіпотезу: «Яке значення змінної просто зараз?», «У яку гілку ми зайшли?», «Яка довжина зрізу перед доступом за індексом?». Це найшвидший спосіб отримати відповідь, особливо якщо ви вже знайшли проблемний рядок за стек-трейсом і тепер хочете зрозуміти передісторію.
Важливо ставитися до цього як до медичного інструмента: ліхтарик у горлі корисний, але жити з ним постійно незручно. Тобто print-debugging добре працює саме як тимчасова діагностика: ви додали його, перевірили, зробили висновок — і зазвичай прибрали або замінили акуратнішим рішенням.
Патерн «мітка + значення + контекст»
Щоб print-debugging справді допомагав, повідомлення має бути зрозумілим не лише «вам зараз», а й «вам за 10 хвилин, коли ви вже забудете, що саме перевіряли». Тому у хорошого діагностичного Printf майже завжди є три частини: мітка, значення, контекст. Мітка відповідає на запитання, що саме ви друкуєте, а контекст — де і в якому сценарії це відбувається.
Ось мінімальний приклад. Зверніть увагу на %T — він часто рятує, коли «здавалося, що це int, а там рядок із введення»:
package main
import "fmt"
func main() {
x := "42"
fmt.Printf("debug: x=%q type=%T\n", x, x) // debug: x="42" type=string
}
Тут %q друкує рядок у лапках — це зручно, коли у значенні є пробіли або «невидимі» символи на кшталт \n.
Де printf-debugging особливо корисний
Printf-debugging особливо корисний у точках, де програма може «тонко» помилитися: перед розіменуванням вказівника, перед індексуванням зрізу, перед важливою умовою if, перед поверненням помилки, на вході у функцію і на виході з неї. Сенс у тому, щоб друкувати не все підряд, а рівно те, що допомагає підтвердити або спростувати одну гіпотезу.
Наприклад, ви підозрюєте, що у функцію прийшов неочікуваний ввід. Тоді друкувати потрібно саме вхід:
package todo
import "fmt"
func NormalizeTitle(title string) string {
fmt.Printf("debug: NormalizeTitle input=%q\n", title)
return title // поки без логіки — нам важливий лише сам факт входу
}
Так, це примітивно — і саме в цьому сила. На етапі, коли треба зловити дивину, вам часто не потрібен ідеальний дизайн. Вам потрібна відповідь на запитання: «Що реально відбувається?».
3. Логування: коли потрібен «чорний ящик»
Логування — це коли ви виводите повідомлення не як разову перевірку, а як осмислений запис подій: «почали операцію», «прочитали дані», «створили задачу», «отримали помилку». Головна відмінність від printf-debugging у тому, що лог, у доброму сенсі, розрахований на читання пізніше: вами через годину, колегою через тиждень або вами ж, але вже в тесті.
У Go базовий інструмент для цього — пакет log. Він простий, але корисний: за замовчуванням додає дату й час, пише в stderr (це важливо) і задає єдиний стиль повідомлень. А ще логування допомагає, коли помилка «плаваюча»: в одному запуску вона проявилася, в іншому — ні. Логи дозволяють порівняти два запуски й побачити, де саме розійшлися шляхи.
Мінімальний log.Printf
Нижче — базовий приклад. Він схожий на fmt.Printf, але працює як лог: формат за замовчуванням більш «службовий».
package main
import "log"
func main() {
log.Printf("op=%s status=%s", "start", "ok") // 2026/01/16 12:34:56 op=start status=ok
}
Мітка часу — це не прикраса. Коли у вас кілька повідомлень поспіль, дата й час допомагають зрозуміти порядок і паузи між подіями.
Чому log.Fatal — не «просто зручний принт»
Важливо не переплутати: log.Printf друкує й продовжує роботу, а log.Fatal друкує і завершує програму через os.Exit(1).
У навчальних прикладах інколи показують log.Fatal(err) як простий спосіб «упасти, якщо є помилка». Наприклад, у класичному розборі помилок у Go трапляється така конструкція: якщо os.Open повернув помилку, викликаємо log.Fatal і припиняємо виконання.
Для налагодження це інколи зручно, але як звичка «скрізь ставити log.Fatal» — небезпечно: у бібліотечному коді це ламає керування помилками, а в застосунку може обірвати нормальне завершення роботи, очищення ресурсів і закриття файлів. Тримайте в голові просте правило: log.Fatal — інструмент межі застосунку, а не «універсальний if».
4. fmt.Printf і log.Printf на практиці
Коли початківці чують «логування», вони думають: «Це ж теж просто вивід тексту». Формально так, але за змістом це два різні режими спілкування з програмою.
Порівняння за критеріями
| Критерій | (printf-debugging) |
(логування) |
|---|---|---|
| Типова мета | Швидко перевірити гіпотезу | Залишити слід подій |
| Тривалість життя | Зазвичай тимчасово, поки шукаємо баг | Часто залишається в коді надовго |
| Куди пише за замовчуванням | |
|
| Формат «з коробки» | Лише те, що ви написали | Дата/час + ваше повідомлення |
| «Користувацьке виведення» | Часто так (у CLI/задачах) | Майже ніколи — це діагностика |
| Ризик зіпсувати виведення задачі чи тестів | Високий (stdout забруднюється) | Нижчий (stderr окремо) |
Практичне правило просте: якщо ваш код має друкувати користувачу строго визначене виведення, особливо в задачах і тестах, діагностичні fmt.Printf легко все зіпсують. Логи менш небезпечні, бо живуть у stderr.
stdout і stderr: навіщо розділяти
Багатьом здається, що розділення stdout/stderr — це якась «юніксова філософія». На практиці це просто спосіб не переплутати результат програми та її внутрішні коментарі.
Якщо ви друкуєте користувачу результат у stdout, то будь-який додатковий fmt.Printf("debug: ...") псує результат. А якщо діагностика йде в stderr, то результат лишається чистим. Саме тому пакет log за замовчуванням пише в stderr: він ніби каже вам «це службове».
Уявіть, суто концептуально, що хтось захоче обробити виведення вашої програми як дані. Якщо ви змішали туди дебаг, людина отримає кашу. А якщо дані йдуть у stdout, а діагностика — у stderr, усе працює передбачувано.
5. Приклад: мінітрекер задач і «дивний ввід»
Зберемо маленьку історію, дуже схожу на реальне життя: програма не завжди падає, але час від часу поводиться дивно. Ми не будемо ускладнювати архітектуру — нам важлива саме техніка діагностики. Нехай у нас є невеликий застосунок, який приймає рядок команди виду title: купити молоко і дістає заголовок задачі.
Версія з багом і типовою панікою
Пишемо наївний парсер: розділяємо за допомогою : і беремо другу частину. Якщо двокрапки не буде, станеться index out of range.
package todo
import (
"strings"
)
func ParseTitle(line string) string {
parts := strings.Split(line, ":")
return strings.TrimSpace(parts[1]) // panic, якщо ":" немає
}
Тепер уявіть, що користувач увів title=купити молоко і переплутав : зі =. За стек-трейсом ви знайдете цей рядок. Але далі виникає запитання: який саме рядок сюди потрапив і як його розібрали? Ось тут і починається вибір між fmt.Printf і log.Printf.
Швидка перевірка через fmt.Printf
Якщо ви локально відтворюєте проблему й вам потрібне лише одне спостереження, print-debugging — найшвидший крок:
package todo
import (
"fmt"
"strings"
)
func ParseTitle(line string) string {
parts := strings.Split(line, ":")
fmt.Printf("debug: line=%q parts=%v len=%d\n", line, parts, len(parts))
return strings.TrimSpace(parts[1])
}
Плюс такого підходу в тому, що ви буквально «просвічуєте» місце, де підозрюєте баг. Мінус у тому, що якщо ця функція використовується там, де stdout має бути чистим, ви раптово отримаєте debug: у відповіді користувачу або в очікуваному виведенні тесту.
Більш системно: log.Printf як слід подій
Якщо ви хочете, щоб діагностика не змішувалася з результатами програми, а в повідомленні була ще й мітка часу, беріть log. У реальності це часто зручніше вже за кілька хвилин налагодження, бо повідомлення не губляться у звичайному виведенні.
package todo
import (
"log"
"strings"
)
func ParseTitle(line string) string {
parts := strings.Split(line, ":")
log.Printf("parse_title: line=%q parts=%v len=%d", line, parts, len(parts))
return strings.TrimSpace(parts[1])
}
Зверніть увагу на формат: тут не потрібно писати «debug debug debug». Краще писати «що роблю» (parse_title) і ключові поля. Це вже схоже на короткий службовий запис.
Виправляємо баг правильно
Налагодження — це не мета, а шлях до нормальної поведінки. Правильне виправлення тут — перестати панікувати на поганому введенні й повернути помилку. І так, fmt.Errorf тут доречний: він створює помилку, яку можна друкувати й логувати, а fmt під час друку помилки використовує її Error()-рядок.
package todo
import (
"fmt"
"strings"
)
func ParseTitle(line string) (string, error) {
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
return "", fmt.Errorf("некоректний формат заголовка: %q", line)
}
return strings.TrimSpace(parts[1]), nil
}
Зауважте, наскільки це змінює налагоджувальну реальність: тепер замість паніки у нас звичайна гілка помилки, яку можна обробити акуратно.
6. Що і коли обирати: практичні правила
Головна помилка новачка — намагатися обрати «єдино правильний» інструмент назавжди. У реальності вибір залежить від стадії пошуку помилки й від того, де саме ви працюєте: у бібліотечній функції, у main, у тесті чи в короткому прототипі.
Printf-debugging доречно використовувати, коли ви вже майже знайшли проблему і вам потрібно швидко зрозуміти одне конкретне значення. Це як поставити програмі одне запитання: «А що зараз лежить у змінній parts?». Зручно, швидко, але зазвичай не хочеться залишати це назавжди, бо такі принти починають жити своїм життям і засмічують виведення.
Логування розумніше, коли ви хочете бачити хід виконання: які кроки було зроблено, з якими параметрами і на якому кроці все пішло не так. Особливо воно допомагає, коли помилка виникає час від часу: без логів ви дивитиметеся на програму як на чорний ящик, а з логами — як на пристрій із записом польоту.
Ще один критерій вибору — «хто читач». Якщо читач повідомлення — користувач, майже завжди це fmt (і ви ретельно проєктуєте текст). Якщо читач — розробник, тобто ви, це або тимчасовий fmt.Printf, або log.Printf як діагностичний слід.
7. Як писати діагностичні повідомлення
Проблема більшості діагностичних повідомлень не в тому, що вони існують, а в тому, що вони марні: x=42 без контексту, here без сенсу, start без уточнення, чого саме. Хороше повідомлення майже завжди відповідає на запитання «Що сталося?» і «З якими даними?».
Часто достатньо домовитися із собою про невеликий стиль. Наприклад: op=<операція> key=value key=value. Навіть без «структурного логування» це різко підвищує читабельність.
package main
import (
"log"
)
func main() {
userID := 7
taskTitle := "купити молоко"
log.Printf("op=create_task user_id=%d title=%q", userID, taskTitle)
}
Такі повідомлення легше читати очима й простіше порівнювати між запусками.
І ще одна самоіронічна істина: найстрашніший лог — це лог, який ви залишили «на п’ять хвилин», а за місяць він усе ще друкується в циклі на 10 000 ітерацій. Тому діагностика має бути або точковою, або керованою, наприклад через змінну debug, або явно тимчасовою.
Діагностика в тестах: t.Logf замість fmt.Println
У тестах часто хочеться друкувати «що сталося», але fmt.Println там може перемішатися з виводом тест-раннера й ускладнити читання. У тестах доречніше використовувати t.Logf, бо воно прив’язане до конкретного тесту й показується керовано, наприклад при -v.
Ключовий момент: якщо ви додали fmt.Printf у код, який викликається тестом, тест може почати «шуміти» і виглядати нестабільно, особливо якщо він перевіряє stdout. Логування в stderr зазвичай заважає менше, але все одно не варто перетворювати тести на серіал.
8. Типові помилки
Помилка №1: змішувати користувацьке виведення і діагностику в stdout.
Це класична пастка: ви додали fmt.Printf("debug: ..."), усе знайшли, а потім забули видалити. У результаті програма наче «працює», але виведення стало нечитабельним, а автоматична перевірка або інший код, який читає stdout, раптово ламається. Діагностику краще або видаляти, або переносити в log (stderr) і робити її осмисленою.
Помилка №2: писати повідомлення без міток і контексту.
Повідомлення «42» або «here» майже завжди марне, бо за хвилину ви не згадаєте, що саме це було і чому воно важливе. Навіть коротка мітка на кшталт parse_title: або len(parts)=... різко підвищує користь діагностики, особливо коли повідомлень стає більше ніж три.
Помилка №3: намагатися «логувати все підряд», особливо всередині циклів.
Коли ви друкуєте в кожній ітерації циклу, у вас виходить не діагностика, а шумова атака на власні очі. Значно ефективніше друкувати або перші кілька ітерацій, або лише аномалії, або агрегати — наприклад, підсумкові лічильники. Інакше ви тонете в тексті й перестаєте помічати реальну проблему.
Помилка №4: використовувати log.Fatal як універсальну обробку помилок.
log.Fatal справді друкує повідомлення і завершує програму, і в прикладах це інколи виглядає зручно. Але якщо ви почнете так робити «скрізь», ви позбавите код нормальної обробки помилок, і замість акуратного повернення помилки отримаєте неочікуваний вихід із програми. Як мінімум тримайте log.Fatal на межі застосунку й використовуйте його усвідомлено.
Помилка №5: залишати діагностику, яка розкриває зайві дані.
Навіть у навчальних прикладах корисно виробити звичку: не друкувати все підряд, особливо якщо там можуть бути токени, паролі чи приватні дані. Сьогодні ми не про безпеку, але «не логуй секрети» — хороша звичка рівня зубної щітки: робити треба щодня, інакше потім буде дорого.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ