Вывод в Go: полный гид по форматированному выводу

Вывод в Go

Помню, когда я только начинал программировать на Go, вывод переменных был первой путаницей: где-то fmt.Println, где-то fmt.Printf, а где-то и вовсе fmt.Sprintf. Вариантов казалось много, и закономерность в голове долго не складывалась. Поэтому в этой статье я разложил весь пакет fmt по полочкам и делюсь результатом, чтобы у вас всё стало на свои места быстрее, чем у меня.

Три вопроса, которые снимают всю путаницу

Весь пакет fmt для вывода строится на пересечении двух осей.

Куда пишем результат?

  • в стандартный вывод (os.Stdout) — функции без префикса, Print...
  • в строку, которая вернёт функция — префикс S от String, Sprint...
  • в произвольный io.Writer (файл, буфер, сетевое соединение) — префикс F от слова File, Fprint...

Как форматируем аргументы?

  • через пробел, без переноса строки — суффикса нет, просто Print
  • через пробел, с переносом строки в конце — суффикс ln
  • по шаблону с плейсхолдерами %v, %d, %s — суффикс f

Если перемножить 3 варианта назначения на 3 варианта форматирования, получится 9 функций. Больше в базовом наборе fmt для вывода ничего нет, и как только эта матрица укладывается в голове, весь пакет перестаёт быть загадкой.

Таблица всех девяти комбинаций

Без переноса строки С переносом строки По шаблону
В консоль fmt.Print fmt.Println fmt.Printf
В строку fmt.Sprint fmt.Sprintln fmt.Sprintf
В io.Writer fmt.Fprint fmt.Fprintln fmt.Fprintf

Дальше — подробный разбор каждой строки, с примерами и подводными камнями.

Это самая привычная тройка, с неё начинается почти любой учебник.

fmt.Print("Привет", "мир") // Приветмир
fmt.Print("Привет", 35)  // Привет35

fmt.Println("Привет", "мир") // Привет мир 
                             // (с переносом строки в конце)
fmt.Printf("Привет, %s! Тебе %d лет.\n", "мир", 35)
// Привет, мир! Тебе 35 лет.

Все три пишут пишут в os.Stdout и возвращают (n int, err error) — количество записанных байт и ошибку. На практике эту ошибку почти никто не проверяет: os.Stdout почти никогда не отбрасывает данные при обычной работе.

Но есть случай, где эта ошибка важна. В терминале можно соединить вывод одной программы со входом другой через символ | — это и называется (pipe).

Например, команда your-tool | head -1 берёт весь вывод your-tool и передаёт его в head -1, которая печатает только первую строку и сразу завершается — читать дальше она не станет.

Если ваша утилита продолжает писать в os.Stdout после того, как head уже закрылась, система вернёт ошибку «broken pipe»: писать больше некуда, получатель отключился. Без проверки err в этот момент программа просто не узнает, что часть вывода потерялась.

Sprint, Sprintln, Sprintf — вывод в строку

Эта тройка ничего никуда не печатает. Она просто собирает результат и отдаёт его как string.

s1 := fmt.Sprint("Привет", "мир") // "Приветмир"
s2 := fmt.Sprintln("Привет", "мир") // "Привет мир\n"
s3 := fmt.Sprintf("id=%d, name=%s", 34, "Стас") // "id=34, name=Стас"

Очень часто использую Sprintf при написании кода. Например, ключ для кэша: у меня есть ID пользователя и название региона, и я хочу собрать из них строку вида user:1:region:vrn.

key := fmt.Sprintf("user:%d:region:%s", userID, region)

Sprint и Sprintln в такой задаче не годятся: они просто склеивают аргументы через пробел, и на выходе получилось бы 1 vrn без структуры и разделителей. Шаблон в Sprintf даёт полный контроль над тем, как выглядит итоговая строка, поэтому в реальном коде он почти всегда обходит Sprint по частоте использования.

Fprint, Fprintln, Fprintf — вывод в io.Writer

Это самая гибкая тройка, и часто самая недооценённая новичками. io.Writer — это интерфейс с одним методом Write, и под него подходит буквально всё: файл, bytes.Buffer, сетевое соединение, http.ResponseWriter, os.Stderr.

file, _ := os.Create("log.txt")
defer file.Close()
fmt.Fprintln(file, "Сервис запущен")

var buf bytes.Buffer
fmt.Fprintf(%buf, "user=%s, ip=%s", "admin", "192.168.1.1")

fmt.Fprintln(os.Stderr, "предупреждение: конфиг не найден")

Кстати, если вы когда-нибудь задумывались, что происходит по капотом у fmt.Println — там просто вызывается Fprintln(os.Stdout, ...). Вся консольная тройка — это тонкая обёртка над записью в os.Stdout, который тоже реализует io.Writer. Матрица из девяти функций на деле держится всего трёх реализациях, остальное — синтаксический сахар.

