Рефакторинг без страха: безопасные шаги
Рефакторинг - это улучшение внутренней структуры кода без изменения поведения.
Секрет: делай маленькими шагами, и будет безопасно.
Знакомая картина: открываешь проект, функция на 800 строк, тестов ноль, единственный комментарий - // не трогать, хз как оно работает. Это легаси, и оно душит: правишь одно - отваливается три, автор давно уволился, доки нет.
Плохая новость: такое есть у всех, даже в крутых командах. Хорошая: выбраться можно, и это точно не «переписать всё с нуля» - так почти всегда делать нельзя, станет только хуже.
Выбираются маленькими шагами: сначала оборачиваешь кусок тестами, чтобы поймать себя, если сломаешь. Потом аккуратно рефакторишь. Потом берёшься за следующий кусок.
Чистый код - это забота о себе будущем, который откроет этот файл через полгода. А тот, кто откроет, скорее всего ты сам.
Безопасный алгоритм рефакторинга
1. Добавь тест (или убедись, что он есть)
2. Сделай ОДНО маленькое изменение
3. Запусти тесты
4. Если зелёные - закоммить
5. Если красные - откати и разберись
6. Повтори
Каждый шаг - атомарный коммит. Если что-то пошло не так - откатываешь один коммит, а не десять.
Каталог рефакторингов
Мартин Фаулер описал десятки приёмов рефакторинга. Вот самые полезные для повседневной работы:
Extract Function (Вынос функции)
Самый частый и самый полезный приём (подробно - в уроке про маленькие функции):
// До: блок кода с комментарием
func HandleOrder(ctx context.Context, order Order) error {
// validate
if order.Total <= 0 {
return ErrInvalidTotal
}
if len(order.Items) == 0 {
return ErrNoItems
}
// process
if err := repo.Save(ctx, order); err != nil {
return err
}
notifier.Send(ctx, order)
return nil
}
// После: комментарий стал функцией
func HandleOrder(ctx context.Context, order Order) error {
if err := validateOrder(order); err != nil {
return err
}
if err := repo.Save(ctx, order); err != nil {
return err
}
notifier.Send(ctx, order)
return nil
}
func validateOrder(order Order) error {
if order.Total <= 0 { return ErrInvalidTotal }
if len(order.Items) == 0 { return ErrNoItems }
return nil
}
<?php
declare(strict_types=1);
// До: блок кода с комментарием
final class OrderHandler
{
public function handle(Order $order): void
{
// validate
if ($order->total <= 0) {
throw new \DomainException('invalid total');
}
if ($order->items === []) {
throw new \DomainException('no items');
}
// process
$this->repo->save($order);
$this->notifier->send($order);
}
}
// После: комментарий стал методом
final class OrderHandler
{
public function handle(Order $order): void
{
$this->validateOrder($order);
$this->repo->save($order);
$this->notifier->send($order);
}
private function validateOrder(Order $order): void
{
if ($order->total <= 0) {
throw new \DomainException('invalid total');
}
if ($order->items === []) {
throw new \DomainException('no items');
}
}
}
Rename (Переименование)
Самый безопасный рефакторинг - IDE делает за тебя:
// До
func proc(d []byte) error { ... }
// После: одно нажатие F2 в IDE
func processWebhookPayload(data []byte) error { ... }
<?php
declare(strict_types=1);
// До
function proc(string $d): void { /* ... */ }
// После: F2 / Shift+F6 в PhpStorm
function processWebhookPayload(string $data): void { /* ... */ }
Replace Nested Conditional with Guard Clauses
Уже знакомый паттерн из урока про ранние return:
// До: лесенка
if user != nil {
if user.Active {
return doWork(user)
}
}
return ErrBadUser
// После: guard clauses
if user == nil { return ErrNoUser }
if !user.Active { return ErrInactive }
return doWork(user)
<?php
declare(strict_types=1);
// До: лесенка
if ($user !== null) {
if ($user->active) {
return $this->doWork($user);
}
}
throw new \DomainException('bad user');
// После: guard clauses
if ($user === null) {
throw new UserNotFoundException();
}
if (!$user->active) {
throw new UserInactiveException();
}
return $this->doWork($user);
Здесь хорошо видно различие в подходе к ошибкам: Go возвращает sentinel-error значением, PHP бросает доменное исключение. Семантика одинакова (контролируемый ранний выход), идиоматика разная.
Replace Conditional with Polymorphism
Если у тебя ветвление по типу или статусу - часто это сигнал к замене на интерфейс + dispatch. В PHP с PHP 8 это пишется через match + interface:
<?php
declare(strict_types=1);
// До: switch размазан по коду, сложно добавить новый тип платежа
final class PaymentProcessor
{
public function process(Order $order): void
{
switch ($order->paymentType) {
case 'card':
$this->chargeCard($order);
break;
case 'paypal':
$this->chargePaypal($order);
break;
case 'crypto':
$this->chargeCrypto($order);
break;
default:
throw new \DomainException('unknown payment type');
}
}
}
// После: интерфейс + match для выбора, расширяется без правки PaymentProcessor
interface PaymentGateway
{
public function charge(Order $order): void;
}
final readonly class CardGateway implements PaymentGateway { /* ... */ }
final readonly class PaypalGateway implements PaymentGateway { /* ... */ }
final readonly class CryptoGateway implements PaymentGateway { /* ... */ }
final readonly class PaymentProcessor
{
/**
* @param array<string, PaymentGateway> $gateways
*/
public function __construct(private array $gateways) {}
public function process(Order $order): void
{
$gateway = $this->gateways[$order->paymentType]
?? throw new \DomainException('unknown payment type: ' . $order->paymentType);
$gateway->charge($order);
}
}
В Symfony это делается через tagged services - все реализации PaymentGateway собираются в коллекцию автоматически, без ручного перечисления в конструкторе.
Introduce Parameter Object
Когда функция принимает слишком много аргументов:
// До: 5 параметров
func CreateReport(title string, from, to time.Time, format string, compress bool) error
// После: структура-параметр
type ReportOptions struct {
Title string
From time.Time
To time.Time
Format string
Compress bool
}
func CreateReport(opts ReportOptions) error
<?php
declare(strict_types=1);
// До: 5 параметров
function createReport(
string $title,
\DateTimeImmutable $from,
\DateTimeImmutable $to,
string $format,
bool $compress,
): void { /* ... */ }
// После: DTO-параметр (named arguments или конструктор)
final readonly class ReportOptions
{
public function __construct(
public string $title,
public \DateTimeImmutable $from,
public \DateTimeImmutable $to,
public string $format,
public bool $compress,
) {}
}
function createReport(ReportOptions $opts): void { /* ... */ }
Replace Magic Number with Constant
// До: что значит 86400?
cache.Set(key, value, 86400)
// После: понятно
const CacheTTL = 24 * time.Hour
cache.Set(key, value, CacheTTL)
<?php
declare(strict_types=1);
// До: что значит 86400?
$cache->setex($key, 86400, $value);
// После: понятно
final class CacheConfig
{
public const int DEFAULT_TTL_SECONDS = 86400; // 24 hours
}
$cache->setex($key, CacheConfig::DEFAULT_TTL_SECONDS, $value);
Безопасный рефакторинг с тестами
Тесты - страховочная сеть рефакторинга. Без тестов рефакторинг - это «надеюсь, не сломал».
С той самой функцией на 800 строк начинают именно отсюда: не улучшать, а сначала обложить тестами то, что боишься тронуть.
Шаг Без тестов С тестами
────────────────────────── ────────────────── ──────────────────
1. Изменил код «Вроде работает» go test → зелёные
2. Нашёл баг через 2 дня «Когда сломалось?» «Тесты красные с коммита X»
3. Откатил «Что откатить?» git revert <коммит X>
Если тестов нет - напиши тест до рефакторинга:
// 1. Сначала пишем тест, который фиксирует текущее поведение
func TestCalculatePrice_LegacyBehavior(t *testing.T) {
got := calculatePrice(Order{Items: 3, Discount: 0.1})
assert.Equal(t, 270.0, got)
}
// 2. Теперь можно рефакторить calculatePrice - тест поймает регрессию
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class CalculatePriceTest extends TestCase
{
// 1. Сначала пишем тест, который фиксирует текущее поведение
public function testLegacyBehavior(): void
{
$order = new Order(items: 3, discount: 0.1);
self::assertSame(270.0, calculatePrice($order));
}
// 2. Теперь можно рефакторить calculatePrice - тест поймает регрессию
}
IDE-поддержка рефакторинга
Современные IDE (GoLand, VS Code с gopls) умеют делать рефакторинг автоматически:
Операция GoLand VS Code
───────────────────────── ────────────── ──────────────
Rename Shift+F6 F2
Extract Function Ctrl+Alt+M Extract to func
Extract Variable Ctrl+Alt+V Extract variable
Inline Variable Ctrl+Alt+N -
Move to File F6 -
Find Usages Alt+F7 Shift+F12
Преимущество IDE-рефакторинга: она обновляет все использования, а не только текущий файл. Ручной поиск-замена легко пропускает случаи.
Когда НЕ рефакторить
Ситуация Что делать вместо
─────────────────────────────────── ──────────────────────────
Код работает и его не трогают Оставь в покое
Нет тестов и нет времени их писать Сначала тесты, потом рефактор
Дедлайн завтра Зафиксируй TODO, вернись позже
Хочется «просто улучшить» Правило бойскаута, не перелопачивание
А комментарий // не трогать, хз как оно работает в этот список не входит. Это не запрет, это метка: сюда в первую очередь нужны тесты.
Мини-задание
- Выбери один файл и сделай 5 маленьких рефакторингов (каждый - отдельный коммит)
- Используй IDE для Rename - убедись, что все использования обновились
- Найди магическое число и замени на именованную константу
- Проверь: есть ли тест для кода, который рефакторишь? Если нет - напиши