Ранние return и меньше вложенности

Ранние return и меньше вложенности

Вложенность убивает читаемость. Чем больше if внутри if, тем сложнее мозгу держать всё в голове. Каждый уровень отступа - это ещё один контекст, который нужно помнить.

Антипаттерн «лесенка»

func HandlePurchase(ctx context.Context, req PurchaseRequest) error {
    if req.User != nil {
        if req.User.Active {
            if req.Amount > 0 {
                item, err := itemRepo.Get(ctx, req.ItemID)
                if err == nil {
                    if item.InStock {
                        // наконец-то основная логика
                        return processPurchase(ctx, req.User, item)
                    }
                }
            }
        }
    }
    return errors.New("bad request")
}
public function handlePurchase(PurchaseRequest $req): Result {
    if ($req->user !== null) {
        if ($req->user->active) {
            if ($req->amount > 0) {
                $item = $this->itemRepo->find($req->itemId);
                if ($item !== null) {
                    if ($item->inStock) {
                        // наконец-то основная логика
                        return $this->processPurchase($req->user, $item);
                    }
                }
            }
        }
    }
    throw new InvalidArgumentException('bad request');
}
async function handlePurchase(req) {
  if (req.user) {
    if (req.user.active) {
      if (req.amount > 0) {
        const item = await itemRepo.find(req.itemId);
        if (item) {
          if (item.inStock) {
            // наконец-то основная логика
            return processPurchase(req.user, item);
          }
        }
      }
    }
  }
  throw new Error('bad request');
}

В JS «лесенка» выглядит так же безнадёжно: основная логика утоплена в пять уровней if, а единственный throw снизу не объясняет, что именно пошло не так.

Проблемы: основная логика спрятана на 5-м уровне вложенности, ошибка "bad request" не говорит, что именно плохо, и чтобы добраться до processPurchase, нужно мысленно пройти все пять условий.

Guard clauses: проверяй и выходи

Вложенная лесенка if против плоских guard clauses, happy path внизу

Guard clause - это проверка в начале функции, которая сразу возвращает ошибку при невалидном состоянии:

func HandlePurchase(ctx context.Context, req PurchaseRequest) error {
    if req.User == nil {
        return errors.New("user is required")
    }
    if !req.User.Active {
        return errors.New("user is not active")
    }
    if req.Amount <= 0 {
        return errors.New("amount must be positive")
    }
    item, err := itemRepo.Get(ctx, req.ItemID)
    if err != nil {
        return fmt.Errorf("get item %d: %w", req.ItemID, err)
    }
    if !item.InStock {
        return errors.New("item is out of stock")
    }
    return processPurchase(ctx, req.User, item)
}
public function handlePurchase(PurchaseRequest $req): Result
{
    if ($req->user === null) {
        throw new InvalidArgumentException('user is required');
    }
    if (!$req->user->active) {
        throw new DomainException('user is not active');
    }
    if ($req->amount <= 0) {
        throw new InvalidArgumentException('amount must be positive');
    }
    $item = $this->itemRepo->find($req->itemId);
    if ($item === null) {
        throw new NotFoundException("item {$req->itemId} not found");
    }
    if (!$item->inStock) {
        throw new DomainException('item is out of stock');
    }
    return $this->processPurchase($req->user, $item);
}
async function handlePurchase(req) {
  if (!req.user) {
    throw new Error('user is required');
  }
  if (!req.user.active) {
    throw new Error('user is not active');
  }
  if (req.amount <= 0) {
    throw new Error('amount must be positive');
  }
  const item = await itemRepo.find(req.itemId);
  if (!item) {
    throw new Error(`item ${req.itemId} not found`);
  }
  if (!item.inStock) {
    throw new Error('item is out of stock');
  }
  return processPurchase(req.user, item);
}

В JS guard clauses работают так же: каждая проверка отдельным throw с конкретным сообщением, а основная логика остаётся на нулевом уровне отступа.

Читаешь сверху вниз и сразу видишь «условия входа». Не прошёл проверку - вернулись. Основная логика остаётся на нулевом уровне отступа.

