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

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

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

1. Введение

Рефакторинг — это когда вы меняете структуру кода, но стараетесь не менять его смысл (поведение). Звучит просто, но на практике мозг очень любит «ну раз уж трогаю — давайте ещё и улучш…». И вот вы уже одновременно переносите файлы, переименовываете сущности, меняете формат сообщений и переписываете алгоритм. Это как во время уборки решить заодно «а давайте ещё стену снесём» — и внезапно вы живёте на стройке.

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

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

fun main() {
    val items = mutableListOf<Pair<String, Int>>() // "title" to 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("Added: $title ($amount)") // Added: Coffee (200)
            } else {
                println("Bad input")
            }
        } else if (line == "list") {
            for ((t, a) in items) println("$t: $a")
        } else {
            println("Unknown command")
        }
    }
}

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

2. Дисциплина малых шагов

Когда говорят «делай маленькие шаги», это звучит как совет уровня «пейте воду» — вроде правильно, но как именно? Давайте превратим это в конкретное правило, которое можно применять буквально руками. После каждого изменения вы должны иметь возможность: (1) собрать проект без ошибок, (2) запустить программу, (3) быстро проверить, что базовые сценарии не выглядят сломанными.

Инвариант компиляции

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

Инвариант смысла

Второе — инвариант смысла: на шаге рефакторинга вы стараетесь не менять поведение. Если поведение всё-таки меняете — делайте это осознанно и отдельно. Иначе получится классика: «Я просто переименовал переменную, но почему-то теперь "add" добавляет отрицательные суммы…».

Ещё одна практичная штука: используйте встроенные проверки предусловий (require, check) там, где это помогает сделать контракт явным. В Kotlin require() удобно использовать для валидации входных аргументов (выбрасывает IllegalArgumentException), а check() — для проверки состояния (выбрасывает IllegalStateException). Это не «магия», а способ сделать ошибку громкой и понятной.

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

Сейчас мы сознательно не уходим в тестовые фреймворки и юнит-тесты (это отдельная большая тема), но совсем без страховки рефакторинг превращается в игру «угадай, где сломалось». Поэтому в консольном проекте удобно иметь пару стабильных «ручных» сценариев проверки — не как «домашку», а как вашу личную систему координат.

Идея такая: вы держите в голове (или в заметке) 3–4 команды, которые быстро прогоняются после каждого шага, например: добавить расход, вывести список, попробовать некорректный ввод, выйти. Это занимает 15–30 секунд, зато даёт уверенность: «я не сломал всё и сразу». В реальной разработке это заменяется автоматическими тестами, но до них нам важно научиться дисциплине маленьких проверок.

В рефакторинге особенно помогает привычка «не трогать сразу всё». Например, если вы хотите вынести парсинг команд в функцию, не надо одновременно менять формат команд. Сначала вынесли — убедились, что работает — потом улучшаете формат.

Как планировать последовательность шагов

Рефакторинг — это не только «делать мелко», но и «делать в правильном порядке». Если вы сначала перенесёте всё по пакетам, потом начнёте менять сигнатуры, потом поймёте, что забыли про парсинг — вы получите много мелких проблем, которые неприятно разгребать.

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

Небольшая блок-схема (в стиле «куда двигаться, чтобы не было больно»):

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("Continue...") // Continue...
}

И вот здесь начинается магия читабельности: 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" -> "Commands: help, add, list, exit"
            else -> "Unknown command: ${cmd.name}"
        }
}

when в Kotlin может быть выражением и возвращать значение, что как раз помогает писать такие функции компактно и читабельно.

И main превращается в простой сценарий:

fun main() {
    val router = CommandRouter()

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

    println(message) // например: Commands: 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 must not be empty" }
        require(amount > 0) { "amount must be > 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), потом стабилизировать места ответственности (роутер/handler), и только потом менять зависимости и контракты (репозиторий, экспортер и т.д.). Тогда каждое изменение проще «приземляется».

Небольшая блок-схема (в стиле «куда двигаться, чтобы не было больно»):

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. Если оно не стартует — вы откатываетесь мысленно на полчаса назад и начинаете «вспоминать, что делал». Куда дешевле (и по времени, и по нервам) запускать чаще: после каждого заметного шага.

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