JavaRush /Курси /Kotlin SELF /Рефакторинг малими кроками

Рефакторинг малими кроками

Kotlin SELF
Рівень 39 , Лекція 3
Відкрита

1. Вступ

Рефакторинг — це коли ви змінюєте структуру коду, але намагаєтеся не змінювати його зміст (поведінку). Звучить просто, але на практиці мозок дуже любить думку: «раз уже чіпаю — давайте ще й поліпшимо…». І ось ви вже одночасно переносите файли, перейменовуєте сутності, змінюєте формат повідомлень і переписуєте алгоритм. Це як під час прибирання вирішити заодно: «а давайте ще стіну знесемо» — і раптом ви живете на будмайданчику.

Проблема «великого вибуху» в тому, що ви втрачаєте контроль: коли щось зламалося, незрозуміло, яка саме з 37 змін винна. Тому в цій лекції ми вчитимемося робити зміни так, щоб у будь-який момент можна було чесно сказати: «код компілюється, програма запускається, і я розумію, що саме щойно змінив(ла)».

Щоб було наочно, уявімо нашу типову «точку старту» — шматок коду в CLI, який уже працює, але виглядає як гаряча локшина:

fun main() {
    val items = mutableListOf<Pair<String, Int>>() // "title" і amount

    while (true) {
        print("> ")
        val line = readln().trim()
        if (line == "exit") break

        if (line.startsWith("add ")) {
            val rest = line.removePrefix("add ")
            val parts = rest.split(" ")
            val title = parts.dropLast(1).joinToString(" ").trim()
            val amount = parts.last().toIntOrNull() ?: 0

            if (title.isNotEmpty() && amount > 0) {
                items.add(title to amount)
                println("Додано: $title ($amount)") // Додано: Coffee (200)
            } else {
                println("Некоректне введення")
            }
        } else if (line == "list") {
            for ((t, a) in items) println("$t: $a")
        } else {
            println("Невідома команда")
        }
    }
}

Він «ніби працює», але змінювати його страшно: будь-яка правка може зламати парсинг, формат виводу або логіку додавання. Далі ми перетворюватимемо це на зрозумілішу структуру — маленькими кроками, без драм.

2. Дисципліна малих кроків

Коли кажуть «робіть маленькі кроки», це звучить як порада рівня «пийте воду»: наче правильно, але незрозуміло, як саме. Перетворімо це на конкретне правило, яке можна застосовувати буквально руками. Після кожної зміни ви маєте мати можливість: (1) зібрати проєкт без помилок, (2) запустити програму, (3) швидко перевірити, що базові сценарії не виглядають поламаними.

Інваріант компіляції

Перше — інваріант компіляції: ви не накопичуєте «напівробочі» стани. Код або компілюється, або ви не завершуєте крок.

Інваріант змісту

Друге — інваріант змісту: на кроці рефакторингу ви намагаєтеся не змінювати поведінку. Якщо поведінку все-таки змінюєте, робіть це усвідомлено й окремо. Інакше вийде класика: «Я просто перейменував змінну, але чомусь тепер "add" додає відʼємні суми…».

Ще одна практична річ: використовуйте вбудовані перевірки передумов (require, check) там, де це допомагає зробити контракт явним. У Kotlin require() зручно застосовувати для валідації вхідних аргументів (викидає IllegalArgumentException), а check() — для перевірки стану (викидає IllegalStateException). Це не «магія», а спосіб зробити помилку гучною й зрозумілою.

Мінімальна страховка без тестів

Зараз ми свідомо не заглиблюємося в тестові фреймворки й юніт-тести (це окрема велика тема), але зовсім без страховки рефакторинг перетворюється на гру «вгадай, де зламалося». Тому в консольному проєкті зручно мати кілька стабільних «ручних» сценаріїв перевірки — не як «домашнє завдання», а як вашу особисту систему координат.