Сравнение: вложенность vs ранний return

Вложенный стиль                  Ранний return
───────────────────────────────  ───────────────────────────────
if ok {                          if !ok { return err }
    if valid {                   if !valid { return err }
        if ready {               if !ready { return err }
            // работа            // работа
        }
    }
}

✗ Основная логика на уровне 3    ✓ Основная логика на уровне 0
✗ Ошибки - один общий return     ✓ Каждая ошибка - отдельная
✗ Сложно добавить проверку       ✓ Новая проверка - одна строка

Happy path - основной маршрут

Happy path - это путь выполнения при успешном сценарии. В чистом коде happy path должен идти по левому краю (минимальный отступ):

func CreateOrder(ctx context.Context, req OrderRequest) (*Order, error) {
    if err := validateOrder(req); err != nil {     // guard
        return nil, err
    }
    user, err := userRepo.Get(ctx, req.UserID)     // guard
    if err != nil {
        return nil, fmt.Errorf("get user: %w", err)
    }
    if !user.CanOrder() {                          // guard
        return nil, ErrUserCantOrder
    }

    // ← happy path идёт по левому краю
    order := NewOrder(user, req.Items)
    if err := orderRepo.Save(ctx, order); err != nil {
        return nil, fmt.Errorf("save order: %w", err)
    }
    notifier.OrderCreated(ctx, order)
    return order, nil
}
<?php
declare(strict_types=1);

final class OrderService
{
    public function __construct(
        private readonly OrderValidator $validator,
        private readonly UserRepository $users,
        private readonly OrderRepository $orders,
        private readonly OrderNotifier $notifier,
    ) {}

    public function createOrder(OrderRequest $req): Order
    {
        $this->validator->validate($req);                       // guard

        $user = $this->users->find($req->userId);               // guard
        if ($user === null) {
            throw new NotFoundException("user {$req->userId} not found");
        }
        if (!$user->canOrder()) {                               // guard
            throw new DomainException('user cannot order');
        }

        // happy path по левому краю
        $order = Order::create($user, $req->items);
        $this->orders->save($order);
        $this->notifier->orderCreated($order);

        return $order;
    }
}

Правило: ошибки - в отступе, успех - по краю. Если при чтении функции ты видишь, что левый край - это успешный сценарий, а все блоки с отступом - обработка ошибок, значит функция хорошо структурирована.

Идиома Go: if err != nil

В Go паттерн раннего return встроен в культуру языка (подробно об ошибках - в следующем уроке и в треке Go: errors):

func SaveReport(ctx context.Context, report Report) error {
    data, err := json.Marshal(report)
    if err != nil {
        return fmt.Errorf("marshal report: %w", err)
    }
    path := filepath.Join(reportsDir, report.ID+".json")
    if err := os.WriteFile(path, data, 0644); err != nil {
        return fmt.Errorf("write %s: %w", path, err)
    }
    if err := index.Add(ctx, report.ID, path); err != nil {
        return fmt.Errorf("index report %s: %w", report.ID, err)
    }
    return nil
}
<?php
declare(strict_types=1);

// PHP-эквивалент: typed exceptions с контекстом + previous для wrap
final class ReportService
{
    public function __construct(
        private readonly ReportIndex $index,
        private readonly string $reportsDir,
    ) {}

    public function saveReport(Report $report): void
    {
        try {
            $data = json_encode($report, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new \RuntimeException('marshal report: ' . $e->getMessage(), previous: $e);
        }

        $path = $this->reportsDir . '/' . $report->id . '.json';
        if (file_put_contents($path, $data, LOCK_EX) === false) {
            throw new \RuntimeException("write {$path} failed");
        }

        try {
            $this->index->add($report->id, $path);
        } catch (\Throwable $e) {
            throw new \RuntimeException("index report {$report->id}", previous: $e);
        }
    }
}

Это не бойлерплейт - это явная обработка каждого шага. Альтернатива (try/catch) скрывает, какой именно шаг упал.

Guard clauses - для проверки **предусловий** (входные данные, состояние). Не используй ранний return чтобы пропустить основную логику по условию - это уже другой паттерн ([Strategy](../gof/03-strategy.md) или полиморфизм).

Когда вложенность допустима

Не все if внутри if - зло. Вложенность нормальна, когда блоки семантически связаны:

// Вложенность оправдана: транзакция с commit/rollback
func Transfer(ctx context.Context, from, to int, amount int) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    if err = debit(tx, from, amount); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if err = credit(tx, to, amount); err != nil {
        return fmt.Errorf("credit: %w", err)
    }
    return tx.Commit()
}
<?php
declare(strict_types=1);

