Ранние 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: проверяй и выходи
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) скрывает, какой именно шаг упал.
Когда вложенность допустима
Не все 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 на нулевом уровне
- Проверь: каждая ошибка имеет уникальное сообщение?
- Убедись, что тесты проходят после рефакторинга