Ідея така: ви тримаєте в голові (або в нотатці) 3–4 команди, які швидко проганяєте після кожного кроку, наприклад: додати витрату, вивести список, спробувати некоректне введення, вийти. Це займає 15–30 с, зате дає впевненість: «я не зламав(ла) все й одразу». У реальній розробці це замінюється автоматичними тестами, але до них нам важливо виробити дисципліну маленьких перевірок.

Під час рефакторингу особливо допомагає звичка «не чіпати одразу все». Наприклад, якщо ви хочете винести парсинг команд у функцію, не треба водночас змінювати формат команд. Спочатку винесли — переконалися, що працює, — а вже потім поліпшуєте формат.

Як планувати послідовність кроків

Рефакторинг — це не лише «робити дрібно», а й «робити в правильному порядку». Якщо ви спочатку рознесете все за пакетами, потім почнете змінювати сигнатури, а потім раптом зрозумієте, що забули про парсинг, — отримаєте купу дрібних проблем, які неприємно розгрібати.

Хороша стратегія: спочатку стабілізувати межі дій (винести функції, зробити явні типи на кшталт ParsedCommand), потім стабілізувати місця відповідальності (маршрутизатор/обробник), і лише потім змінювати залежності та контракти (репозиторій, експортер тощо). Тоді кожна зміна простіше «приземляється».

Невеличка блок-схема (у стилі «куди рухатися, щоб не було боляче»):

flowchart TD
    A["Великий main: усе впереміш"] --> B["Винести дрібні функції: normalize/parse"]
    B --> C["Ввести явні типи: ParsedCommand"]
    C --> D["Винести роутинг команд: CommandRouter/Handler"]
    D --> E["Рознести за пакетами: app.cli / domain / storage"]
    E --> F["Замінити залежності: MutableList -> Repository"]
    F --> G["Стабілізувати формат результату команд (наступна лекція)"]

Зауважте: на кожному кроці можна зупинитися — проєкт усе одно компілюється й запускається. Це дуже заспокоює, особливо якщо ви вже бодай раз переживали стан «усе переписав(ла), нічого не працює».

3. Малий крок №1: виносимо повторюване у функції

Коли код починає розповзатися, насамперед страждає читання: ви відкриваєте main і бачите не «сценарій роботи програми», а кашу з нормалізації рядків, перевірок, парсингу, розгалужень, друку та оновлення списку. Наймʼякший (і зазвичай найбезпечніший) крок — виділити маленькі функції, які не змінюють зміст, а просто дають діям імена.

Почнемо з найнешкідливішого: нормалізації команди. Ми вже робили це в попередніх темах, але зараз важливий саме процес: винести шматок, запустити, переконатися, що нічого не змінилося.

private fun normalizeCommand(raw: String): String =
    raw.trim().lowercase()

fun main() {
    val line = readln()
    val cmd = normalizeCommand(line)

    println(cmd) // якщо ввели "  LiSt  ", побачимо "list"
}

Чому це хороший хід рефакторингу? Тому що він майже не може зламати логіку програми: ви винесли рівно той самий вираз — просто дали йому імʼя. Якщо раптом щось пішло не так, ви точно знаєте, де шукати.

Далі можна винести «перевірку виходу»:

private fun isExitCommand(cmd: String): Boolean =
    cmd == "exit" || cmd == "quit"

fun main() {
    val cmd = normalizeCommand(readln())
    if (isExitCommand(cmd)) return

    println("Продовжуємо…") // Продовжуємо…
}

І ось тут починається магія читабельності: main поступово перетворюється на «прочитав → нормалізував → вибрав гілку». Тобто стає тим самим сценарієм, який легко читати зверху вниз.

4. Малий крок №2: вводимо ParsedCommand замість «всюдисущих рядків»

Одна з причин, чому «великий main» важко рефакторити, — там усюди рядки. Рядок і команда, і аргументи, і початкове введення, і вже нормалізована версія. Це як намагатися керувати складом, де на всіх коробках написано «речі». Зручно? Ні. Реалістично? Так. Часто так і живуть.

