JavaRush /Курсы /Go SELF /Повтор: строки и нормализация данных

Повтор: строки и нормализация данных

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

1. Вспоминаем модель строк в Go

Когда вы только начинаете программировать, строка кажется «списком букв». В Go это ощущение быстро ломается о реальность: строка — это последовательность байт, и чаще всего эти байты являются текстом в UTF-8. Это не занудство ради занудства: если вы один раз поймёте, «из чего сделана строка», то перестанете ловить странные баги вида «почему len("привет") не 6» и «почему s[0] — не буква».

Go как язык вообще любит упрощать жизнь через ограничения: например, исходники Go должны быть в UTF‑8, чтобы инструменты и компилятор не жили в мире «а вдруг тут UTF‑16 с BOM и ещё немного латиницы». Это хорошо ложится на идею нормализации: мы заранее договариваемся о формате, чтобы дальше код был проще и надёжнее.

Ключевые свойства string, которые держим в голове:

  • строка неизменяема (immutable);
  • len(s) даёт количество байт, а не «символов»;
  • индексирование s[i] даёт один байт, а не «символ»;
  • for range по строке идёт по рунам (Unicode code points), читая UTF‑8.

Чтобы закрепить, вот маленький пример, который одновременно показывает «байты vs руны», и почему «сердечко» может занимать больше одного байта.

package main

import "fmt"

func main() {
	s := "Go❤️"

	fmt.Println(len(s)) // 6 (байты)
	cnt := 0
	for range s {
		cnt++
	}
	fmt.Println(cnt) // 3 (руны)
}

len, индексирование и range: три взгляда на строку

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

Давайте соберём это в маленькую таблицу — как шпаргалку для чтения кода:

Операция Что возвращает Про что это Тип результата
len(s)
длину строки байты, не руны
int
s[i]
элемент по индексу один байт
byte
for i, r := range s
индекс и значение индекс байта, значение руна
int, rune

Мини-демо (в нём важно увидеть, что индекс i в range — это индекс байта, а не «номер символа»):

package main

import "fmt"

func main() {
	s := "кофе"

	for i, r := range s {
		fmt.Printf("i=%d r=%c\n", i, r)
		// i=0 r=к
		// i=2 r=о
		// i=4 r=ф
		// i=6 r=е
	}
}

Почему индексы 0,2,4,6? Потому что буквы кириллицы в UTF‑8 обычно занимают 2 байта.

byte и rune: «символ» — это не байт

Когда вы видите слово «символ», хочется считать, что это что-то маленькое и фиксированное. В ASCII так и было: 1 символ = 1 байт. UTF‑8 не гарантирует такого равенства: один символ может занимать 1, 2, 3, 4 байта. Поэтому в Go есть два часто встречающихся «строительных блока»:

byte — это один байт (0..255).
rune — это число (по сути int32), представляющее Unicode code point.

Важно: «руна» — это не всегда то, что пользователь воспринимает как «один символ на экране». Некоторые «человеческие символы» могут состоять из нескольких code point (например, буква + диакритический знак). Мы в рамках этой лекции не будем уходить в глубины Unicode-комбинаций, но будем честны: «один символ глазами пользователя» — понятие сложнее, чем rune. Наша практическая цель проще: не ломать UTF‑8 и корректно нормализовать ввод.

Конвертации, которые встречаются чаще всего:

  • []byte(s) — получить байты строки (например, для низкоуровневой работы);
  • []rune(s) — получить руны, когда нужно индексирование «по символам», а не «по байтам»;
  • string(runes) — собрать строку обратно.

Мини-пример «аккуратно взять первую руну»:

package main

import "fmt"

func main() {
	s := "кофе"
	rs := []rune(s)

	fmt.Printf("%c\n", rs[0]) // к
}

Это не значит, что так надо делать всегда. Но это правильный способ, если вам реально нужна «первая руна», а не «первый байт».

2. Нормализация ввода: пробелы и регистр

Зачем нормализовать данные на границе

Нормализация — это не «подумаешь, пробелы». Это способ превратить непредсказуемый внешний мир в предсказуемые данные внутри программы. В идеале бизнес-логика должна работать по принципу: «я получил строку уже в нормальной форме, и дальше мне не нужно по всему коду ставить костыли вида TrimSpace и ToLower».

Представьте, что вы пишете мини-программу для задач (условный TaskBox). Пользователь вводит:

  • add Buy milk
  • ADD buy milk
  • add buy milk
  • Add BUY MILK

Если вы храните задачу «как ввели», то поиск, сравнение, дедупликация, фильтры — всё усложняется. Если вы храните задачу в нормализованном виде, то всё становится прямолинейно. И тут появляется важное инженерное правило: нормализовать нужно на границе, то есть сразу при чтении/создании значения, а не «где-нибудь потом».

Типичная простая цепочка нормализации выглядит так:

flowchart LR
    A[сырой ввод] --> B[TrimSpace]
    B --> C[Fields]
    C --> D[Join с одним пробелом]
    D --> E[ToLower или EqualFold при сравнениях]
    E --> F[валидное значение внутри программы]

