Применение PHP 8 в паттернах проектирования

Раздел: Программирование на PHP -> Паттерны проектирования в PHP

Современные версии PHP (8.0 и выше) предлагают разработчикам новые инструменты для реализации паттернов проектирования. Readonly-свойства, union types, атрибуты, выражение match и другие возможности позволяют писать более лаконичный, типобезопасный и поддерживаемый код. В этой статье рассматриваются различные подходы к созданию и выбору объектов на примере паттерна Фабричный метод.

Основное решение: фабрика с match и enum

Наиболее эффективный способ реализовать фабрику в PHP 8 - использовать enum (доступен с 8.1) вместе с выражением match. Это даёт максимальную типобезопасность, исключает необходимость в длинных цепочках if/else и делает код легко расширяемым без нарушения принципа открытости/закрытости.


<?php

enum PaymentType: string {
    case CreditCard = 'credit_card';
    case PayPal = 'paypal';
    case BankTransfer = 'bank_transfer';
}

interface PaymentProcessor {
    public function process(float $amount): string;
}

class CreditCardProcessor implements PaymentProcessor {
    public function process(float $amount): string {
        return "Оплата {$amount} через кредитную карту.";
    }
}

class PayPalProcessor implements PaymentProcessor {
    public function process(float $amount): string {
        return "Оплата {$amount} через PayPal.";
    }
}

class BankTransferProcessor implements PaymentProcessor {
    public function process(float $amount): string {
        return "Оплата {$amount} через банковский перевод.";
    }
}

class PaymentFactory {
    public function create(PaymentType $type): PaymentProcessor {
        return match ($type) {
            PaymentType::CreditCarq => new CreditCardProcessor(),
            PaymentType::PayPal => new PayPalProcessor(),
            PaymentType::BankTransfer => new BankTransferProcessor(),
        };
    }
}

// Использование
$factory = new PaymentFactory();
$processor = $factory->create(PaymentType::CreditCarq);
echo $processor->process(100.50);

Php 8 объекты шаблоны (объекты и шаблоны php 8)

Оплата 100.5 через кредитную карту.

Выражение match гарантирует, что каждый кейс enum обработан, а union type в параметре factory исключает передачу неверных значений. Ошибка на этапе компиляции отлавливается статическим анализатором. Фабрика легко расширяется: достаточно добавить новый case в enum и соответствующий класс, а затем дописать одну строку в match.

Типичные ошибки:

  • Забыть обработать новый case в match - компилятор не выдаст ошибки, но во время выполнения возникнет исключение UnhandledMatchError.
  • Передача строки вместо enum - match не сможет сопоставить, если аргумент не строго типизирован. Решение: всегда использовать enum в параметрах фабрики.

Варианты решения

Вариант 1: как реализовать фабрику без enum (на основе строковых констант)?

До появления enum в PHP 8.1 часто использовали константы класса или простые строки. Такой подход менее типобезопасен и подвержен опечаткам.


<?php

class PaymentFactoryOld {
    const CREDIT_CARD = 'credit_card';
    const PAYPAL = 'paypal';
    const BANK_TRANSFER = 'bank_transfer';

    public function create(string $type): PaymentProcessor {
        return match ($type) {
            self::CREDIT_CARD => new CreditCardProcessor(),
            self::PAYPAL => new PayPalProcessor(),
            self::BANK_TRANSFER => new BankTransferProcessor(),
            default => throw new InvalidArgumentException("Unknown type: $type"),
        };
    }
}

Здесь match используется с default-веткой, что чревато скрытыми ошибками - можно передать произвольную строку, и исключение возникнет только во время выполнения. Кроме того, IDE не подскажет доступные варианты автоматически.

Проблемы: отсутствие автодополнения, риск опечатки, необходимость вручную синхронизировать константы и их использование. При добавлении нового типа нужно не забыть обновить константу и match.

Вариант 2: как сделать выбор реализации через интерфейсы и полиморфизм (паттерн Стратегия)?

Вместо фабрики можно передавать реализацию PaymentProcessor напрямую как зависимость. Это чаще всего используется с внедрением зависимостей (DI).


<?php

class OrderService {
    public function __construct(
        private PaymentProcessor $processor
    ) {}

    public function checkout(float $amount): string {
        return $this->processor->process($amount);
    }
}

// Использование
$order = new OrderService(new PayPalProcessor());
echo $order->checkout(200);
Оплата 200 через PayPal.

Такой подход полностью избавляет от фабрики и делает систему гибкой. Однако он требует, чтобы выбор конкретной стратегии производился на этапе сборки (в контейнере DI). Если логика выбора сложная (зависит от данных или контекста), этот вариант становится неудобным.

Проблемы: увеличение количества классов, необходимость конфигурирования DI-контейнера. При динамическом выборе (например, на основе значения из базы данных) придётся внедрять фабрику дополнительно.

Вариант 3: как автоматизировать создание объектов с помощью атрибутов (метапрограммирование)?

PHP 8 ввёл атрибуты, которые можно использовать для аннотирования классов метками. Например, для автоматической регистрации процессоров в фабрике.


<?php

#[\Attribute(\Attribute::TARGET_CLASS)]
class AsPaymentProcessor {
    public function __construct(
        public PaymentType $type
    ) {}
}

#[AsPaymentProcessor(PaymentType::CreditCarq)]
class CreditCardProcessor implements PaymentProcessor { ... }

#[AsPaymentProcessor(PaymentType::PayPal)]
class PayPalProcessor implements PaymentProcessor { ... }