Малий крок — ввести невеличку модель даних для результату парсингу. Це не «ООП заради ООП», а просто спосіб зробити контракт зрозумілішим.

data class ParsedCommand(
    val name: String,
    val arg: String?
)

Тепер ви можете написати функцію парсингу, яка повертає структуру, а не «спробуй здогадайся»:

private fun parseCommandLine(line: String): ParsedCommand {
    val s = line.trim()
    val space = s.indexOf(' ')

    return if (space == -1) {
        ParsedCommand(name = s.lowercase(), arg = null)
    } else {
        val name = s.substring(0, space).lowercase()
        val arg = s.substring(space + 1).trim().takeIf { it.isNotEmpty() }
        ParsedCommand(name = name, arg = arg)
    }
}

Зверніть увагу на takeIf: він допомагає повернути null, якщо аргумент порожній. Це зручно, бо далі ви можете розрізняти «аргументу немає» і «аргумент є». (І так, null тут доречний: це саме «відсутність значення», а не помилка.)

І тепер main читається спокійніше:

fun main() {
    print("> ")
    val cmd = parseCommandLine(readln())

    println("name=${cmd.name}, arg=${cmd.arg}") // наприклад: name=add, arg=Coffee 200
}

Це важливий прийом рефакторингу: ви не переписуєте всю програму одразу. Ви лише змінюєте форму подання даних на зручнішу — і вже потім, спираючись на неї, робите наступний крок.

5. Малий крок №3: виносимо маршрутизацію команд у CommandRouter

Коли парсинг уже повертає ParsedCommand, наступний шматок «локшини» зазвичай — розгалуження через if/else if або величезний when прямо в main. І знову: наша мета не «зробити красиво як у книжці», а зробити так, щоб кожен шматок коду мав один зрозумілий обовʼязок.

Створімо клас, який займається лише тим, що приймає вже розпарсену команду й вирішує, що робити далі. Поки що нехай він повертає рядок (повідомлення для CLI). Це важливо: ми не тягнемо сюди консоль і не друкуємо всередині. Повертаємо рядок — CLI потім сам вирішить, як його показати.

class CommandRouter {
    fun route(cmd: ParsedCommand): String =
        when (cmd.name) {
            "help" -> "Команди: help, add, list, exit"
            else -> "Невідома команда: ${cmd.name}"
        }
}

when у Kotlin може бути виразом і повертати значення, що якраз допомагає писати такі функції компактно й читабельно.

І main перетворюється на простий сценарій:

fun main() {
    val router = CommandRouter()

    print("> ")
    val cmd = parseCommandLine(readln())
    val message = router.route(cmd)

    println(message) // наприклад: Команди: help, add, list, exit
}

Далі ви «підʼєднуєте» до роутера реальні дії ("add"/"list"), але робите це теж малими кроками: спочатку підтримали "list" заглушкою, потім підʼєднали сервіс, потім додали валідацію.

6. Малий крок №4: перенесення файлів і пакетів без зміни логіки

Перенесення за пакетами звучить як «ну це ж просто папки», але на практиці це одна з частих точок, де новачки ламають проєкт. Хороша новина: IDE вміє робити це майже безпечно (Move/Refactor), а погана — якщо робити все вручну й одразу «по всьому проєкту», легко влаштувати квест «чому воно не бачить клас».

Правильний маленький крок виглядає так: ви переносите один файл, лагодите імпорти, переконуєтеся, що все компілюється, і лише потім беретеся за наступний. Це нудно, зате працює.

Наприклад, переносимо модель команди в пакет CLI.

// FILE: app/cli/ParsedCommand.kt
package app.cli

data class ParsedCommand(
    val name: String,
    val arg: String?
)

І у файлі Main.kt:

// FILE: app/cli/Main.kt
package app.cli

