Рефакторинг без страха: безопасные шаги

Рефакторинг - это улучшение внутренней структуры кода без изменения поведения.

Секрет: делай маленькими шагами, и будет безопасно.

Знакомая картина: открываешь проект, функция на 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 - убедись, что все использования обновились
  • Найди магическое число и замени на именованную константу
  • Проверь: есть ли тест для кода, который рефакторишь? Если нет - напиши

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.