TrimSpace, Fields, Join: базовый анти-пробельный набор

Когда речь про «грязный ввод», чаще всего виноваты пробелы, табы и переводы строк. И здесь Go даёт очень приятный минимальный набор функций из strings, который закрывает 80% реальных задач, не превращая код в трагедию.

Важные наблюдения:

strings.TrimSpace(s) убирает пробельные символы по краям.
strings.Fields(s) разбивает по любым пробельным символам и не возвращает пустые токены.
strings.Join(parts, " ") склеивает обратно с одним пробелом.

Это вместе даёт эффект «схлопнуть пробелы»: сколько бы пробелов ни было, внутри окажется ровно по одному.

Вот прям «эталонный» маленький нормализатор заголовка задачи:

package main

import (
	"fmt"
	"strings"
)

func normalizeTitle(s string) string {
	parts := strings.Fields(strings.TrimSpace(s))
	return strings.Join(parts, " ")
}

func main() {
	fmt.Println(normalizeTitle("  buy    milk  ")) // buy milk
}

Обратите внимание: Fields уже по сути включает идею «плевать, сколько там пробелов и табов». Поэтому strings.Split(s, " ") для таких задач обычно хуже: он создаёт пустые элементы, и вы потом вынуждены их фильтровать.

Регистр: ToLower и EqualFold

С регистром есть две частые ситуации: либо вы хотите привести данные к единому виду и хранить, либо вы хотите сравнить без учёта регистра, ничего не меняя.

Для «привести и хранить» обычно используют strings.ToLower. Для «сравнить без учёта регистра» часто лучше подходит strings.EqualFold, потому что он делает Unicode-aware сравнение (насколько это возможно без полноценной «локализации»). Мы не будем обсуждать языковые тонкости вроде турецкой I, но важно понимать идею: «просто привести обе строки к lower-case и сравнить» — часто достаточно, но не всегда идеально.

В нашем учебном TaskBox команды могут быть в любом регистре: ADD, Add, add. Поэтому команду удобно нормализовать к lower-case.

package main

import (
	"fmt"
	"strings"
)

func normalizeCmd(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}

func main() {
	fmt.Println(normalizeCmd("  ADD  ")) // add
}

А вот сравнение «две строки равны без учёта регистра» удобнее выразить так:

package main

import (
	"fmt"
	"strings"
)

func main() {
	fmt.Println(strings.EqualFold("кофе", "КОФЕ")) // true
}

3. Разбор команды и поиск

Разбор пользовательской команды через Fields

Сейчас соберём это в небольшой кусочек нашего приложения. Мы будем представлять, что у нас есть строка команды (мы её получили каким-то способом), и хотим разобрать:

  • пустую строку — это ошибка;
  • первое слово — команда;
  • всё остальное — аргументы.

Идея простая: Fields даёт нам токены уже без пустот и без «лишних пробелов».

package main

import (
	"errors"
	"strings"
)

func parseLine(line string) (string, []string, error) {
	parts := strings.Fields(strings.TrimSpace(line))
	if len(parts) == 0 {
		return "", nil, errors.New("empty command")
	}
	cmd := strings.ToLower(parts[0])
	return cmd, parts[1:], nil
}

Здесь важна именно линейность: нормализация → проверка пустоты → выделение команды. Мы не делаем «лесенок» из else, а сразу возвращаем ошибку, если ввод пустой.

Можно проверить на паре строк:

package main

import "fmt"

func main() {
	cmd, args, err := parseLine("  Add   buy   milk ")
	fmt.Println(cmd, args, err) // add [buy milk] <nil>
}

Видите, как удобно? Даже если человек ввёл «криво», мы получили нормальную структуру.

Нормализация заголовка задачи: почему Fields+Join лучше, чем TrimSpace

Если вы делаете TrimSpace, вы приводите в порядок только края. Но что делать с тем, что внутри может быть «три пробела подряд»? А потом вы начинаете печатать список задач — и он выглядит так, будто пользователь печатает локтями. В логике поиска это тоже мешает: запрос "buy milk" и текст "buy milk" становятся «разными строками», хотя человеку очевидно, что это одно и то же.

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

package main

import (
	"errors"
	"strings"
)

func newTitle(raw string) (string, error) {
	parts := strings.Fields(strings.TrimSpace(raw))
	if len(parts) == 0 {
		return "", errors.New("title is empty")
	}
	return strings.Join(parts, " "), nil
}

И мини-проверка:

package main

import "fmt"

func main() {
	t, err := newTitle("   ")
	fmt.Printf("%q %v\n", t, err) // "" title is empty
}

Поиск по строке: Contains без сюрпризов

Когда вы делаете поиск по подстроке, почти всегда хочется «не обращать внимания на регистр». Пользователь пишет milk, а задача хранится как Buy milk. Если сравнивать как есть — совпадения не будет. Поэтому мы обычно нормализуем обе стороны.

Самый простой вариант: привести обе строки к lower-case и использовать strings.Contains. Это не идеальная Unicode-лингвистика, но для учебных задач и большинства прикладных сценариев вполне.

package main

import (
	"strings"
)

