JavaRush /Курсы /Kotlin SELF /Декомпозиция Text Analyzer: шаги и сборка через let(::......

Декомпозиция Text Analyzer: шаги и сборка через let(::...)

Kotlin SELF
28 уровень , 3 лекция
Открыта

1. Пайплайн как конвейер и типы шагов

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

Представьте, что у вас есть цепочка из 10 операций: trim, lowercase, replace, split, filter, groupingBy, eachCount, entries, sortedByDescending, take. Всё это можно написать одной строкой, но ошибка в одном месте превращает отладку в игру «угадай, где мы потеряли смысл». Поэтому мы делаем то, что делают взрослые разработчики: выделяем шаги в отдельные функции с говорящими именами.

Когда мы говорим «декомпозиция», это не просто «разделить на функции, потому что так принято». Мы хотим получить конвейер, где у каждого станка есть понятный вход и понятный выход. Тогда конвейер можно собирать как угодно, тестировать по частям и заменять один станок, не разбирая весь завод.

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

Шаг Название шага Вход Выход Смысл
1
normalizeText
String
String
Приводим текст к «аккуратному» виду
2
tokenize
String
List<String>
Режем строку на слова
3
countTokens
List<String>
Map<String, Int>
Считаем частоты
4
topN
Map<String, Int>
List<Pair<String, Int>>
Берём самые частые
5
formatTop
List<Pair<String, Int>>
String
Делаем текст отчёта

Если вы заметили, здесь почти всё идеально «стыкуется»: выход одного шага — это вход следующего. Это как раз тот случай, где let(::...) получается особенно читаемым, потому что let по смыслу «передай результат дальше» и возвращает результат блока. Эта идея хорошо ложится на идиому value?.let { ... } и на «преобразование значения через блок» в Kotlin.

Границы ответственности функций

Очень хочется сделать функцию analyzeText() и запихнуть туда всё: и нормализацию, и подсчёт, и форматирование, и печать в консоль. Но тогда мы получим «комбайн», который сложно повторно использовать: например, вы захотите вывести top‑N не текстом, а таблицей — и придётся переписывать половину.

Хорошая граница ответственности звучит скучно, но работает прекрасно: функция делает одну понятную вещь и возвращает результат, не печатая его и не читая ввод. Печать и ввод — это зона main() (или отдельного CLI‑слоя), а чистые вычисления — это функции пайплайна. Тогда шаги можно переиспользовать хоть в другом проекте, хоть в тестах, хоть в другом режиме вывода.

2. Шаги пайплайна Text Analyzer

Шаг 1 — normalizeText(text: String): String

Нормализация — это «уборка в комнате перед тем, как искать носки». Если мы сразу начнём split(" ") по сырому тексту, мы получим токены вроде "" (пустая строка), "\n" или слова в разных регистрах. Нормализация делает вход предсказуемым: одинаковые пробелы, одинаковый регистр, никаких «лишних» краёв.

Пример небольшой функции (обратите внимание: 1 шаг — 1 смысл):

import kotlin.text.Regex

fun normalizeText(text: String): String =
    Regex("""\s+""")
        .replace(text.trim().lowercase(), " ")

Здесь мы делаем три вещи: trim() убирает пробелы по краям, lowercase() приводит к одному регистру, а Regex("""\s+""") схлопывает любые «пробельные полосы препятствий» в один пробел.

Шаг 2 — tokenize(text: String): List<String>

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

Функция токенизации в самом простом виде может быть такой:

fun tokenize(text: String): List<String> =
    text.split(" ")
        .filter { it.isNotBlank() }

Да, после нормализации у нас и так «один пробел», но filter { it.isNotBlank() } — это ремень безопасности. В программировании ремни безопасности не выглядят круто, но спасают жизнь (вашему времени и нервам).

Шаг 3 — countTokens(tokens: List<String>): Map<String, Int>

На этом этапе мы делаем то, ради чего весь праздник: превращаем список слов в частоты. Раньше многие пишут это вручную через MutableMap, но в Kotlin есть стандартный и очень читаемый путь: groupingBy { it }.eachCount().

Функция получается почти «самодокументируемой»:

fun countTokens(tokens: List<String>): Map<String, Int> =
    tokens.groupingBy { it }.eachCount()

На входе список слов, на выходе карта “слово → сколько раз встретилось”. Такой шаг удобно держать отдельно: он вообще не зависит от того, откуда слова появились. Хотите — токенизируйте по пробелам, хотите — по запятым, хотите — доставайте слова из JSON, а частоты всё равно считаются одинаково.

Шаг 4 — topN(freq: Map<String, Int>, n: Int): List<Pair<String, Int>>

Top‑N — это место, где легко запутаться в типах: Map нельзя «отсортировать» напрямую так, как список, потому что сортировка — это про порядок, а Map по смыслу про доступ по ключу. Поэтому мы работаем через entries: сначала превращаем в список элементов, сортируем по value, берём take, и только потом выбираем форму результата.

Мы сознательно возвращаем List<Pair<String, Int>>, потому что это удобно для форматирования: список пар легко превратить в строки, таблицу, что угодно.

fun topN(freq: Map<String, Int>, n: Int): List<Pair<String, Int>> =
    freq.entries
        .sortedByDescending { it.value }
        .take(n)
        .map { it.key to it.value }

Обратите внимание на последнюю строку: map { it.key to it.value }. Это превращает Map.Entry<String, Int> в Pair<String, Int>. Мы не тащим Entry дальше, потому что это «внутренний» тип карты — для следующего шага он не нужен.

Шаг 5 — formatTop(items: List<Pair<String, Int>>): String

Форматирование — это слой «представления». Здесь мы превращаем данные в красивый текст. И важный момент: форматирование не должно ничего считать, сортировать или фильтровать. Если вы в форматировании вдруг начали пересчитывать частоты — значит, границы шагов размылись.

Сделаем простой формат: одна строка — одно слово и его количество:

fun formatTop(items: List<Pair<String, Int>>): String =
    items.joinToString(separator = "\n") { (word, count) ->
        "$word: $count"
    }

Деконструкция (word, count) делает код человечным. Мы уже знакомы с деконструкцией из предыдущих тем, а здесь она просто усиливает читаемость.

5. Сборка пайплайна через let(::...)

Теперь самое вкусное: мы имеем пять маленьких функций, которые можно читать отдельно. Осталось собрать из них сценарий так, чтобы он выглядел как конвейер, а не как «клубок переменных». Для этого отлично подходит let, потому что он как раз про преобразование значения в новое значение.

Начнём с простого: входной текст → отчёт:

fun analyze(text: String, top: Int): String =
    text
        .let(::normalizeText)
        .let(::tokenize)
        .let(::countTokens)
        .let { freq -> topN(freq, n = top) }
        .let(::formatTop)

Здесь есть один «особый» шаг: topN требует два аргумента, поэтому мы не можем написать просто .let(::topN) — нам нужно подставить n. Мы решаем это лямбдой с именем параметра freq, чтобы не плодить загадочный it.

Обратите внимание, насколько линейно это читается: взяли текст, нормализовали, токенизировали, посчитали, взяли top‑N, отформатировали. Это прямо «история», а не «техническая инструкция».

6. Использование в main() и пример вывода

Теперь сделаем небольшой main(), который использует наш анализатор. Важно: main() занимается вводом/выводом, а analyze — вычислениями. Так мы не смешиваем ответственности.

fun main() {
    println("Введите текст для анализа:")
    val text = readln()

    val report = analyze(text, top = 5)

    println("Top-5 слов:")
    println(report)
}

Если запустить и ввести:

Kotlin kotlin is great, and Kotlin is fun

то вывод будет примерно таким (после вашей нормализации и простой токенизации по пробелам пунктуация может остаться в словах — это нормально для текущей версии):

Top-5 слов:
kotlin: 3
is: 2
great,: 1
and: 1
fun: 1

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

7. Схема пайплайна

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

flowchart TD
    A["String (raw)"] --> B["normalizeText(): String"]
    B --> C["tokenize(): List⟨String⟩"]
    C --> D["countTokens(): Map⟨String, Int⟩"]
    D --> E["topN(n): List⟨Pair⟨String, Int⟩⟩"]
    E --> F["formatTop(): String"]

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

8. Читаемость: let(::step) вместо цепочки val

Новички часто попадают в одну из двух крайностей. Либо они пишут всё одной гигантской цепочкой, либо делают 15 промежуточных переменных вида text1, text2, list3, map4, и это превращается в «археологические раскопки»: что было list3 и чем отличается от list2?

let(::normalizeText) — это компромисс. Мы не теряем линейность, но у нас есть ясные имена шагов. Кроме того, такие шаги легко переиспользовать в другом месте: например, вы можете отдельно вызывать tokenize(normalizeText(text)) и использовать токены для другой задачи.

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

9. Диагностика через also между шагами

Иногда хочется посмотреть, что получилось после каждого шага. Делать println() внутри normalizeText() — плохая идея: функция перестанет быть чистой и начнёт «шуметь» при любом использовании. Но мы можем временно вставлять диагностику в пайплайн через also, потому что also возвращает исходный объект и предназначен для побочных действий.

Например:

fun analyzeDebug(text: String, top: Int): String =
    text
        .also { println("RAW: $it") }
        .let(::normalizeText)
        .also { println("NORMALIZED: $it") }
        .let(::tokenize)
        .also { println("TOKENS: $it") }
        .let(::countTokens)
        .also { println("FREQ: $it") }
        .let { freq -> topN(freq, n = top) }
        .let(::formatTop)

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

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

Ошибка №1: «Комбайн вместо пайплайна».
Часто студент делает одну функцию, которая и нормализует, и токенизирует, и сортирует, и печатает. Сначала кажется удобно, но потом любое изменение превращается в риск сломать всё сразу. Гораздо стабильнее держать шаги мелкими: один шаг — один смысл, один вход — один выход.

Ошибка №2: размытый контракт topN: возвращают неудобный тип.
Если topN возвращает List<Map.Entry<String, Int>>, это вроде бы работает, но вы тащите дальше тип, который предназначен для внутренней жизни Map. В форматировании появляются странные конструкции, а чтение кода ухудшается. Лучше выбрать тип результата под следующий шаг, и для форматирования обычно удобнее List<Pair<String, Int>>.

Ошибка №3: «двойной it» и потеря читабельности в let.
Когда вы начинаете писать вложенные let и везде оставляете it, через минуту непонятно, что именно сейчас хранится в it: строка, список или карта. Это тот случай, когда лучше явно назвать параметр: let { freq -> ... } или временно распрямить цепочку в пару промежуточных переменных с нормальными именами.

Ошибка №4: побочные эффекты внутри вычислительных шагов.
Иногда внутрь normalizeText() или countTokens() добавляют println() для отладки, а потом забывают убрать. В итоге функции нельзя нормально переиспользовать: они начинают печатать что-то «сами по себе». Если хочется диагностики, лучше вставлять её через also в месте сборки пайплайна, а не прятать внутри шагов.

Ошибка №5: попытка «склеить» шаги с разными сигнатурами через ::function.
let(::tokenize) работает, потому что tokenize принимает ровно то, что возвращает предыдущий шаг. Но topN требует ещё n, поэтому попытка сделать .let(::topN) не сработает. Это нормальная ситуация: просто используйте лямбду .let { freq -> topN(freq, n = 5) } и не пытайтесь «победить компилятор силой мысли».

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