JavaRush /Курсы /Swift SELF /Нормализация текста

Нормализация текста

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

1. Проверки на уровне Character: isLetter и isNumber

Когда вы пишете учебные задачи, ввод обычно «идеальный»: без лишних пробелов, без случайных табов, без странных символов и с правильным регистром. Но как только в игру вступает человек, текст начинает жить собственной жизнью: пробелы в начале, два пробела подряд, перенос строки в конце, буква “ё” вместо “е”, цифра “١” (арабская) вместо “1”, и так далее.

Нормализация — это не про «сделать красиво». Это про то, чтобы ваш код был предсказуемым: вы сравниваете строки, сохраняете данные, ищете совпадения и хотите, чтобы одинаковые по смыслу вводы приводили к одинаковому представлению. Иначе приложение выглядит капризным: "username" принимается, а "username " (с пробелом в конце) — внезапно нет.

Чтобы тренироваться на чем-то осязаемом, будем развивать мини‑консольное приложение TextBuddy. Оно будет читать строку команды и выполнять простые операции над текстом. Сегодня добавим туда «санитизацию»: очистку, проверку и извлечение нужных символов.

Когда мы перебираем строку циклом for ch in text, ch имеет тип Character. У Character есть полезные свойства, которые отвечают на вопросы вроде «это буква?» или «это число?». Swift добавляет такие свойства, потому что Character в Swift — это расширенная графема (то, что воспринимается человеком как один символ), а не обязательно «один байт» или «одна кодовая точка».

isLetter: «похоже на букву» — и это нормально

Начнём с isLetter. Это свойство отвечает, является ли символ буквой. Причём важно понимать философию: это не строгая проверка “ASCII‑буква a–z”, а более широкая классификация для Unicode‑текста. В документации/дизайне этих свойств прямо говорится, что такие проверки часто «разрешающие» (permissive): они дают разумный ответ для самых разных письменностей, а не только латиницы.

Мини‑пример: проверим, что строка состоит только из букв (например, «имя автора» без пробелов и дефисов — чисто учебный кейс).

let name = "Swift"

var ok = true
for ch in name {
    if !ch.isLetter {
        ok = false
        break
    }
}

print(ok) // true

Здесь важно, что мы делаем «ранний выход» через break: как только встретили плохой символ — дальше проверять смысла нет.

isNumber: числа бывают не только 0...9

С isNumber начинается самое интересное (и слегка коварное). Многие ожидают, что «число» — это только десятичные цифры 0...9. Но Unicode богаче: существуют разные наборы цифр и даже символы вроде дробей. В примерах для isNumber упоминаются не только обычные “7”, но и, например, “⅚” или цифры других письменностей.

Значит, isNumber отвечает на вопрос «этот символ относится к числовым», а не «это ASCII‑цифра». Для нормализации пользовательского ввода это часто хорошо (мы не дискриминируем чужую раскладку), но иногда неожиданно.

Посмотрим:

let s = "⅚"

for ch in s {
    print(ch.isNumber) // true
}

Если вы делаете приложение, где ожидаете строго ID из 0...9, то isNumber может быть слишком «добрым». Сегодня мы это просто зафиксируем как важный нюанс: isNumber — широкая категория.

Практика: команда check-username в TextBuddy

Представим, что в нашем приложении TextBuddy есть команда проверки логина: допускаем только буквы и цифры, без пробелов и знаков препинания. Это хороший учебный кейс, потому что он показывает идею “разрешённые символы”.

Сделаем маленький фрагмент кода, который проверяет строку username:

let username = "User123"

var ok = true
for ch in username {
    if !(ch.isLetter || ch.isNumber) {
        ok = false
        break
    }
}

print(ok) // true

Здесь мы комбинируем isLetter и isNumber. Важно именно то, что условие читается как человеческая фраза: «если символ НЕ (буква ИЛИ цифра) — то ошибка».

3. CharacterSet: наборы символов и trimmingCharacters(in:)

Перед тем как перейти к практике, важно не смешивать два разных сценария:

Валидация — мы проверяем, что строка соответствует правилам, и если нет — говорим «не подходит». Пример: логин с ограничениями.

Нормализация — мы «чиним» ввод, приводим к стандартному виду. Пример: убираем пробелы по краям, превращаем несколько пробелов в один, удаляем табы и переносы, выкидываем «мусорные» символы.

И вот тут появляется вопрос: «а как удалять пробелы красиво и по‑взрослому?» Можно, конечно, написать цикл и выкидывать ' ', но пробелы бывают не только обычные. И вот здесь нам очень помогает CharacterSet.

