1. Диагностика строк: %q, байты и руны
Когда мы выводим строку обычным fmt.Println(s), мы видим то, что похоже на текст. Но компьютер живёт не в мире “похожести”, а в мире точных байтов. Ввод пользователя может содержать перевод строки, табуляцию, возврат каретки "\r" (привет Windows), несколько пробелов, невидимые пробелы — и всё это делает строки разными, хотя для человеческого глаза они одинаковые.
Представьте, что вы проверяете команду так:
if cmd == "add" { ... }
А на самом деле cmd — это "add\r" или "add " или "add\t". Вы глазами видите "add", а программа видит “другое слово”. И она права — у неё просто плохой характер, но хорошая память.
Чтобы перестать угадывать, мы будем диагностировать строку, а не “смотреть на неё”.
Мини‑приложение для экспериментов
Чтобы все примеры были связаны, будем развивать мини‑приложение: простейший CLI‑скрипт, который читает одну строку команды и одну строку текста задачи. Никаких файлов и сложной архитектуры — только ввод, чистка и понятный вывод. Сейчас наша цель не “сделать идеальный todo”, а научиться ловить грязный ввод так, чтобы не тратить вечер на поиски невидимого "\r".
Для начала набросаем скелет программы, который читает команду целиком (с пробелами) и печатает её как есть:
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
r := bufio.NewReader(os.Stdin)
cmd, _ := r.ReadString('\n')
fmt.Println("command:", cmd) // command: add (но тут может быть перенос строки)
}
Обратите внимание: сейчас мы намеренно пока не обрабатываем ошибку и не чистим строку. Мы хотим увидеть, как выглядит “сырой” ввод, а потом научиться его приводить в порядок.
Главный инструмент: печать через %q
Обычно новичок печатает строку через Println и верит тому, что увидел. Это как проверять качество воды по вкусу: иногда сработает, но в целом сомнительно. Формат %q в fmt.Printf — это наш “рентген”: он печатает строку в кавычках и показывает escape‑последовательности явно. То есть вместо реального перевода строки вы увидите "\n", вместо таба — "\t", вместо кавычки — "\"".
Сравним вывод:
package main
import "fmt"
func main() {
s := "add\r\n"
fmt.Println(s) // add
fmt.Printf("%q\n", s) // "add\r\n"
}
С Println у вас будет иллюзия “всё нормально”. С %q становится видно: в строке два управляющих символа.
Мини‑таблица: что чаще всего “прячется” в строках
Чтобы не держать в голове всю вселенную Unicode, достаточно узнавать несколько “вредителей” по имени:
| Что это | Как выглядит в %q | Зачем важно |
|---|---|---|
| Перевод строки | |
Часто остаётся после чтения строки |
| Возврат каретки | |
Типичен в Windows‑строках |
| Табуляция | |
Может быть вместо пробелов |
| Кавычка | |
Внутри данных, CSV/JSON и т.д. |
| Обратный слэш | |
Пути, регулярные выражения, экранирование |
И да: %q полезен не только для диагностики, но и для логов. Когда вы пишете “получили значение X”, очень хочется видеть, что это именно X, а не X плюс два пробела и один "\r" “в подарок”.
Экранирование: "\n" — не две буквы
На этом месте часто происходит путаница: новичок видит в коде "\n" и думает, что это два символа — \ и n. В строковом литерале в двойных кавычках "\n" означает один управляющий символ “перевод строки”. То есть длина такой строки — 1 байт.
Проверим (и заодно потренируем %q):
package main
import "fmt"
func main() {
a := "\n"
b := "\\n"
fmt.Printf("%q len=%d\n", a, len(a)) // "\n" len=1
fmt.Printf("%q len=%d\n", b, len(b)) // "\\n" len=2
}
Первая строка реально переносит строку при печати как %s, а %q показывает её “в безопасном режиме”. Вторая строка содержит именно два символа: обратный слэш и букву n.
Это критично, когда вы сравниваете строки, режете их на части или считаете длину. Если вы перепутали “управляющий символ” с “двумя буквами”, дальше всё становится сюрреализмом: программа делает “не то”, но формально она делает ровно то, что вы попросили.
Кстати, иногда для тестов удобно использовать “сырой” литерал в обратных кавычках (raw string literal), где экранирование не работает. Но это скорее удобство записи констант, чем основной инструмент отладки.
Если %q недостаточно: смотрим байты через []byte
Бывают ситуации, когда %q показал “что-то странное”, но вы не понимаете, что именно попало во ввод. Тогда мы переходим на следующий уровень: показываем точные байты. В Go это делается просто: []byte(s).
Давайте посмотрим разницу между пробелом и табом:
package main
import "fmt"
func main() {
a := "A B"
b := "A\tB"
fmt.Printf("%q bytes=%v\n", a, []byte(a)) // "A B" bytes=[65 32 66]
fmt.Printf("%q bytes=%v\n", b, []byte(b)) // "A\tB" bytes=[65 9 66]
}
Здесь 32 — это пробел, а 9 — таб. Визуально в некоторых шрифтах/редакторах таб может выглядеть “как несколько пробелов”, но байты у него совсем другие.
Для нашего мини‑приложения это особенно полезно, когда команда “вроде бы add”, но не равна "add". Тогда вы печатаете байты и видите, например, что в конце лежит 13 (это "\r").
Важная деталь: len(s) для строки — это число байт. Поэтому len([]byte(s)) будет равно len(s) и часто помогает убедиться, что вы не “потеряли” данные при преобразованиях.
Локализация проблемы: печать по рунам через range
Когда строка содержит не‑ASCII символы (например, русские буквы), байты уже сложнее интерпретировать “на глаз”. Там включается UTF‑8: одна буква может занимать 2 байта, 3 байта или даже 4. В таких случаях удобно обходить строку через range: вы получаете руны (rune), то есть кодовые точки Unicode.
Фокус в том, что range по строке даёт вам два значения: i — байтовый индекс, и r — руна.
Вот диагностика “по‑символьно”:
package main
import "fmt"
func main() {
s := "add\r\n"
for i, r := range s {
fmt.Printf("i=%d r=%q\n", i, r)
}
// i=0 r='a'
// i=1 r='d'
// i=2 r='d'
// i=3 r='\r'
// i=4 r='\n'
}
Сразу видно, где живёт проблема и какой именно символ мешает.
Это можно использовать и для “странных пробелов”. Например, если пользователь вставил текст из документа, там может оказаться “необычный пробел”. Вы в range увидите, что это не ' ' (обычный пробел), а что-то другое. Мы не будем сегодня углубляться в тонкости Unicode, но сам приём “посмотреть по рунам” — ваш универсальный фонарик.
“Панель приборов”: что печатать при отладке
Иногда достаточно %q. Иногда хочется увидеть тип (вдруг вы печатаете не строку, а []byte и удивляетесь). Иногда хочется увидеть код символа.
Ниже — компактная “панель приборов” для отладки одной строки, которую можно временно вставить в код:
package main
import "fmt"
func main() {
s := "Hi,\r\n"
fmt.Printf("s=%q\n", s) // s="Hi,\r\n"
fmt.Printf("type=%T\n", s) // type=string
fmt.Printf("len=%d\n", len(s)) // len=5 (байты)
fmt.Printf("bytes=%v\n", []byte(s))
}
Если вы отлаживаете конкретный символ rune, то %q покажет его “читаемо”, а %d — числом:
package main
import "fmt"
func main() {
r := '\n'
fmt.Printf("r=%q code=%d\n", r, r) // r='\n' code=10
}
Это полезно, когда вы “видите пробел”, но подозреваете, что это не пробел. Число помогает сравнить факты, а не эмоции.
3. Нормализация ввода: чистим осознанно
Чистим строку по формату, а не “на всякий случай”
Очень хочется просто везде написать strings.TrimSpace(s) и радоваться жизни. Иногда это действительно правильный ход: команды, логины, ответы “yes/no”, идентификаторы обычно не должны зависеть от пробелов по краям. Но иногда пробелы значимы (например, “имя задачи” может начинаться с пробела, если пользователь так захотел — странно, но люди вообще способны на многое).
Поэтому правило такое: чистим ввод там, где формат это позволяет.
Давайте улучшим наш мини‑CLI: прочитаем команду, подчистим её и покажем диагностику (на случай, если команда всё равно не распознаётся):
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
r := bufio.NewReader(os.Stdin)
cmd, err := r.ReadString('\n')
if err != nil {
fmt.Println("read error")
return
}
cmd = strings.TrimSpace(cmd)
fmt.Printf("cmd=%q bytes=%v\n", cmd, []byte(cmd))
}
Теперь даже если пользователь пришлёт "add\r\n", после TrimSpace команда станет "add".
Тонкость: TrimSpace убирает пробельные символы по краям, а не внутри строки. Это обычно то, что нужно для команд.
Блок‑схема: как дебажить строку
flowchart TD
A["Строка 'на вид' нормальная, но логика не работает"] --> B["Печатаем через %q"]
B --> C{Видим \\n/\\r/\\t/пробелы?}
C -- да --> D["Исправляем: TrimSpace/Replace и т.п."]
C -- нет --> E["Печатаем []byte(s) через %v"]
E --> F{Понятно, какие байты лишние?}
F -- да --> D
F -- нет --> G["Обходим range: i, r и печатаем r=%q"]
G --> D
Смысл простой: сначала “рентген” (%q), потом “анализ крови” ([]byte), потом “МРТ” (range по рунам).
Кейс: почему команда "add" не распознаётся
Теперь соберём типичный баг. Допустим, мы хотим распознавать команды "add" и "list". Сделаем простую проверку и специально добавим отладочный вывод, который включается, если команда неизвестна.
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
r := bufio.NewReader(os.Stdin)
cmdRaw, _ := r.ReadString('\n')
cmd := strings.TrimSpace(cmdRaw)
if cmd == "add" {
fmt.Println("ok: add")
return
}
if cmd == "list" {
fmt.Println("ok: list")
return
}
fmt.Printf("unknown command: %q\n", cmd) // видно экранирование
fmt.Printf("raw was: %q\n", cmdRaw) // покажет \r\n, если есть
fmt.Printf("raw bytes: %v\n", []byte(cmdRaw)) // точные байты
}
Почему здесь важны оба значения: cmdRaw и cmd? Потому что cmd — уже “почищенный” вариант. Он нужен для логики. А cmdRaw нужен для диагностики: если вы чистите слишком агрессивно или чистка не сработала, вы хотите видеть исходную картину.
И да, это нормально — иметь в программе несколько переменных “сырой ввод” и “нормализованный ввод”. Это делает код понятнее. Если назвать их s1 и s2, вы начнёте дебажить уже названия.
“Грязный” ввод в имени задачи: аккуратное хранение
Допустим, команда "add" добавляет задачу. Мы попросим пользователя ввести текст задачи отдельной строкой. И тут снова возникает проблема: после чтения строки у нас будет "\n", а иногда "\r\n". Если мы храним задачу “как есть”, то при выводе списка задачи могут выглядеть странно (пустые строки, скачущая верстка).
Сделаем минимальный прототип: прочитать название задачи, почистить края и сохранить в слайс.
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
r := bufio.NewReader(os.Stdin)
tasks := make([]string, 0)
line, _ := r.ReadString('\n')
title := strings.TrimSpace(line)
tasks = append(tasks, title)
fmt.Printf("saved: %q\n", tasks[0]) // saved: "..."
}
Да, пока мы читаем всего одну строку. Но логика важна: мы сохраняем нормализованное значение, а не сырое. Тогда остальной код работает проще. Если позже вы будете печатать задачи в таблицу или сравнивать строки, не придётся в каждом месте вспоминать про "\r".
Если хочется оставить “как ввёл пользователь” — вы можете хранить и сырое значение тоже. Но тогда вы осознанно соглашаетесь, что в данных могут быть хвосты. А хвосты потом кусаются.
4. Типичные ошибки
Ошибка №1: отладка строк только через fmt.Println и вера глазам.
Println показывает строку “как текст”, но прячет управляющие символы. В результате "add\r\n", "add\n" и "add" могут выглядеть одинаково, хотя логика сравнения строк честно считает их разными. В таких ситуациях спасает fmt.Printf("%q\n", s) — он делает невидимое видимым.
Ошибка №2: “лечить всё” через TrimSpace без понимания формата.
TrimSpace — отличный инструмент для команд, ключей и “токенов”, но может быть неправильным для данных, где пробелы по краям значимы (редко, но бывает). Хороший стиль — хранить отдельно raw и normalized значения и чистить только там, где это согласовано с форматом.
Ошибка №3: попытка искать “первый символ строки” через s[0] в Unicode‑тексте.
Индексирование строки даёт байт, а не “символ”. В UTF‑8 один символ может занимать несколько байт, поэтому s[0] для русского текста — это просто первый байт кодировки, а не буква. Для диагностики “по символам” используйте for i, r := range s и печать r через %q.
Ошибка №4: путаница между "\n" и "\\n".
В двойных кавычках "\n" — это перевод строки (один символ), а "\\n" — это два символа: слэш и буква n. Если перепутать, можно отлаживать не ту проблему: вы будете думать, что у вас “лишний перенос строки”, а у вас на самом деле текст "\n" как два видимых знака.
Ошибка №5: игнорирование ошибки чтения строки и работа с “полустрокой”.
Методы чтения (включая ReadString('\n')) возвращают и строку, и ошибку. Даже если сегодня вы пишете учебный прототип, полезно держать привычку: если err != nil, выбираем понятный путь (например, печатаем сообщение и выходим). Иначе вы можете отлаживать “грязный ввод”, когда на самом деле данные просто не дочитались.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