На практике это выглядит так: когда пишите консольную утилиту, os.Stderr для ошибок и предупреждений, os.Stdout для основного результата — это не просто вкусовщина, а конвенция Unix, которая позволяет делать your-tool 2>errors.log >result.txt и разводить потоки.

Пробелы между аргументами: где логика, а где сюрприз

Вот тот самый момент, на котором я застрял в начале. Правило звучит так: Print и Sprint ставят пробел между двумя соседними аргументами только в одном случае — когда оба этих аргумента не строки. Если хотя бы один из пары — строка, пробела не будет.

fmt.Print("a", "b") // ab  - оба строки, пробела нет
fmt.Print("a", 1)   // a1  - рядом строка, пробела нет
fmt.Print(1, 2)     // 1 2 - оба не строки, пробела есть
fmt.Print(1, "b")   // 1b  - рядом строка, пробела нет

Println и Sprintln под это правило не попадают — они ставят пробел между всеми аргументами всегда, независимо от типов, и добавляют перенос строки в конце.

Тут кроется подвох из примера в начале статьи. fmt.Sprint("a", 1, "b") даёт a1b — без единого пробела. Между "a" и 1 пробела нет, потому что рядом строка. Между 1 и "b" пробела тоже нет — по той же причине. Пробел появляется только там, где два не-строковых значения стоят рядом друг с другом. Если результат конкатенации отличается от ожидаемого — первым делом проверьте, нет ли рядом двух не-строковых аргументов.

Шпаргалка по глаголам форматирования

Раз уж заговорили про Printf-семейство, вот основные плейсхолдеры, которые реально нужны в интервью и в жизни:

Глагол Что делает Пример
%v значение в стандартном виде Printf(«%v», user)
%+v значение с именами полей структуры Printf(«%+v», user)
%#v Go-синтаксис значения Printf(«%#v», user)
%T тип значения Printf(«%T», user)
%d целое число Printf(«%d», 35)
%s строка Printf(«%s», name)
%f float с фиксированной точностью Printf(«%.2f», 3.14159)
%t булево значение Printf(«%t», true)
%p указатель (адрес) Printf(«%p», %x)
%w обёртывание ошибки (только в Errorf) Printf(«контекст: %w», err)

%+v — мой любимый глагол для дебага структур: выводит все поля с именами, и не нужно писать String() метод ради разового принта в консоль.

Errorf и вывод ошибок

Формально Errorf не входит в матрицу «вывода», потому что он ничего не печатает и не возвращает строку — он возвращает error. Но раз мы уже разбираем семейство fmt, обойти его нельзя, тем более что синтаксис у него общий с Printf.

if err != nil {
    return fmt.Errorf("не удалось прочитать конфиг: %w", err)
}

Глагол %w появился в Go 1.13 и умеет заворачивать исходную ошибку так, что её потом можно достать через erros.Unwrap, errors.Is или erros.As. Если использовать %v вместо %w, ошибка превратится в обычную строку и потеряет связь с оригиналом — это частый вопрос на собеседованиях по Go, и хорошая тема для отдельной статьи.

Когда что выбирать на практике

  • Нужно быстро что-то напечатать при отладке — fmt.Println, самый низкий порог входа.
  • Нужен предсказуемый формат вывода — fmt.Printf, явный шаблон читается лучше неявной склейки через пробел.
  • Собираете строку для дальнейшего использования (лог, ключ кэша, сообщение об ошибке) — fmt.Sprintf.
  • Пишете в файл, буфер или любой другой io.Writer — вся тройка Fprint*, особенно Fprintf для форматирования записи.
  • Пишите библиотеку и не хотите завязываться на os.Stdout — всегда принимайте io.Writer параметром и используйте Fprintf, это даёт вызывающему коду выбор, куда слать вывод.

Последний пункт — не теоретическая рекомендация. Любая функция, которая жёстко вызывает fmt.Println внутри библиотеки, потом мешает тестировать код: тесты ловят вывод через захват os.Stdout, что неудобно и хрупко. Если вместо этого функция принимает io.Writer, в тесте достаточно подставить bytes.Buffer и сравнить содержимое.

Ключевой вывод

Девять функций fmt для вывода укладываются в простую матрицу: три места назначения (консоль, строка, io.Writer) на три способа форматирования (через пробел, через пробел с переносом на новую строку, по шаблону). Как только эта матрица закрепляется в голове, выбор нужной функции перестаёт требовать подглядывания в документацию.

А как у вас — есть свой выбор привычек по выбору между Printf и Sprintf? Или свой способ ловить баги на пробелах между аргументами? Делитесь в комментариях, интересно сравнить подходы.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: