Маленькие функции и один смысл

Маленькие функции и один смысл

Функция должна делать одну вещь. Если она делает две - это уже две функции.

Это не прихоть - это самый эффективный способ контролировать сложность. Маленькую функцию проще прочитать, протестировать, переиспользовать и заменить. На уровне модулей этот же принцип называется SRP - Single Responsibility.

Признаки «слишком большой функции»

Сигнал                                  Что делать
──────────────────────────────────────  ──────────────────────────
В ней есть «и ещё вот это»              Вынести в отдельную функцию
Больше 40-60 строк                      Разбить на шаги
Много уровней if/for                    Guard clauses + вынос
Она и валидирует, и сохраняет,          Каждое действие - отдельно
  и шлёт уведомления
Нужен комментарий-разделитель           Вместо «// шаг 1» - функция
  «// шаг 1», «// шаг 2»                 с понятным именем

Плохой пример: монолитная функция

func RegisterUser(ctx context.Context, req RegisterRequest) error {
    // валидация
    if req.Email == "" {
        return errors.New("email required")
    }
    if len(req.Password) < 8 {
        return errors.New("password too short")
    }
    // хеширование
    hash, err := bcrypt.GenerateFromPassword(
        []byte(req.Password), bcrypt.DefaultCost,
    )
    if err != nil {
        return fmt.Errorf("hash password: %w", err)
    }
    // сохранение
    user := User{Email: req.Email, PasswordHash: string(hash)}
    if err := db.Create(&user).Error; err != nil {
        return fmt.Errorf("create user: %w", err)
    }
    // письмо
    msg := fmt.Sprintf("Welcome, %s!", req.Email)
    if err := smtp.Send(req.Email, "Welcome", msg); err != nil {
        log.Printf("failed to send welcome email: %v", err)
    }
    // аналитика
    analytics.Track("user_registered", map[string]string{
        "email": req.Email,
    })
    return nil
}
public function registerUser(RegisterRequest $req): void {
    // валидация
    if ($req->email === '') {
        throw new InvalidArgumentException('email required');
    }
    if (strlen($req->password) < 8) {
        throw new InvalidArgumentException('password too short');
    }
    // хеширование
    $hash = password_hash($req->password, PASSWORD_BCRYPT);
    // сохранение
    $this->db->prepare('INSERT INTO users(email, password_hash) VALUES (?, ?)')
 ->execute([$req->email, $hash]);
    // письмо
    try {
        $this->mailer->send($req->email, 'Welcome', "Welcome, {$req->email}!");
    } catch (Throwable $e) {
        $this->logger->warning('welcome email failed', ['err' => $e->getMessage()]);
    }
    // аналитика
    $this->analytics->track('user_registered', ['email' => $req->email]);
}
async function registerUser(req) {
  // валидация
  if (!req.email) {
    throw new Error('email required');
  }
  if ((req.password ?? '').length < 8) {
    throw new Error('password too short');
  }
  // хеширование
  const hash = await bcrypt.hash(req.password, 10);
  // сохранение
  const user = await db.users.create({
    data: { email: req.email, passwordHash: hash },
  });
  // письмо
  try {
    await mailer.send(req.email, 'Welcome', `Welcome, ${req.email}!`);
  } catch (e) {
    logger.warn('welcome email failed', { err: e?.message });
  }
  // аналитика
  analytics.track('user_registered', { email: req.email });
  return user;
}

В JS та же история: функция валидирует, хеширует, сохраняет, шлёт письмо и трекает - пять обязанностей в одном async-методе.

Эта функция делает пять разных вещей. Если сломается отправка письма - ты будешь дебажить 40-строчную функцию вместо 5-строчной.

Лучше: оркестрация + делегирование

Длинная функция processOrder против короткого оркестратора и четырёх делегатов

func RegisterUser(ctx context.Context, req RegisterRequest) error {
    if err := validateRegistration(req); err != nil {
        return err
    }
    user, err := createUser(ctx, req)
    if err != nil {
        return err
    }
    sendWelcomeEmail(user)
    trackRegistration(user)
    return nil
}
public function registerUser(RegisterRequest $req): void
{
    $this->validateRegistration($req);
    $user = $this->createUser($req);
    $this->sendWelcomeEmail($user);
    $this->trackRegistration($user);
}
async function registerUser(req) {
  validateRegistration(req);
  const user = await createUser(req);
  await sendWelcomeEmail(user);
  trackRegistration(user);
  return user;
}

Функция превратилась в оркестратор: каждая строка - отдельный шаг с осмысленным именем, легко мокается и тестируется.

Теперь registerUser читается как оглавление. Каждый шаг - отдельная функция, которую можно тестировать независимо.

Если ты можешь прочитать вызовы в функции как оглавление книги - ты победил. Читатель видит **что** происходит, и лезет в детали только когда нужно.

Правило одного уровня абстракции

Внутри функции все операции должны быть на одном уровне абстракции. Нельзя мешать бизнес-логику с деталями реализации:

// Плохо: мешаем уровни абстракции
func PlaceOrder(ctx context.Context, order Order) error {
    if order.Total() > 0 {                     // бизнес-логика
        tx := db.Begin()                        // детали БД
        if err := tx.Create(&order).Error;      // детали ORM
           err != nil {
            tx.Rollback()
            return err
        }
        tx.Commit()
        smtp.Send(order.Email, "Order placed",  // детали SMTP
            fmt.Sprintf("Order #%d", order.ID))
    }
    return nil
}

// Хорошо: один уровень абстракции
func PlaceOrder(ctx context.Context, order Order) error {
    if err := validateOrder(order); err != nil {
        return err
    }
    if err := orderRepo.Save(ctx, order); err != nil {
        return fmt.Errorf("save order: %w", err)
    }
    notifier.OrderPlaced(ctx, order)
    return nil
}
<?php
declare(strict_types=1);