fun main() {
    val cmd = ParsedCommand("help", null)
    println(cmd) // ParsedCommand(name=help, arg=null)
}

Потім переносимо парсер туди ж:

// FILE: app/cli/CommandParsing.kt
package app.cli

fun parseCommandLine(line: String): ParsedCommand {
    val s = line.trim()
    val space = s.indexOf(' ')
    return if (space == -1) ParsedCommand(s.lowercase(), null)
    else ParsedCommand(s.substring(0, space).lowercase(), s.substring(space + 1).trim())
}

На цьому кроці дуже важливо не «поліпшувати парсер», а просто перенести його. Інакше ви змішаєте дві зміни: (1) організаційну, (2) логічну.

7. Малий крок №5: змінюємо залежності через «прошарок»

Найболючіша частина рефакторингу — заміна прямих залежностей на інтерфейси. Наприклад, раніше ваш ExpenseService працював напряму зі списком, а тепер за архітектурою він має працювати через ExpenseRepository. Якщо робити це «одним махом», ви зачепите пів проєкту: сигнатури, створення обʼєктів, імпорт, логіку зберігання.

Малий крок тут — зробити «адаптер» навколо того, що вже є, і поступово перевести код на новий контракт.

Уявімо, що в нас уже є доменна модель:

// FILE: domain/model/Expense.kt
package domain.model

data class Expense(
    val title: String,
    val amount: Int
)

Раніше ми могли зберігати MutableList<Expense> прямо в main. Тепер робимо репозиторій у памʼяті. Важливо: це можна зробити, взагалі не змінюючи логіку команд — лише замінивши «де лежать дані».

// FILE: domain/service/ExpenseRepository.kt
package domain.service

import domain.model.Expense

interface ExpenseRepository {
    fun add(expense: Expense)
    fun all(): List<Expense>
}

Реалізація:

// FILE: storage/InMemoryExpenseRepository.kt
package storage

import domain.model.Expense
import domain.service.ExpenseRepository

class InMemoryExpenseRepository : ExpenseRepository {
    private val items = mutableListOf<Expense>()

    override fun add(expense: Expense) {
        items.add(expense)
    }

    override fun all(): List<Expense> = items
}

Зверніть увагу: all() повертає List, а не MutableList. Це маленька деталь, але вона захищає вас від ситуації «CLI випадково змінив сховище в обхід правил».

Тепер сервіс. І ось тут є спокуса «переписати все красиво». Не треба. Робіть мінімум: просто замініть список на репозиторій і залиште колишню логіку.

// FILE: domain/service/ExpenseService.kt
package domain.service

import domain.model.Expense

class ExpenseService(private val repo: ExpenseRepository) {

    fun add(title: String, amount: Int) {
        val normalizedTitle = title.trim()

        require(normalizedTitle.isNotEmpty()) { "title не має бути порожнім" }
        require(amount > 0) { "amount має бути > 0" } // require викидає IllegalArgumentException 

        repo.add(Expense(normalizedTitle, amount))
    }

    fun all(): List<Expense> = repo.all()
}

І лише тепер змінюємо збирання в main (рівно в одному місці, не чіпаючи решту):

// FILE: app/cli/Main.kt
package app.cli

import domain.service.ExpenseService
import storage.InMemoryExpenseRepository

fun main() {
    val service = ExpenseService(InMemoryExpenseRepository())

    service.add("Coffee", 200)
    println(service.all()) // [Expense(title=Coffee, amount=200)]
}

Це і є рефакторинг малими кроками: ви додали новий шар (Repository), але не переписували весь світ. «Старий світ» просто почав користуватися новим контрактом.

8. Як планувати послідовність кроків

Рефакторинг — це не лише «робити дрібно», а й «робити в правильному порядку». Якщо ви спочатку перенесете все за пакетами, потім почнете змінювати сигнатури, потім зрозумієте, що забули про парсинг, — ви отримаєте багато дрібних проблем, які неприємно розгрібати.

