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() і запхати туди все: і нормалізацію, і підрахунок, і форматування, і друк у консоль. Але тоді ми отримаємо «комбайн», який складно повторно використати. Наприклад, ви захочете показати топ‑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>>

Топ‑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.

Зверніть увагу, як лінійно це читається: взяли текст, нормалізували, токенізували, порахували, взяли топ‑N, відформатували. Це вже «історія», а не «технічна інструкція».

6. Використання в main() і приклад виводу

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

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

    val report = analyze(text, top = 5)

    println("Топ-5 слів:")
    println(report)
}

Якщо запустити програму й увести:

Kotlin kotlin is great, and Kotlin is fun

то вивід буде приблизно таким (після вашої нормалізації та простої токенізації за пробілами пунктуація може лишитися в словах — це нормально для поточної версії):

Топ-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("СИРИЙ: $it") }
        .let(::normalizeText)
        .also { println("НОРМАЛІЗОВАНИЙ: $it") }
        .let(::tokenize)
        .also { println("ТОКЕНИ: $it") }
        .let(::countTokens)
        .also { println("ЧАСТОТИ: $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) } і не намагайтеся «перемогти компілятор силою думки».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