CharacterSet — это тип из Foundation, который представляет набор символов (на уровне Unicode‑скаляров). Важно запомнить две вещи: во‑первых, за CharacterSet обычно нужно подключать Foundation, во‑вторых, он мыслит скалярами, а не Character. Сам тип входит в набор foundation‑value‑types (вместе с Date, Data, URL и т. п.).

Это может звучать абстрактно, но на практике CharacterSet полезен именно как «готовый словарь классов символов»: пробелы, переносы строк, буквы, цифры и т. п.

Самая частая операция: trimmingCharacters(in:)

Начнём с супер‑практики: убрать «мусор» по краям строки. У нас уже был trimmingCharacters(in:) раньше в курсе на более бытовом уровне, а сегодня мы осознаем, что он работает через CharacterSet.

import Foundation

let raw = "   Hello \n"
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)

print(cleaned) // Hello

Обратите внимание на важную деталь: trimmingCharacters(in:) удаляет символы только по краям, а не «везде». Это не баг и не лень авторов Swift — это честная специфика операции: «обрезать края» и «удалить все пробелы» — разные алгоритмы.

Сравнение: Character‑свойства и CharacterSet

Чтобы не смешивать инструменты, полезно держать в голове простое сравнение:

Что вы делаете Лучше подходит Почему
Проверяете строку символ за символом в цикле for ch in text
Character.isLetter/isNumber
Работаете на уровне того, что видит пользователь (Character)
Хотите «обрезать края» по пробелам/переносам
trimmingCharacters(in: .whitespacesAndNewlines)
Готовая операция строкового API + CharacterSet
Хотите проверять принадлежность к большому набору символов (пробелы, переносы, табы…)
CharacterSet
Уже собранная «коллекция классов» символов
Хотите “удалить пробелы везде” Цикл + проверка через CharacterSet Потому что “везде” — это проход по всей строке

4. Практика нормализации: пробелы, цифры, slug

Как совместить Character и CharacterSet

Сейчас у вас может возникнуть логичный вопрос: «Если CharacterSet работает со скалярами, а я перебираю строку по Character, что делать?»

Ответ: у Character есть представление unicodeScalars, и мы можем проверить, содержит ли CharacterSet хотя бы один скаляр из этого символа. Иногда достаточно правила “если любой скаляр — пробельный, считаем символ пробельным”, а иногда хочется “все скаляры должны входить в набор” — но сегодня будем использовать простую и понятную модель.

Сделаем маленькую функцию isWhitespaceLike, которая проверит символ на пробельность через CharacterSet.whitespacesAndNewlines.

import Foundation

func isWhitespaceLike(_ ch: Character) -> Bool {
    for scalar in ch.unicodeScalars {
        if CharacterSet.whitespacesAndNewlines.contains(scalar) {
            return true
        }
    }
    return false
}

Здесь мы специально пишем через циклы, без allSatisfy и прочих «красивостей», потому что у нас курс ещё не про замыкания. Код чуть длиннее, зато максимально прозрачный: “идём по скалярам, если нашли пробельный — возвращаем true”.

Команда strip-whitespace: удаляем пробелы везде

Теперь соберём понятную практическую операцию: пользователь вводит строку, а мы удаляем из неё все пробельные символы, включая переносы строк и табы. Это удобно, например, чтобы «сжать» номер телефона/карты/идентификатора.

Сначала сделаем функцию, которая строит новую строку:

import Foundation

func stripWhitespace(_ text: String) -> String {
    var result = ""

    for ch in text {
        if !isWhitespaceLike(ch) {
            result += String(ch)
        }
    }

    return result
}

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

import Foundation

let raw = "a b\tc\nd"
let compact = stripWhitespace(raw)

print(compact) // abcd

Да, конкатенация result += ... не самая быстрая стратегия для огромных строк, но сегодня наша цель — понятность. Мы ещё не обсуждаем оптимизацию построения строк, и это нормально.

Команда extract-digits: извлекаем только цифры

Ещё один очень типичный сценарий нормализации: «оставить только цифры». Мы можем сделать это через Character.isNumber, и это будет работать даже для широких наборов Unicode‑цифр. Снова: это иногда хорошо, иногда неожиданно — но как учебная практика отлично.

func extractDigits(_ text: String) -> String {
    var result = ""

    for ch in text {
        if ch.isNumber {
            result += String(ch)
        }
    }

    return result
}

Проверка:

let raw = "tel: +1 (555) 12-34"
let digits = extractDigits(raw)

print(digits) // 15551234