func containsIgnoreCase(s, sub string) bool {
	s = strings.ToLower(s)
	sub = strings.ToLower(sub)
	return strings.Contains(s, sub)
}

Если хочется продемонстрировать на примере:

package main

import "fmt"

func main() {
	fmt.Println(containsIgnoreCase("Buy milk", "MILK")) // true
}

Заметьте: если вы заранее храните заголовки в нормализованной форме (например, со «схлопнутыми пробелами»), то поиск становится стабильнее ещё и по пробелам. Это хороший бонус нормализации: она помогает не только «красиво печатать», но и «стабильно искать».

4. Практика работы со строками: сборка и отладка

Сборка строк: почему strings.Builder — это не «оптимизация ради оптимизации»

Когда вы собираете строку из кусочков, у новичка рука тянется к +=. И это нормально: работает же! Но в цикле += часто приводит к тому, что создаётся много промежуточных строк, и сборка становится заметно тяжелее (и по времени, и по памяти). Иногда это не важно. Иногда важно. А иногда важно просто потому, что код с Builder получается честнее: «я собираю строку».

Для нашего TaskBox представим, что мы хотим сделать красивый вывод задачи как одной строки: номер, статус и заголовок. Builder тут смотрится аккуратно:

package main

import (
	"fmt"
	"strings"
)

func formatTask(i int, done bool, title string) string {
	var b strings.Builder
	b.WriteString(fmt.Sprintf("%d) ", i))
	if done {
		b.WriteString("[x] ")
	} else {
		b.WriteString("[ ] ")
	}
	b.WriteString(title)
	return b.String()
}

И пример:

package main

import "fmt"

func main() {
	fmt.Println(formatTask(1, false, "buy milk")) // 1) [ ] buy milk
}

Да, тут есть Sprintf, который сам по себе создаёт строку, но идея Builder всё равно понятна. На этом этапе нам важнее читаемость и сам приём, чем микроскопическая оптимизация.

Диагностика строк: печать через %q

Ошибки со строками часто невидимые. То есть они буквально невидимые: у вас там в конце строки пробел, а вы его глазами не видите. Или там \n, или таб, или неразрывный пробел. Поэтому очень полезная привычка: когда отлаживаете строки, печатайте их как «строковый литерал», то есть через %q.

Это превращает «молчаливые» пробелы и управляющие символы в видимые \n, \t и т. п.

package main

import "fmt"

func main() {
	s := " milk \n"
	fmt.Printf("%q\n", s) // " milk \n"
}

Если вы нормализуете ввод, то %q — отличный способ убедиться, что нормализация реально убрала то, что вы думали.

Микро-напоминание про строковые константы

В Go есть тонкий, но полезный момент: строковый литерал может быть «нетипизированной константой» до тех пор, пока контекст не заставит его стать string. На практике новичку чаще всего достаточно помнить, что const x = "hello" ведёт себя чуть свободнее, чем переменная, и компилятор может позволять присваивания/сравнения в более широком контексте.

Почему мы это вспоминаем именно в лекции про строки и нормализацию? Потому что нормализация часто приводит к созданию маленьких «эталонных значений»: например, команды "add", "list", "done". И их удобно держать именно как константы: они не меняются, и код становится читаемее.

5. Типичные ошибки при работе со строками и нормализацией

Ошибка №1: считать len(s) количеством символов.
Это одна из самых частых ловушек, потому что на английских строках len и «количество букв» часто совпадают. Но как только появляется кириллица или эмодзи, len начинает считать байты, и логика «отрезать первые N символов» ломается. Если вам нужно считать руны, используйте for range или []rune.

Ошибка №2: делать s[i] и думать, что это “символ”.
Индексирование строки возвращает byte. Если вы потом печатаете это как %c, вы можете получить «кракозябру» или часть UTF‑8 последовательности. Для «символов» используйте range или []rune, а s[i] оставьте для случаев, когда вы осознанно работаете с байтами (например, ASCII или протокол).

Ошибка №3: использовать strings.Split(s, " ") для пользовательского ввода.
Если у вас несколько пробелов подряд, Split создаст пустые токены, и дальше ваш код начинает жить в мире «фильтруем пустые элементы». Для «разбить по пробельным символам» чаще всего правильнее strings.Fields: он сразу выкидывает пустоты и работает и с табами, и с переносами строк.

Ошибка №4: нормализовать данные “где-нибудь потом”, а не на границе.
Когда TrimSpace и ToLower размазаны по коду, вы гарантированно забудете сделать нормализацию в каком-нибудь новом месте. Потом начинаются баги «почему команда ADD не работает», «почему поиск иногда не находит», «почему одинаковые задачи дублируются». Намного надёжнее: нормализовали один раз при чтении/создании значения — и дальше работаете только с нормой.

Ошибка №5: отлаживать строки без %q.
Когда в строке прячется пробел в конце или неожиданный \n, обычная печать fmt.Println(s) показывает вам «красивую картинку», но скрывает проблему. Печать через %q делает ввод “видимым” и часто экономит часы времени. В программировании это называется «включить свет и перестать спотыкаться о мебель».

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