Хороша стратегія: спочатку стабілізувати межі дій (винести функції, зробити явні типи на кшталт ParsedCommand), потім стабілізувати місця відповідальності (маршрутизатор/обробник), і лише потім змінювати залежності та контракти (репозиторій, експортер тощо). Тоді кожна зміна простіше «приземляється».

Невеличка блок-схема (у стилі «куди рухатися, щоб не було боляче»):

flowchart TD
    A["Великий main: усе впереміш"] --> B["Винести дрібні функції: normalize/parse"]
    B --> C["Ввести явні типи: ParsedCommand"]
    C --> D["Винести роутинг команд: CommandRouter/Handler"]
    D --> E["Рознести за пакетами: app.cli / domain / storage"]
    E --> F["Замінити залежності: MutableList -> Repository"]
    F --> G["Стабілізувати формат результату команд (наступна лекція)"]

Зауважте: на кожному кроці можна зупинитися — проєкт усе одно компілюється й запускається. Це дуже заспокоює, особливо коли ви вже один раз пережили «усе переписав(ла), нічого не працює».

9. Типові помилки під час рефакторингу малими кроками

Помилка №1: «Я трошки рефакторнув(ла)» — і випадково змінив(ла) поведінку.
Це найчастіша історія: ви винесли функцію, заодно «поліпшили» умову, а потім зʼясувалося, що команда "add" почала приймати порожні рядки або, навпаки, відхиляти те, що раніше проходило. Лікується нудно, але ефективно: на кроці рефакторингу не поліпшуйте логіку. Поліпшення робіть окремим кроком — коли структура вже стала зрозумілою.

Помилка №2: переносити файли за пакетами й одночасно перейменовувати все підряд.
Коли ви рухаєте файл і перейменовуєте класи, IDE/компілятор «лається» з усіх боків, і ви не розумієте, що саме пішло не так: пакет? імпорт? імʼя? Правильніше зробити дві маленькі зміни: спочатку перенесення (домогтися компіляції), потім перейменування (домогтися компіляції). Так, це два коміти, зате без детектива.

Помилка №3: функцію «винесено», але залежність лишилася прихованою (тягне зовнішній var або глобальний стан).
Новачки часто виносять функцію fun addExpense() і всередині читають/пишуть у зовнішній список, який живе в main. Формально код став «за файлами красивішим», але фактично залежність лишилася неявною. Хороша ознака якісного кроку: функція приймає потрібні дані параметрами й повертає результат, а не «повзає» по зовнішньому світу.

Помилка №4: спроба впровадити інтерфейси одразу всюди — без перехідного шару.
Інтерфейси (Repository, Exporter) корисні, але якщо ви різко замінюєте «все й одразу», вам доведеться одночасно змінити конструктори, сигнатури, місця створення обʼєктів і порядок імпортів. Це і є «великий вибух». Значно спокійніше робити адаптацію через прошарок: спочатку додали репозиторій і підʼєднали його лише в сервіс, потім перевели CLI, а потім прибрали старі прямі залежності.

Помилка №5: передчасна «ідеальна архітектура».
Дуже хочеться після перших успіхів одразу зробити «як у великому проєкті»: 20 пакетів, 15 інтерфейсів, фабрики й ще один шар абстракції — «бо так роблять дорослі». Проблема в тому, що дорослі так роблять, коли це справді потрібно. У навчальному проєкті та на ранній версії v1 важливіше інше: щоб код було легко читати й розширювати, а не щоб він був схожий на діаграму з книжки.

Помилка №6: забувати про маленьку перевірку запуску після кожного кроку.
Коли ви захоплено переносите код, легко зробити 10 змін підряд і лише потім натиснути Run. Якщо воно не стартує, ви подумки відкочуєтеся на пів години назад і починаєте згадувати, що робили. Значно дешевше (і за часом, і за нервами) запускати частіше: після кожного помітного кроку.

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