// PHP-аналог через Doctrine DBAL transactional() - функциональная вложенность оправдана:
final class TransferService
{
    public function __construct(
        private readonly \Doctrine\DBAL\Connection $db,
        private readonly AccountRepository $accounts,
    ) {}

    public function transfer(int $fromId, int $toId, int $amount): void
    {
        $this->db->transactional(function () use ($fromId, $toId, $amount): void {
            try {
                $this->accounts->debit($fromId, $amount);
            } catch (\Throwable $e) {
                throw new \RuntimeException('debit: ' . $e->getMessage(), previous: $e);
            }

            try {
                $this->accounts->credit($toId, $amount);
            } catch (\Throwable $e) {
                throw new \RuntimeException('credit: ' . $e->getMessage(), previous: $e);
            }
            // commit автоматически, rollback при любом throw
        });
    }
}

Ключевой вопрос: связаны ли вложенные блоки семантически? Если да (транзакция, resource cleanup) - вложенность оправдана. Если нет (независимые проверки) - используй guard clauses.

Таблицы решений вместо лесенки if

Когда условий много и они независимы, вместо лесенки используй таблицу:

// Плохо: лесенка условий
func CalculateDiscount(user User, order Order) float64 {
    if user.IsPremium {
        if order.Total > 10000 {
            return 0.15
        }
        return 0.10
    }
    if order.Total > 10000 {
        return 0.05
    }
    return 0
}

// Лучше: таблица решений - легко расширять
type discountRule struct {
    premium  bool
    minTotal int
    discount float64
}

var discountRules = []discountRule{
    {premium: true, minTotal: 10000, discount: 0.15},
    {premium: true, minTotal: 0, discount: 0.10},
    {premium: false, minTotal: 10000, discount: 0.05},
}

func CalculateDiscount(user User, order Order) float64 {
    for _, r := range discountRules {
        if user.IsPremium == r.premium && order.Total >= r.minTotal {
            return r.discount
        }
    }
    return 0
}
<?php
declare(strict_types=1);

// Плохо: лесенка условий
final class BadDiscountCalculator
{
    public function calculate(User $user, Order $order): float
    {
        if ($user->isPremium) {
            if ($order->total > 10000) {
                return 0.15;
            }
            return 0.10;
        }
        if ($order->total > 10000) {
            return 0.05;
        }
        return 0.0;
    }
}

// Лучше: таблица правил - расширяется добавлением одной строки
final readonly class DiscountRule
{
    public function __construct(
        public bool $premium,
        public int $minTotal,
        public float $discount,
    ) {}
}

final class DiscountCalculator
{
    /** @var list<DiscountRule> */
    private array $rules;

    public function __construct()
    {
        $this->rules = [
            new DiscountRule(premium: true, minTotal: 10000, discount: 0.15),
            new DiscountRule(premium: true, minTotal: 0, discount: 0.10),
            new DiscountRule(premium: false, minTotal: 10000, discount: 0.05),
        ];
    }

    public function calculate(User $user, Order $order): float
    {
        foreach ($this->rules as $rule) {
            if ($user->isPremium === $rule->premium && $order->total >= $rule->minTotal) {
                return $rule->discount;
            }
        }
        return 0.0;
    }
}

Мини-задание

  • Найди функцию с 3+ уровнями вложенности
  • Перепиши с guard clauses - оставь happy path на нулевом уровне
  • Проверь: каждая ошибка имеет уникальное сообщение?
  • Убедись, что тесты проходят после рефакторинга

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