JavaRush /Курсы /Go SELF /Отладка строк: %q и контроль «грязного» ввода

Отладка строк: %q и контроль «грязного» ввода

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

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 Зачем важно
Перевод строки
\n
Часто остаётся после чтения строки
Возврат каретки
\r
Типичен в Windows‑строках
\r\n
Табуляция
\t
Может быть вместо пробелов
Кавычка
\"
Внутри данных, 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, выбираем понятный путь (например, печатаем сообщение и выходим). Иначе вы можете отлаживать “грязный ввод”, когда на самом деле данные просто не дочитались.

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