JavaRush /Курсы /Go SELF /Printf-debugging и логирование

Printf-debugging и логирование

Go SELF
33 уровень , 1 лекция
Открыта

1. Введение

Когда программа ведёт себя странно, рука тянется к старому доброму «давай распечатаем переменные». И это нормально: отладка — это по сути разговор с программой, только она отвечает числами, строками и иногда паникой. Но есть нюанс: распечатка распечатке рознь. Иногда вам нужен одноразовый «щуп» в конкретной строке, а иногда — аккуратная «трасса» событий, по которой можно восстановить ход выполнения.

В этой лекции разберём два инструмента, которые внешне похожи (оба выводят текст), но решают разные задачи: printf-debugging через fmt.Printf и логирование через пакет log. И главное — научимся выбирать, что уместно в конкретный момент, чтобы не утонуть в собственных сообщениях.

2. Printf-debugging: быстрый «фонарик» в коде

Printf-debugging — это когда вы временно добавляете fmt.Printf/fmt.Println, чтобы проверить гипотезу: «какое значение переменной прямо здесь?», «в какую ветку мы зашли?», «какая длина слайса перед индексированием?». Это самый быстрый способ получить ответ, особенно если вы уже нашли проблемную строку по stack trace и теперь хотите понять предысторию.

Важно относиться к этому как к медицинскому инструменту: фонарик в горло — полезно, но жить с ним постоянно неудобно. То есть 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» опасно: в библиотечном коде это ломает управление ошибками, а в приложении может обрубать нормальную обработку (cleanup, закрытие файлов, возврат кода выхода). Держите в голове простое правило: log.Fatal — инструмент границы приложения, а не «универсальный if».

4. fmt.Printf и log.Printf на практике

Когда начинающие слышат «логирование», они думают: «ну это же тоже вывод текста». Формально да, но по смыслу — это два разных режима общения с программой.

Сравнение по критериям

Критерий
fmt.Printf
(printf-debugging)
log.Printf
(логирование)
Типичная цель Быстро проверить гипотезу Оставить след событий
Длительность жизни Обычно временно, пока ищем баг Часто остаётся в коде надолго
Куда пишет по умолчанию
stdout
stderr
Формат «из коробки» Только то, что вы написали Дата/время + ваше сообщение
«Пользовательский вывод» Часто да (в 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=купить молоко (перепутал : и =). По stack trace вы найдёте эту строку. Но дальше возникает вопрос: какая именно строка пришла сюда и как она была разобрана? Вот здесь и начинается выбор между 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("invalid title format: %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)
}

Такие сообщения легче фильтровать глазами и проще сравнивать между запусками.

И ещё одна самоироничная истина: самый страшный лог — это лог, который вы оставили «на пять минут», а через месяц он всё ещё печатается в цикле на 10000 итераций. Поэтому диагностика должна быть либо точечной, либо управляемой (например, через переменную 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: оставлять диагностику, которая раскрывает лишние данные.
Даже в учебных примерах полезно вырабатывать привычку: не печатать всё подряд (особенно если там могут быть токены, пароли, приватные данные). Сегодня мы не про безопасность, но «не логируй секреты» — хорошая привычка уровня зубной щётки: делать надо каждый день, иначе потом будет дорого.

1
Задача
Go SELF, 33 уровень, 1 лекция
Недоступна
Печать диагностики
Печать диагностики
1
Задача
Go SELF, 33 уровень, 1 лекция
Недоступна
Логи суммы
Логи суммы
1
Задача
Go SELF, 33 уровень, 1 лекция
Недоступна
Максимум с трассировкой
Максимум с трассировкой
1
Задача
Go SELF, 33 уровень, 1 лекция
Недоступна
Калькулятор без паники
Калькулятор без паники
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