class AnnotatedPaymentFactory {
    /** @var array<string, PaymentProcessor> */
    private array $processors = [];

    public function __construct() {
        // Сканирование классов (здесь упрощённый пример с предопределённым списком)
        $classes = [CreditCardProcessor::class, PayPalProcessor::class];
        foreach ($classes as $class) {
            $reflection = new ReflectionClass($class);
            $attribute = $reflection->getAttributes(AsPaymentProcessor::class)[0] ?? null;
            if ($attribute) {
                $instance = $attribute->newInstance();
                $this->processors[$instance->type->value] = new $class();
            }
        }
    }

    public function create(PaymentType $type): PaymentProcessor {
        return $this->processors[$type->value] ?? throw new Exception('Unknown type');
    }
}

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

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

Расширенные примеры

1. Фабрика с поддержкой кеширования и readonly-свойств

Пример

<?php

readonly class ProcessorsCollection {
    public function __construct(
        private array $items
    ) {}

    public function get(string $type): PaymentProcessor {
        return $this->items[$type] ?? throw new \RuntimeException("Unsupported type: $type");
    }
}

class CachedPaymentFactory {
    private static ?ProcessorsCollection $collection = null;

    public function create(PaymentType $type): PaymentProcessor {
        if (self::$collection === null) {
            self::$collection = new ProcessorsCollection([
                PaymentType::CreditCarq->value => new CreditCardProcessor(),
                PaymentType::PayPal->value => new PayPalProcessor(),
                PaymentType::BankTransfer->value => new BankTransferProcessor(),
            ]);
        }
        return self::$collection->get($type->value);
    }
}

// Использование
$factory = new CachedPaymentFactory();
$proc = $factory->create(PaymentType::PayPal);
echo $proc->process(50);
Оплата 50 через PayPal.

Readonly-свойство collection гарантирует, что набор процессоров не изменится после инициализации. Singleton-подход с ленивой загрузкой позволяет избежать повторного создания коллекции.

2. Набор стратегий с использованием именованных аргументов (named arguments)

Пример

<?php

interface DiscountStrategy {
    public function apply(float $price): float;
}

class FixedDiscount implements DiscountStrategy {
    public function __construct(private float $amount) {}

    public function apply(float $price): float {
        return max(0, $price - $this->amount);
    }
}

class PercentageDiscount implements DiscountStrategy {
    public function __construct(private float $percent) {}

    public function apply(float $price): float {
        return $price * (1 - $this->percent / 100);
    }
}

function applyDiscount(
    float $price,
    DiscountStrategy $strategy,
    bool $verbose = false
): float {
    $result = $strategy->apply($price);
    if ($verbose) {
        echo "Original: $price - Discounted: $result\n";
    }
    return $result;
}

// Именованные аргументы делают вызов понятнее
$price = 100.0;
$final = applyDiscount(
    price: $price,
    strategy: new FixedDiscount(amount: 15),
    verbose: true
);
echo $final;
Original: 100 - Discounted: 85
85

Именованные аргументы избавляют от необходимости запоминать порядок параметров, особенно когда конструктор принимает много параметров. В комбинации с readonly-свойствами код становится самодокументируемым.

3. Union types для возвращаемого значения фабрики

Пример

<?php

class NullPaymentProcessor implements PaymentProcessor {
    public function process(float $amount): string {
        return "Обработка отключена.";
    }
}

class SafePaymentFactory {
    public function create(?PaymentType $type): CreditCardProcessor|PayPalProcessor|NullPaymentProcessor {
        return match ($type) {
            PaymentType::CreditCarq => new CreditCardProcessor(),
            PaymentType::PayPal => new PayPalProcessor(),
            null => new NullPaymentProcessor(),
            default => throw new \InvalidArgumentException(),
        };
    }
}

$factory = new SafePaymentFactory();
$processor = $factory->create(null);
echo $processor->process(0);
Обработка отключена.

Union types в сигнатуре метода показывают все возможные типы возвращаемого значения, что улучшает статический анализ и документацию. Если в будущем добавится новый процессор, union types придётся расширить (это может быть минусом).

4. Атрибуты для автоматического внедрения (мини-DI)

Пример

<?php

#[\Attribute]
class Inject {
    public function __construct(
        public string $service
    ) {}
}

class Logger {
    public function log(string $message): void {
        echo "[LOG]: $message\n";
    }
}

class UserService {
    public function __construct(
        #[Inject('logger')]
        private ?Logger $logger = null
    ) {}

    public function register(string $name): void {
        $this->logger?->log("User $name registered.");
    }
}

// Контейнер (упрощённый)
$container = ['logger' => new Logger()];
$reflection = new ReflectionClass(UserService::class);
$constructor = $reflection->getConstructor();
$args = [];
foreach ($constructor->getParameters() as $param) {
    $attr = $param->getAttributes(Inject::class)[0] ?? null;
    if ($attr) {
        $serviceName = $attr->newInstance()->service;
        $args[] = $container[$serviceName] ?? null;
    } else {
        $args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
    }
}
$service = $reflection->newInstanceArgs($args);
$service->register('Alice');
[LOG]: User Alice registered.

Атрибут Inject позволяет аннотировать параметры конструктора именами сервисов. Рефлексия собирает зависимости. Это прототип контейнера внедрения зависимостей, использующий новые возможности PHP 8.

Объекты и шаблоны PHP 8 - comments

En
Php 8 объекты шаблоны (php)