1. Пайплайн анализа текста
Когда слышишь «анализ текста», легко представить что-то страшное: нейросети, лингвистика, и преподавателя, который говорит «это очевидно». На самом деле в простом учебном Text Analyzer почти всё держится на очень земной идее: мы последовательно превращаем один тип данных в другой, пока не получим удобный результат. Это и есть пайплайн: несколько шагов, у каждого есть понятный вход и выход.
Представьте, что вам дали большой текст и спросили: «Какие слова встречаются чаще всего?». Если писать это «в лоб», можно утонуть в циклах, временных переменных и проверках. Пайплайн спасает тем, что вы делаете ровно 5–6 стандартных шагов: нормализация → токены → частоты → сортировка → top‑N → форматирование. И на каждом шаге у вас получается структура, с которой приятно работать.
Вот схема (очень техническая, но честная):
flowchart TD
A["Сырой текст (String)"] --> B["Нормализованный текст (String)"]
B --> C["Токены (List⟨String⟩)"]
C --> D["Частоты (Map⟨String, Int⟩)"]
D --> E["Отсортированные элементы (List⟨Entry⟩)"]
E --> F["Top-N (List⟨Pair⟨String, Int⟩⟩)"]
F --> G["Отчёт (String)"]
Главная фишка: мы не «пытаемся решить всё сразу», мы каждый раз делаем маленький переход между понятными типами.
Карта типов на шагах пайплайна
Чтобы пайплайн не превращался в магию, полезно заранее держать в голове «карту типов»: что у нас было и что стало. Тогда код читается как маршрут в навигаторе, а не как квест «найди, где пропала скобка».
Ниже — практическая таблица, к которой можно возвращаться, когда вы в очередной раз поймаете себя на мысли «а почему тут entries, а не map?».
| Шаг | Что делаем | Вход | Выход | Пример операций |
|---|---|---|---|---|
|
Нормализуем текст | |
|
|
|
Токенизируем | |
|
|
|
Считаем частоты | |
|
|
|
Выбираем top‑N | |
List<Map.Entry<String, Int>> или List<Pair<String, Int>> | |
|
Форматируем | список Pair/Entry | |
|
Важно: мы пока делаем простой анализатор. Мы не лезем в «умную токенизацию», не учитываем все знаки пунктуации мира и не решаем судьбу дефиса в слове «мать‑его‑программист». Мы строим надёжный базовый пайплайн, который можно улучшать позже.
2. Нормализация текста
Нормализация текста — это момент, когда вы превращаете ввод пользователя в вид, пригодный для честной обработки. Пользователь может вставить текст с переводами строк, несколькими пробелами, табами и случайными заглавными буквами. Если мы начнём split() прямо по этому «сырью», мы получим мусор: пустые токены, «Kotlin» отдельно от «kotlin», и странные результаты частот.
Мини‑нормализация: trim() и lowercase()
Начнём с самого базового. Мы уберём пробелы по краям и приведём всё к нижнему регистру. Это уже резко уменьшает «шум».
fun main() {
val raw = " Kotlin KOTLIN kotlin "
val normalized = raw.trim().lowercase()
println(normalized) // kotlin kotlin kotlin
}
Здесь идея простая: если регистр не важен для смысла (а в нашем анализаторе он не важен), то лучше сделать единый регистр сразу.
Нормализация пробелов через Regex("""\s+""").replace(...)
Теперь боль новичка: «почему у меня после split(" ") пустые строки?». Обычно потому, что в тексте много пробелов, переводов строк и табов. Мы хотим «схлопнуть» любую последовательность пробельных символов в один обычный пробел.
import kotlin.text.Regex
fun main() {
val raw = " Kotlin Kotlin \n is\tgreat "
val spaces = Regex("""\s+""")
val normalized = spaces.replace(raw.trim().lowercase(), " ")
println(normalized) // kotlin kotlin is great
}
Обратите внимание на "\s+". Это «один или больше пробельных символов». То есть и пробелы, и "\n", и "\t" мы приводим к одной форме. После этого токенизация становится предсказуемой.
Если после нормализации текст оказался пустым, удобно сразу выбрать запасной сценарий через takeIf { ... } ?: ... . Это тот самый стиль «взяли значение, если оно подходит, иначе дефолт», который читается линейно.
3. Токенизация
Токенизация звучит как страшное слово, но в нашем контексте это буквально «разбить строку на слова». Здесь важно не спешить: если вы делаете split(" ") по грязному тексту, вы получите пустые элементы. Если вы уже сделали нормализацию пробелов, задача становится почти скучной — а это отличный признак правильного кода.
Простая токенизация после нормализации
Предположим, у нас уже есть строка, где между словами ровно один пробел. Тогда split(" ") будет работать стабильно.
fun main() {
val normalized = "kotlin kotlin is great"
val tokens = normalized.split(" ")
println(tokens) // [kotlin, kotlin, is, great]
}
Фильтрация пустых токенов: filter { it.isNotBlank() }
Даже если вы старались, «грязь» иногда пролезает. Например, пользователь ввёл только пробелы, или нормализация ещё не учитывает какой-то случай. Поэтому фильтрация isNotBlank() — дешёвая и полезная страховка.
fun main() {
val normalized = "kotlin kotlin " // специально "грязно"
val tokens = normalized
.split(" ")
.filter { it.isNotBlank() }
println(tokens) // [kotlin, kotlin]
}
Фильтр здесь не делает магии — он просто выкидывает пустые строки и строки из пробелов. И это именно тот «маленький шаг», который делает весь пайплайн устойчивее.
4. Подсчёт частот
Вот здесь начинается самая приятная часть: мы уже не возимся со строкой, а работаем с нормальной коллекцией List<String>. И наша цель — получить Map<String, Int>, где ключ — слово, значение — сколько раз оно встретилось.
В старых добрых временах (примерно вчера) вы могли бы сделать MutableMap и вручную увеличивать счётчик. Это полезный навык, но Kotlin даёт более компактный и читаемый вариант: groupingBy { ... }.eachCount().
fun main() {
val tokens = listOf("kotlin", "kotlin", "is", "great", "great")
val freq: Map<String, Int> = tokens.groupingBy { it }.eachCount()
println(freq) // {kotlin=2, is=1, great=2}
}
Почему это удобно именно как шаг пайплайна? Потому что на выходе мы получаем структуру, которая идеально соответствует задаче «top‑N»: частоты уже посчитаны, осталось выбрать самые большие.
5. Выбор top‑N
Наивная идея звучит так: «я хочу отсортировать Map». И тут Kotlin вам мягко намекнёт, что Map — не список, и сортировать «карту» напрямую нельзя тем же способом, что список. Зато у Map есть entries: набор пар «ключ‑значение», который можно превратить в список и сортировать как угодно.
Top‑N через entries → sortedByDescending → take
fun main() {
val freq = mapOf("kotlin" to 2, "is" to 1, "great" to 2, "fun" to 5)
val top2 = freq.entries
.sortedByDescending { it.value }
.take(2)
for (e in top2) {
println("${e.key}: ${e.value}")
// fun: 5
// kotlin: 2 (или great: 2 — порядок при равенстве не фиксируем)
}
}
Тут есть важный нюанс для начинающих: если значения одинаковые (например, kotlin=2 и great=2), конкретный порядок между ними вам обычно не важен. Позже можно будет добавить вторую сортировку по алфавиту, но в этой лекции мы держим фокус на базовом пайплайне.
Преобразование Entry в Pair
Иногда Entry неудобен для форматирования. Тогда можно сделать ещё один маленький шаг: превратить Entry в Pair<String, Int>.
fun main() {
val freq = mapOf("kotlin" to 2, "is" to 1, "great" to 2)
val top = freq.entries
.sortedByDescending { it.value }
.take(2)
.map { it.key to it.value }
println(top) // [(kotlin, 2), (great, 2)] (или наоборот при равенстве)
}
Заметьте, как это вписывается в философию пайплайна: ещё одно простое преобразование данных, чтобы следующий шаг был легче.
6. Форматирование отчёта
До этого момента мы «честно считали», и это была вычислительная часть. Теперь начинается представление результата пользователю: мы хотим красивую строку отчёта, которую можно вывести через println. Здесь важно не начать мешать логику подсчёта с логикой форматирования. Даже если вы делаете всё в одном main, держите в голове: «вот тут я считаю, а вот тут я показываю».
Печать через joinToString("\n")
fun main() {
val top = listOf("fun" to 5, "kotlin" to 2, "is" to 1)
val report = top.joinToString(separator = "\n") { (word, count) ->
"$word: $count"
}
println(report)
// fun: 5
// kotlin: 2
// is: 1
}
Деконструкция (word, count) тут работает потому, что Pair поддерживает компоненты. Получается компактно и читаемо.
Небольшое выравнивание через padEnd и padStart
У нас уже была тема про padStart/padEnd в строках, поэтому можно сделать отчёт чуть аккуратнее, чтобы числа стояли столбиком.
fun main() {
val top = listOf("fun" to 5, "kotlin" to 12, "is" to 1)
val report = top.joinToString(separator = "\n") { (word, count) ->
"${word.padEnd(10)} | ${count.toString().padStart(3)}"
}
println(report)
// fun | 5
// kotlin | 12
// is | 1
}
Это всё ещё не «большой форматтер», а просто приятный вид для консоли.
7. Сборка мини‑приложения Text Analyzer
Сейчас мы сделаем то, ради чего вообще затевался пайплайн: соединим шаги в один сценарий. Идея такая: читаем текст, нормализуем, токенизируем, считаем частоты, берём top‑N и печатаем отчёт. Мы пока не выносим шаги в отдельные функции — это будет следующая лекция. Сейчас нам важнее почувствовать линейность преобразований и увидеть, как типы «переезжают» от шага к шагу.
Вариант с промежуточными val
import kotlin.text.Regex
fun main() {
println("Введите текст:")
val rawText = readln()
val normalized = Regex("""\s+""").replace(rawText.trim().lowercase(), " ")
val tokens = normalized.split(" ").filter { it.isNotBlank() }
val freq = tokens.groupingBy { it }.eachCount()
val topN = freq.entries
.sortedByDescending { it.value }
.take(5)
.map { it.key to it.value }
val report = topN.joinToString("\n") { (w, c) -> "$w: $c" }
println(report)
}
Этот код «чуть длиннее», зато он честно показывает мысль: вот строка стала списком, список стал картой, карта стала top‑N, top‑N стал текстом. Для первых попыток это лучше, чем «суперцепочка на 1 строку».
Вариант‑цепочка со scope‑функциями
Если вы уже знакомы со scope‑функциями из прошлой лекции, можно собрать результат более «трубопроводно». Здесь also удобно вставляет отладочную печать и не ломает основной результат (оно возвращает исходный объект).
import kotlin.text.Regex
fun main() {
val report = readln()
.let { Regex("""\s+""").replace(it.trim().lowercase(), " ") }
.also { println("Нормализовано: $it") } // Нормализовано: ...
.split(" ")
.filter { it.isNotBlank() }
.groupingBy { it }.eachCount()
.entries
.sortedByDescending { it.value }
.take(5)
.joinToString("\n") { "${it.key}: ${it.value}" }
println(report)
}
Важно не пытаться писать так всегда. Это стиль, который хорош, когда цепочка короткая и читаемая. Если вы начинаете путаться, где у вас String, где List, а где Map, лучше вернуться к промежуточным val — это не «проигрыш», это нормальная инженерная осторожность.
8. Типичные ошибки при сборке пайплайна Text Analyzer
Ошибка №1: токенизация до нормализации.
Если начать с split(" ") по сырому тексту, вы почти гарантированно получите пустые токены и странные результаты. Проблема не в split, а в том, что пробелы бывают множественные, плюс есть "\n" и "\t". Сначала приводите пробельные символы к одному пробелу, а уже потом делите на слова.
Ошибка №2: забыли фильтрацию isNotBlank() и удивились пустым словам.
Даже после нормализации иногда остаются пограничные случаи: пустой ввод, пробелы по краям, неожиданные комбинации. Если не выкидывать пустые токены, у вас может появиться «слово» "", и оно внезапно станет самым частым (потому что пустота — очень популярна… философы бы одобрили, а пользователи — нет).
Ошибка №3: попытка «отсортировать Map напрямую».
Map — это структура «ключ → значение», а сортировка — это история про упорядоченные коллекции. Поэтому правильный путь почти всегда такой: freq.entries → sortedByDescending { it.value } → take(n). Как только вы принимаете это как норму, top‑N перестаёт быть загадкой.
Ошибка №4: смешивание подсчётов и форматирования в одну кашу.
Когда вы одновременно считаете частоты и тут же в процессе строите многострочную строку, код быстро становится нечитаемым: непонятно, где заканчивается логика, а где начинается «красивый вывод». Даже если вы всё пишете в одном main, держите этап форматирования отдельным последним шагом (например, отдельной переменной report).
Ошибка №5: слишком умная цепочка, в которой вы потеряли типы.
Scope‑функции и цепочки операций — классные, но они не должны превращаться в фокус с исчезающим кроликом. Если в середине цепочки вы уже не уверены, что у вас в руках (String? List<String>? Map<String, Int>?), остановитесь, сделайте промежуточные val и дайте им нормальные имена. Это не замедляет программиста — это ускоряет отладку.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