Заметьте, как красиво тут работает идея «нормализация вместо валидации». Мы не говорим пользователю «у вас неправильный телефон, там пробелы». Мы просто берём ввод и приводим к виду, который нам удобен.

Упрощённая нормализация названия в slug

Чуть более «продуктовый» пример: пользователь вводит название заметки или книги, а вы хотите получить аккуратный идентификатор, который можно сравнивать и хранить. Мы не лезем в полноценные “slugify” библиотеки и не обсуждаем тонкости языков — делаем учебную, предсказуемую модель:

1) обрезаем пробелы по краям,
2) приводим к нижнему регистру,
3) заменяем любые пробельные символы на один пробел,
4) выкидываем всё, что не буква, не цифра и не пробел,
5) заменяем пробелы на -.

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

Первый шаг: trim + lowercased.

import Foundation

func normalizeBase(_ text: String) -> String {
    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
    return trimmed.lowercased()
}

Второй шаг: собрать «разрешённые символы» (буквы/цифры/пробел) и одновременно схлопнуть повторяющиеся пробелы. Тут будет маленькое состояние: wasSpace.

func normalizeToSlugLike(_ text: String) -> String {
    let base = normalizeBase(text)
    var result = ""
    var wasSpace = false

    for ch in base {
        let ok = ch.isLetter || ch.isNumber || isWhitespaceLike(ch)
        if !ok { continue }

        if isWhitespaceLike(ch) {
            if !wasSpace { result += " " }
            wasSpace = true
        } else {
            result += String(ch)
            wasSpace = false
        }
    }

    return result
}

Третий шаг: заменить пробелы на -. Мы сделаем это ещё одним проходом (да, это два прохода — зато просто).

func spacesToDashes(_ text: String) -> String {
    var result = ""

    for ch in text {
        result += (ch == " ") ? "-" : String(ch)
    }

    return result
}

И наконец проверка:

import Foundation

let title = "  Hello,\nSwift 6.2!!!  "
let slug = spacesToDashes(normalizeToSlugLike(title))

print(slug) // hello-swift-62

Да, это не идеальный slugger для всех языков мира. Но он отлично тренирует главное: нормализация — это конвейер понятных шагов, а не один «магический метод».

5. Схема-конвейер нормализации

Когда вы строите нормализацию, очень помогает рисовать себе простую блок‑схему. Это снижает шанс, что вы перепутаете порядок шагов (например, удалите пробелы до того, как замените табы, или наоборот).

flowchart TD
    A[Ввод пользователя: String] --> B[Trim по краям: whitespacesAndNewlines]
    B --> C[Привести к lowercased]
    C --> D[Фильтрация символов: letter/number/space]
    D --> E[Схлопнуть повторяющиеся пробелы]
    E --> F[Заменить пробелы на '-']
    F --> G[Результат: нормализованный идентификатор]

Схема банальная, но она делает ваш код и вашу голову спокойнее. А спокойная голова — главный инструмент программиста. (После кофе.)

6. Типичные ошибки

Ошибка №1: путать «проверить» и «почистить».
Новички часто пишут валидатор там, где пользователю нужна нормализация. Например, номер телефона можно либо строго запретить ввод “+1 (555) …”, либо спокойно извлечь цифры и работать дальше. Если правила не требуют жёсткой формы, нормализация обычно даёт лучше UX.

Ошибка №2: ожидать, что isNumber означает «только 0…9».
Character.isNumber покрывает широкий спектр Unicode‑чисел, и это концептуально правильно для международного текста. Но если вы проектируете внутренний формат (ID, код подтверждения, PIN), вам может понадобиться более узкое правило. Сегодня мы это не усложняем, но помнить про разницу полезно.

Ошибка №3: думать, что trimmingCharacters(in:) удаляет символы «везде».
trimmingCharacters(in:) — это про края. Если вы хотите удалить пробелы в середине строки, нужен проход по всем символам и сборка новой строки. Очень частая путаница: “я сделал trim, почему у меня в середине всё ещё два пробела?” — потому что trim не про это.

Ошибка №4: забыть import Foundation для CharacterSet и trimmingCharacters(in:).
Character.isLetter/isNumber — это стандартная библиотека, а CharacterSet и многие методы нормализации связаны с Foundation. Если забыть импорт, IDE честно скажет, что не знает такой тип или такой API.

Ошибка №5: пытаться кормить CharacterSet напрямую Character.
CharacterSet работает на уровне Unicode‑скаляров, поэтому для Character обычно нужно смотреть ch.unicodeScalars. Это не «сложность ради сложности», а следствие Unicode‑модели и того, что один Character может состоять из нескольких частей.

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