1. Пайплайн как конвейер и типы шагов
Когда мы только собираем первый пайплайн, возникает приятное чувство: «Смотрите, мама, я функциональный программист». Но потом проходит два дня, вы открываете свой код — и ловите эффект «это писал не я, это писал мой ночной двойник». Проблема не в пайплайне, а в том, что мозгу сложно держать в голове одновременно смысл каждого шага и тип данных на каждом шаге.
Представьте, что у вас есть цепочка из 10 операций: trim, lowercase, replace, split, filter, groupingBy, eachCount, entries, sortedByDescending, take. Всё это можно написать одной строкой, но ошибка в одном месте превращает отладку в игру «угадай, где мы потеряли смысл». Поэтому мы делаем то, что делают взрослые разработчики: выделяем шаги в отдельные функции с говорящими именами.
Когда мы говорим «декомпозиция», это не просто «разделить на функции, потому что так принято». Мы хотим получить конвейер, где у каждого станка есть понятный вход и понятный выход. Тогда конвейер можно собирать как угодно, тестировать по частям и заменять один станок, не разбирая весь завод.
Чтобы мозгу было проще, полезно зафиксировать типы результатов на каждом шаге. Вот так выглядит наш «конвейер» анализатора текста:
| Шаг | Название шага | Вход | Выход | Смысл |
|---|---|---|---|---|
| 1 | |
|
|
Приводим текст к «аккуратному» виду |
| 2 | |
|
|
Режем строку на слова |
| 3 | |
|
|
Считаем частоты |
| 4 | |
|
|
Берём самые частые |
| 5 | |
|
|
Делаем текст отчёта |
Если вы заметили, здесь почти всё идеально «стыкуется»: выход одного шага — это вход следующего. Это как раз тот случай, где 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) } и не пытайтесь «победить компилятор силой мысли».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