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. Если оно не стартует — вы откатываетесь мысленно на полчаса назад и начинаете «вспоминать, что делал». Куда дешевле (и по времени, и по нервам) запускать чаще: после каждого заметного шага.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