// Плохо: смешаны бизнес-логика, Doctrine и SMTP в одном методе
final class BadOrderService
{
    public function placeOrder(Order $order): void
    {
        if ($order->total() > 0) {                        // бизнес-логика
            $this->em->getConnection()->beginTransaction(); // детали Doctrine
            try {
                $this->em->persist($order);                 // детали ORM
                $this->em->flush();
                $this->em->getConnection()->commit();
            } catch (\Throwable $e) {
                $this->em->getConnection()->rollBack();
                throw $e;
            }
            $this->mailer->send(                            // детали SMTP
                $order->email(),
                'Order placed',
                'Order #' . $order->id(),
            );
        }
    }
}

// Хорошо: один уровень абстракции - оркестратор
final class OrderService
{
    public function __construct(
        private readonly OrderValidator $validator,
        private readonly OrderRepository $orders,
        private readonly OrderNotifier $notifier,
    ) {}

    public function placeOrder(Order $order): void
    {
        $this->validator->validate($order);
        $this->orders->save($order);
        $this->notifier->orderPlaced($order);
    }
}

Extract Method: механика выноса

Когда видишь блок с комментарием-разделителем - это сигнал для Extract Method:

// До: комментарий-разделитель = скрытая функция
func ProcessPayment(ctx context.Context, p Payment) error {
    // - validate ---
    if p.Amount <= 0 {
        return ErrInvalidAmount
    }
    if p.Currency == "" {
        p.Currency = "RUB"
    }

    // - charge ---
    resp, err := gateway.Charge(p.Amount, p.Currency)
    if err != nil {
        return fmt.Errorf("charge: %w", err)
    }

    // - save receipt ---
    receipt := Receipt{PaymentID: p.ID, GatewayRef: resp.Ref}
    return receiptRepo.Save(ctx, receipt)
}

// После: каждый блок стал функцией
func ProcessPayment(ctx context.Context, p Payment) error {
    p, err := normalizePayment(p)
    if err != nil {
        return err
    }
    resp, err := chargePayment(p)
    if err != nil {
        return err
    }
    return saveReceipt(ctx, p.ID, resp.Ref)
}
<?php
declare(strict_types=1);

// До: блоки с комментариями = скрытые методы
final class BadPaymentService
{
    public function processPayment(Payment $p): void
    {
        // --- validate ---
        if ($p->amount <= 0) {
            throw new \InvalidArgumentException('amount must be positive');
        }
        if ($p->currency === '') {
            $p->currency = 'RUB';
        }

        // --- charge ---
        $resp = $this->gateway->charge($p->amount, $p->currency);

        // --- save receipt ---
        $receipt = new Receipt($p->id, $resp->ref);
        $this->receipts->save($receipt);
    }
}

// После: каждый блок - приватный метод с осмысленным именем
final class PaymentService
{
    public function processPayment(Payment $p): void
    {
        $p = $this->normalizePayment($p);
        $resp = $this->chargePayment($p);
        $this->saveReceipt($p->id, $resp->ref);
    }

    private function normalizePayment(Payment $p): Payment
    {
        if ($p->amount <= 0) {
            throw new \InvalidArgumentException('amount must be positive');
        }
        return $p->withCurrency($p->currency ?: 'RUB');
    }
}

Цикломатическая сложность

Цикломатическая сложность (CC) - количество независимых путей через функцию. Каждый if, for, case, &&, || добавляет +1.

CC     Уровень           Что делать
────   ──────────────    ────────────────────────────
1-5    Простая            Всё хорошо
6-10   Умеренная          Подумай о разбиении
11-20  Сложная            Разбей обязательно
21+    Очень сложная      Срочный рефакторинг

В Go golangci-lint проверяет CC автоматически:

# .golangci.yml
linters-settings:
  cyclop:
    max-complexity: 10
CC = 12 в функции-валидаторе с десятью guard clauses - терпимо. CC = 8 в функции с тремя вложенными циклами - проблема. Смотри на **когнитивную** сложность, а не только на числа.

Сколько параметров у функции?

Идеально - 0-2. Три - допустимо. Больше трёх - сигнал:

// Плохо: 5 параметров, легко перепутать порядок
func CreateUser(name, email, phone string, age int, active bool) error

// Лучше: структура-параметр
type CreateUserInput struct {
    Name   string
    Email  string
    Phone  string
    Age    int
    Active bool
}
func CreateUser(input CreateUserInput) error
<?php
declare(strict_types=1);

// Плохо: 5 позиционных параметров - легко перепутать
final class BadUserService
{
    public function createUser(
        string $name,
        string $email,
        string $phone,
        int $age,
        bool $active,
    ): void {
        // ...
    }
}
// Вызов: createUser('Alice', 'a@x.io', '+7...', 25, true) - порядок критичен

// Лучше: readonly DTO с именованными аргументами (named arguments PHP 8.0+)
final readonly class CreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
        public string $phone,
        public int $age,
        public bool $active,
    ) {}
}

final class UserService
{
    public function createUser(CreateUserInput $input): void
    {
        // ...
    }
}

// Вызов с именованными аргументами - порядок не важен:
$svc->createUser(new CreateUserInput(
    name: 'Alice',
    email: 'a@example.com',
    phone: '+7...',
    age: 25,
    active: true,
));

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

  • Найди функцию > 60 строк в своём проекте
  • Определи, сколько «обязанностей» она выполняет
  • Вынеси 2-3 блока в отдельные функции (Extract Method)
  • Проверь: читается ли основная функция как оглавление?

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