Перейти к содержанию

Шаблонный метод

Также известен как: Template Method

Шаблонный метод — это поведенческий паттерн проектирования, который определяет скелет алгоритма, перекладывая ответственность за некоторые его шаги на подклассы. Паттерн позволяет подклассам переопределять шаги алгоритма, не меняя его общей структуры.

Паттерн Шаблонный метод

Проблема

Вы пишете программу для дата-майнинга в офисных документах. Пользователи будут загружать в неё документы в разных форматах (PDF, DOC, CSV), а программа должна извлекать из них полезную информацию.

В первой версии вы ограничились только обработкой DOC-файлов. В следующей версии добавили поддержку CSV. А через месяц прикрутили работу с PDF-документами.

Классы дата-майнинга содержат много дублирования

Классы дата-майнинга содержат много дублирования.

В какой-то момент вы заметили, что код всех трёх классов обработки документов хоть и отличается в части работы с файлами, но содержат довольно много общего в части самого извлечения данных. Было бы здорово избавится от повторной реализации алгоритма извлечения данных в каждом из классов.

К тому же остальной код, работающий с объектами этих классов, наполнен условиями, проверяющими тип обработчика перед началом работы. Весь этот код можно упростить, если слить все три класса воедино либо свести их к общему интерфейсу.

Решение

Паттерн Шаблонный метод предлагает разбить алгоритм на последовательность шагов, описать эти шаги в отдельных методах и вызывать их в одном шаблонном методе друг за другом.

Это позволит подклассам переопределять некоторые шаги алгоритма, оставляя без изменений его структуру и остальные шаги, которые для этого подкласса не так важны.

В нашем примере с дата-майнингом мы можем создать общий базовый класс для всех трёх алгоритмов. Этот класс будет состоять из шаблонного метода, который последовательно вызывает шаги разбора документов.

Шаблонный метод содержит вызовы методов-шагов

Шаблонный метод разбивает алгоритм на шаги, позволяя подклассам переопределить некоторые из них.

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

По-настоящему важным является следующий этап. Теперь мы можем определить общее для всех классов поведение и вынести его в суперкласс. В нашем примере шаги открытия, считывания и закрытия могут отличаться для разных типов документов, поэтому останутся абстрактными. А вот одинаковый для всех типов документов код обработки данных переедет в базовый класс.

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

Но есть и третий тип шагов — хуки: их не обязательно переопределять, но они не содержат никакого кода, выглядя как обычные методы. Шаблонный метод останется рабочим, даже если ни один подкласс не переопределит такой хук. Однако, хук даёт подклассам дополнительные точки «вклинивания» в шаблонный метод.

Аналогия из жизни

Строительство типовых домов

Проект типового дома могут немного изменить по желанию клиента.

Строители используют подход, похожий на шаблонный метод при строительстве типовых домов. У них есть основной архитектурный проект, в котором расписаны шаги строительства: заливка фундамента, постройка стен, перекрытие крыши, установка окон и так далее.

Но, несмотря на стандартизацию каждого этапа, строители могут вносить небольшие изменения на любом из этапов, чтобы сделать дом чуточку непохожим на другие.

Структура

Структура классов паттерна Шаблонный Метод

  1. Абстрактный класс определяет шаги алгоритма и содержит шаблонный метод, состоящий из вызовов этих шагов. Шаги могут быть как абстрактными, так и содержать реализацию по умолчанию.
  2. Конкретный класс переопределяет некоторые (или все) шаги алгоритма. Конкретные классы не переопределяют сам шаблонный метод.

Псевдокод

В этом примере Шаблонный метод используется как заготовка для стандартного искусственного интеллекта в простой игре-стратегии. Для введения в игру новой расы достаточно создать подкласс и реализовать в нём недостающие методы.

Структура классов примера паттерна Шаблонный метод

Пример классов искусственного интеллекта для простой игры.

Все расы игры будут содержать примерно такие же типы юнитов и строений, поэтому структура ИИ будет одинаковой. Но разные расы могут по-разному реализовать эти шаги. Так, например, орки будут агрессивней в атаке, люди — более активны в защите, а дикие монстры вообще не будут заниматься строительством.

class GameAI is
    // Шаблонный метод должен быть задан в базовом классе. Он
    // состоит из вызовов методов в определённом порядке. Чаще
    // всего эти методы являются шагами некоего алгоритма.
    method turn() is
        collectResources()
        buildStructures()
        buildUnits()
        attack()

    // Некоторые из этих методов могут быть реализованы прямо в
    // базовом классе.
    method collectResources() is
        foreach (s in this.builtStructures) do
            s.collect()

    // А некоторые могут быть полностью абстрактными.
    abstract method buildStructures()
    abstract method buildUnits()

    // Кстати, шаблонных методов в классе может быть несколько.
    method attack() is
        enemy = closestEnemy()
        if (enemy == null)
            sendScouts(map.center)
        else
            sendWarriors(enemy.position)

    abstract method sendScouts(position)
    abstract method sendWarriors(position)

// Подклассы могут предоставлять свою реализацию шагов
// алгоритма, не изменяя сам шаблонный метод.
class OrcsAI extends GameAI is
    method buildStructures() is
        if (there are some resources) then
            // Строить фермы, затем бараки, а потом цитадель.

    method buildUnits() is
        if (there are plenty of resources) then
            if (there are no scouts)
                // Построить раба и добавить в группу
                // разведчиков.
            else
                // Построить пехотинца и добавить в группу
                // воинов.

    // ...

    method sendScouts(position) is
        if (scouts.length > 0) then
            // Отправить разведчиков на позицию.

    method sendWarriors(position) is
        if (warriors.length > 5) then
            // Отправить воинов на позицию.

// Подклассы могут не только реализовывать абстрактные шаги, но
// и переопределять шаги, уже реализованные в базовом классе.
class MonstersAI extends GameAI is
    method collectResources() is
        // Ничего не делать.

    method buildStructures() is
        // Ничего не делать.

    method buildUnits() is
        // Ничего не делать.

Применимость

Когда подклассы должны расширять базовый алгоритм, не меняя его структуры.

Шаблонный метод позволяет подклассам расширять определённые шаги алгоритма через наследование, не меняя при этом структуру алгоритмов, объявленную в базовом классе.

Когда у вас есть несколько классов, делающих одно и то же с незначительными отличиями. Если вы редактируете один класс, то приходится вносить такие же правки и в остальные классы.

Паттерн Шаблонный метод предлагает создать для похожих классов общий суперкласс и оформить в нём главный алгоритм в виде шагов. Отличающиеся шаги можно переопределить в подклассах.

Это позволит убрать дублирование кода в нескольких классах с похожим поведением, но отличающихся в деталях.

Шаги реализации

  1. Изучите алгоритм и подумайте, можно ли его разбить на шаги. Прикиньте, какие шаги будут стандартными для всех вариаций алгоритма, а какие — изменяющимися.
  2. Создайте абстрактный базовый класс. Определите в нём шаблонный метод. Этот метод должен состоять из вызовов шагов алгоритма. Имеет смысл сделать шаблонный метод финальным, чтобы подклассы не могли переопределить его (если ваш язык программирования это позволяет).
  3. Добавьте в абстрактный класс методы для каждого из шагов алгоритма. Вы можете сделать эти методы абстрактными или добавить какую-то реализацию по умолчанию. В первом случае все подклассы должны будут реализовать эти методы, а во втором — только если реализация шага в подклассе отличается от стандартной версии.
  4. Подумайте о введении в алгоритм хуков. Чаще всего, хуки располагают между основными шагами алгоритма, а также до и после всех шагов.
  5. Создайте конкретные классы, унаследовав их от абстрактного класса. Реализуйте в них все недостающие шаги и хуки.

Преимущества и недостатки

Преимущества

  • Облегчает повторное использование кода.

Недостатки

  • Вы жёстко ограничены скелетом существующего алгоритма.
  • Вы можете нарушить принцип подстановки Барбары Лисков, изменяя базовое поведение одного из шагов алгоритма через подкласс.
  • С ростом количества шагов шаблонный метод становится слишком сложно поддерживать.

Примеры реализации паттерна

Python

  • Сложность: 1/3
  • Популярность: 2/3
  • Применимость: Шаблонные методы можно встретить во многих библиотечных классах Python. Разработчики создают их, чтобы позволить клиентам легко и быстро расширять стандартный код при помощи наследования.
  • Признаки применения паттерна: Класс заставляет своих потомков реализовать методы-шаги, но самостоятельно реализует структуру алгоритма.

Этот пример показывает структуру паттерна Шаблонный метод, а именно — из каких классов он состоит, какие роли эти классы выполняют и как они взаимодействуют друг с другом.

from abc import ABC, abstractmethod


class AbstractClass(ABC):
    """
    Абстрактный Класс определяет шаблонный метод, содержащий скелет
    некоторого алгоритма, состоящего из вызовов (обычно) абстрактных
    примитивных операций.

    Конкретные подклассы должны реализовать эти операции, но оставить
    сам шаблонный метод без изменений.
    """

    def template_method(self) -> None:
        """
        Шаблонный метод определяет скелет алгоритма.
        """

        self.base_operation1()
        self.required_operations1()
        self.base_operation2()
        self.hook1()
        self.required_operations2()
        self.base_operation3()
        self.hook2()

    # Эти операции уже имеют реализации.

    def base_operation1(self) -> None:
        print("AbstractClass says: I am doing the bulk of the work")

    def base_operation2(self) -> None:
        print("AbstractClass says: But I let subclasses override "
            + "some operations")

    def base_operation3(self) -> None:
        print("AbstractClass says: But I am doing the bulk of the "
            + "work anyway")

    # А эти операции должны быть реализованы в подклассах.

    @abstractmethod
    def required_operations1(self) -> None:
        pass

    @abstractmethod
    def required_operations2(self) -> None:
        pass

    # Это «хуки». Подклассы могут переопределять их, но это не
    # обязательно, поскольку у хуков уже есть стандартная (но пустая)
    # реализация. Хуки предоставляют дополнительные точки расширения
    # в некоторых критических местах алгоритма.

    def hook1(self) -> None:
        pass

    def hook2(self) -> None:
        pass


class ConcreteClass1(AbstractClass):
    """
    Конкретные классы должны реализовать все абстрактные операции
    базового класса. Они также могут переопределить некоторые операции
    с реализацией по умолчанию.
    """

    def required_operations1(self) -> None:
        print("ConcreteClass1 says: Implemented Operation1")

    def required_operations2(self) -> None:
        print("ConcreteClass1 says: Implemented Operation2")


class ConcreteClass2(AbstractClass):
    """
    Обычно конкретные классы переопределяют только часть операций
    базового класса.
    """

    def required_operations1(self) -> None:
        print("ConcreteClass2 says: Implemented Operation1")

    def required_operations2(self) -> None:
        print("ConcreteClass2 says: Implemented Operation2")

    def hook1(self) -> None:
        print("ConcreteClass2 says: Overridden Hook1")


def client_code(abstract_class: AbstractClass) -> None:
    """
    Клиентский код вызывает шаблонный метод для выполнения алгоритма.
    Клиентский код не должен знать конкретный класс объекта, с которым
    работает, при условии, что он работает с объектами через интерфейс
    их базового класса.
    """

    # ...
    abstract_class.template_method()
    # ...


if __name__ == "__main__":
    print("Same client code can work with different subclasses:")
    client_code(ConcreteClass1())
    print("")

    print("Same client code can work with different subclasses:")
    client_code(ConcreteClass2())
Same client code can work with different subclasses:
AbstractClass says: I am doing the bulk of the work
ConcreteClass1 says: Implemented Operation1
AbstractClass says: But I let subclasses override some operations
ConcreteClass1 says: Implemented Operation2
AbstractClass says: But I am doing the bulk of the work anyway

Same client code can work with different subclasses:
AbstractClass says: I am doing the bulk of the work
ConcreteClass2 says: Implemented Operation1
AbstractClass says: But I let subclasses override some operations
ConcreteClass2 says: Overridden Hook1
ConcreteClass2 says: Implemented Operation2
AbstractClass says: But I am doing the bulk of the work anyway

PHP

  • Сложность: 1/3
  • Популярность: 2/3
  • Применимость: Шаблонные методы можно встретить во многих PHP-фреймворках. Разработчики создают такие методы, чтобы позволить клиентам легко и быстро расширять стандартный код при помощи наследования.
  • Признаки применения паттерна: Класс заставляет своих потомков реализовать методы-шаги, но самостоятельно реализует структуру алгоритма.

Этот пример показывает структуру паттерна Шаблонный метод, а именно — из каких классов он состоит, какие роли эти классы выполняют и как они взаимодействуют друг с другом.

После ознакомления со структурой, вам будет легче воспринимать второй пример, который рассматривает реальный случай использования паттерна в мире PHP.

<?php
/**
* Абстрактный Класс определяет шаблонный метод, содержащий скелет некоторого
* алгоритма, состоящего из вызовов (обычно) абстрактных примитивных операций.
*
* Конкретные подклассы должны реализовать эти операции, но оставить сам
* шаблонный метод без изменений.
*/

abstract class AbstractClass
{
    /**
    * Шаблонный метод определяет скелет алгоритма.
    */
    final public function templateMethod(): void
    {
        $this->baseOperation1();
        $this->requiredOperations1();
        $this->baseOperation2();
        $this->hook1();
        $this->requiredOperation2();
        $this->baseOperation3();
        $this->hook2();
    }

    /**
    * Эти операции уже имеют реализации.
    */
    protected function baseOperation1(): void
    {
        echo "AbstractClass says: I am doing the bulk of the work\n";
    }

    protected function baseOperation2(): void
    {
        echo "AbstractClass says: But I let subclasses override some operations\n";
    }

    protected function baseOperation3(): void
    {
        echo "AbstractClass says: But I am doing the bulk of the work anyway\n";
    }

    /**
    * А эти операции должны быть реализованы в подклассах.
    */
    abstract protected function requiredOperations1(): void;

    abstract protected function requiredOperation2(): void;

    /**
    * Это «хуки». Подклассы могут переопределять их, но это не обязательно,
    * поскольку у хуков уже есть стандартная (но пустая) реализация. Хуки
    * предоставляют дополнительные точки расширения в некоторых критических
    * местах алгоритма.
    */
    protected function hook1(): void { }

    protected function hook2(): void { }
}

/**
* Конкретные классы должны реализовать все абстрактные операции базового
* класса. Они также могут переопределить некоторые операции с реализацией по
* умолчанию.
*/

class ConcreteClass1 extends AbstractClass
{
    protected function requiredOperations1(): void
    {
        echo "ConcreteClass1 says: Implemented Operation1\n";
    }

    protected function requiredOperation2(): void
    {
        echo "ConcreteClass1 says: Implemented Operation2\n";
    }
}

/**
* Обычно конкретные классы переопределяют только часть операций базового
* класса.
*/ 

class ConcreteClass2 extends AbstractClass
{
    protected function requiredOperations1(): void
    {
        echo "ConcreteClass2 says: Implemented Operation1\n";
    }

    protected function requiredOperation2(): void
    {
        echo "ConcreteClass2 says: Implemented Operation2\n";
    }

    protected function hook1(): void
    {
        echo "ConcreteClass2 says: Overridden Hook1\n";
    }
}

/**
* Клиентский код вызывает шаблонный метод для выполнения алгоритма. Клиентский
* код не должен знать конкретный класс объекта, с которым работает, при
* условии, что он работает с объектами через интерфейс их базового класса.
*/ 

function clientCode(AbstractClass $class)
{
    // ...
    $class->templateMethod();
    // ...
}

echo "Same client code can work with different subclasses:\n";
clientCode(new ConcreteClass1());
echo "\n";

echo "Same client code can work with different subclasses:\n";
clientCode(new ConcreteClass2());
Same client code can work with different subclasses:
AbstractClass says: I am doing bulk of the work
ConcreteClass1 says: Implemented Operation1
AbstractClass says: But I let subclasses to override some operations
ConcreteClass1 says: Implemented Operation2
AbstractClass says: But I am doing bulk of the work anyway

Same client code can work with different subclasses:
AbstractClass says: I am doing bulk of the work
ConcreteClass2 says: Implemented Operation1
AbstractClass says: But I let subclasses to override some operations
ConcreteClass2 says: Overridden Hook1
ConcreteClass2 says: Implemented Operation2
AbstractClass says: But I am doing bulk of the work anyway

В этом примере Шаблонный метод определяет общую схему алгоритма отправки сообщений в социальных сетях. Каждый подкласс представляет отдельную социальную сеть и реализует все шаги по-разному, но повторно использует базовый алгоритм.

<?php
/**
* Абстрактный Класс определяет метод шаблона и объявляет все его шаги.
*/

abstract class SocialNetwork
{
    protected $username;

    protected $password;

    public function __construct(string $username, string $password)
    {
        $this->username = $username;
        $this->password = $password;
    }

    /**
    * Фактический метод шаблона вызывает абстрактные шаги в определённом
    * порядке. Подкласс может реализовать все шаги, позволяя этому методу
    * реально публиковать что-то в социальной сети.
    */
    public function post(string $message): bool
    {
        // Проверка подлинности перед публикацией. Каждая сеть использует свой
        // метод авторизации.
        if ($this->logIn($this->username, $this->password)) {
            // Отправляем почтовые данные. Все сети имеют разные API.
            $result = $this->sendData($message);
            // ...
            $this->logOut();
            return $result;
        }
        return false;
    }

    /**
    * Шаги объявлены абстрактными, чтобы заставить подклассы реализовать их
    * полностью.
    */
    abstract public function logIn(string $userName, string $password): bool;

    abstract public function sendData(string $message): bool;

    abstract public function logOut(): void;
}

/**
* Этот Конкретный Класс реализует API Facebook (ладно, он пытается).
*/

class Facebook extends SocialNetwork
{
    public function logIn(string $userName, string $password): bool
    {
        echo "\nChecking user's credentials...\n";
        echo "Name: " . $this->username . "\n";
        echo "Password: " . str_repeat("*", strlen($this->password)) . "\n";
        simulateNetworkLatency();
        echo "\n\nFacebook: '" . $this->username . "' has logged in successfully.\n";
        return true;
    }

    public function sendData(string $message): bool
    {
        echo "Facebook: '" . $this->username . "' has posted '" . $message . "'.\n";
        return true;
    }

    public function logOut(): void
    {
        echo "Facebook: '" . $this->username . "' has been logged out.\n";
    }
}

/**
* Этот Конкретный Класс реализует API Twitter.
*/

class Twitter extends SocialNetwork
{
    public function logIn(string $userName, string $password): bool
    {
        echo "\nChecking user's credentials...\n";
        echo "Name: " . $this->username . "\n";
        echo "Password: " . str_repeat("*", strlen($this->password)) . "\n";
        simulateNetworkLatency();
        echo "\n\nTwitter: '" . $this->username . "' has logged in successfully.\n";
        return true;
    }

    public function sendData(string $message): bool
    {
        echo "Twitter: '" . $this->username . "' has posted '" . $message . "'.\n";
        return true;
    }

    public function logOut(): void
    {
        echo "Twitter: '" . $this->username . "' has been logged out.\n";
    }
}

/**
* Небольшая вспомогательная функция, которая делает время ожидания похожим на
* реальность.
*/

function simulateNetworkLatency()
{
    $i = 0;
    while ($i < 5) {
        echo ".";
        sleep(1);
        $i++;
    }
}

/**
* Клиентский код.
*/
echo "Username: \n";
$username = readline();
echo "Password: \n";
$password = readline();
echo "Message: \n";
$message = readline();

echo "\nChoose the social network to post the message:\n" .
"1 - Facebook\n" .
"2 - Twitter\n";
$choice = readline();

// Теперь давайте создадим правильный объект социальной сети и отправим
// сообщение.
if ($choice == 1) {
    $network = new Facebook($username, $password);
} elseif ($choice == 2) {
    $network = new Twitter($username, $password);
} else {
    die("Sorry, I'm not sure what you mean by that.\n");
}
$network->post($message);
Username:
> neo
Password:
> 123123
Message:
> What is the Matrix?

Choose the social network to post the message:
1 - Facebook
2 - Twitter
> 1

Checking user's credentials...
Name: neo
Password: ******
.....

Facebook: 'neo' has logged in successfully.
Facebook: 'neo' has posted 'What is the Matrix?'.
Facebook: 'neo' has been logged out.

Задачи

Задача 1. Парсер документов

Реализуйте шаблонный метод для парсинга документов разных форматов. Алгоритм: открыть файл → распарсить заголовок → распарсить тело → извлечь метаданные → закрыть файл → вернуть результат. Создайте три подкласса: CsvParser, JsonParser, HtmlParser. Хук before_parse позволяет подклассам выполнять подготовительные действия.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field


@dataclass
class ParseResult:
    title: str = ""
    body: list[str] = field(default_factory=list)
    metadata: dict = field(default_factory=dict)
    format: str = ""


class DocumentParser(ABC):
    def parse(self, content: str) -> ParseResult:
        """Шаблонный метод — определяет скелет алгоритма."""
        self.before_parse(content)
        result = ParseResult(format=self.format_name)
        raw = self.open_document(content)
        result.title = self.parse_title(raw)
        result.body = self.parse_body(raw)
        result.metadata = self.extract_metadata(raw)
        self.close_document(raw)
        return self.finalize(result)

    # Хук — подклассы могут переопределить, но не обязаны
    def before_parse(self, content: str) -> None:
        pass

    # Шаги с реализацией по умолчанию
    def open_document(self, content: str) -> str:
        print(f"[{self.format_name}] Открытие документа")
        return content

    def close_document(self, raw: str) -> None:
        print(f"[{self.format_name}] Закрытие документа")

    def finalize(self, result: ParseResult) -> ParseResult:
        print(f"[{self.format_name}] Парсинг завершён: '{result.title}'")
        return result

    # Абстрактные шаги — обязательны для реализации
    @property
    @abstractmethod
    def format_name(self) -> str: ...

    @abstractmethod
    def parse_title(self, raw: str) -> str: ...

    @abstractmethod
    def parse_body(self, raw: str) -> list[str]: ...

    @abstractmethod
    def extract_metadata(self, raw: str) -> dict: ...


class CsvParser(DocumentParser):
    @property
    def format_name(self) -> str:
        return "CSV"

    def parse_title(self, raw: str) -> str:
        # TODO: первая строка — заголовок (имена колонок через запятую)
        pass

    def parse_body(self, raw: str) -> list[str]:
        # TODO: остальные строки — строки данных
        pass

    def extract_metadata(self, raw: str) -> dict:
        # TODO: вернуть {"rows": кол-во строк, "columns": кол-во колонок}
        pass


class JsonParser(DocumentParser):
    @property
    def format_name(self) -> str:
        return "JSON"

    def before_parse(self, content: str) -> None:
        # TODO: валидировать JSON перед парсингом
        pass

    def parse_title(self, raw: str) -> str:
        # TODO: значение ключа "title" или "name" из корня JSON
        pass

    def parse_body(self, raw: str) -> list[str]:
        # TODO: элементы массива "items" или "data", каждый как строка
        pass

    def extract_metadata(self, raw: str) -> dict:
        # TODO: вернуть все ключи верхнего уровня кроме title/items/data
        pass


class HtmlParser(DocumentParser):
    @property
    def format_name(self) -> str:
        return "HTML"

    def parse_title(self, raw: str) -> str:
        # TODO: содержимое тега <title> или <h1>
        pass

    def parse_body(self, raw: str) -> list[str]:
        # TODO: содержимое всех тегов <p>
        pass

    def extract_metadata(self, raw: str) -> dict:
        # TODO: содержимое тегов <meta name="..."> как {"name": "content"}
        pass


# Ожидаемый результат:
# csv_content = "name,age,city\nAlice,30,Moscow\nBob,25,SPb"
# result = CsvParser().parse(csv_content)
# print(result.title)    # "name,age,city"
# print(result.body)     # ["Alice,30,Moscow", "Bob,25,SPb"]
# print(result.metadata) # {"rows": 2, "columns": 3}
#
# json_content = '{"title": "Users", "items": ["Alice", "Bob"], "version": "1.0"}'
# result = JsonParser().parse(json_content)
# print(result.title)    # "Users"
# print(result.body)     # ["Alice", "Bob"]
# print(result.metadata) # {"version": "1.0"}
<?php
class ParseResult
{
    public string $title = "";
    public array $body = [];
    public array $metadata = [];
    public string $format = "";
}

abstract class DocumentParser
{
    /** Шаблонный метод */
    final public function parse(string $content): ParseResult
    {
        $this->beforeParse($content);
        $result = new ParseResult();
        $result->format = $this->getFormatName();
        $raw = $this->openDocument($content);
        $result->title = $this->parseTitle($raw);
        $result->body = $this->parseBody($raw);
        $result->metadata = $this->extractMetadata($raw);
        $this->closeDocument($raw);
        return $this->finalize($result);
    }

    // Хук
    protected function beforeParse(string $content): void {}

    // Шаги с реализацией по умолчанию
    protected function openDocument(string $content): string
    {
        echo "[{$this->getFormatName()}] Открытие документа\n";
        return $content;
    }

    protected function closeDocument(string $raw): void
    {
        echo "[{$this->getFormatName()}] Закрытие документа\n";
    }

    protected function finalize(ParseResult $result): ParseResult
    {
        echo "[{$this->getFormatName()}] Парсинг завершён: '{$result->title}'\n";
        return $result;
    }

    // Абстрактные шаги
    abstract protected function getFormatName(): string;
    abstract protected function parseTitle(string $raw): string;
    abstract protected function parseBody(string $raw): array;
    abstract protected function extractMetadata(string $raw): array;
}

class CsvParser extends DocumentParser
{
    protected function getFormatName(): string { return "CSV"; }

    protected function parseTitle(string $raw): string
    {
        // TODO: первая строка — имена колонок
    }

    protected function parseBody(string $raw): array
    {
        // TODO: остальные строки данных
    }

    protected function extractMetadata(string $raw): array
    {
        // TODO: ["rows" => ..., "columns" => ...]
    }
}

class JsonParser extends DocumentParser
{
    protected function getFormatName(): string { return "JSON"; }

    protected function beforeParse(string $content): void
    {
        // TODO: валидировать JSON
    }

    protected function parseTitle(string $raw): string
    {
        // TODO: ключ "title" или "name" из корня
    }

    protected function parseBody(string $raw): array
    {
        // TODO: массив "items" или "data"
    }

    protected function extractMetadata(string $raw): array
    {
        // TODO: все ключи кроме title/name/items/data
    }
}

class HtmlParser extends DocumentParser
{
    protected function getFormatName(): string { return "HTML"; }

    protected function parseTitle(string $raw): string
    {
        // TODO: содержимое <title> или <h1>
    }

    protected function parseBody(string $raw): array
    {
        // TODO: содержимое всех <p>
    }

    protected function extractMetadata(string $raw): array
    {
        // TODO: теги <meta name="..." content="...">
    }
}

// Ожидаемый результат:
// $csv = "name,age,city\nAlice,30,Moscow\nBob,25,SPb";
// $result = (new CsvParser())->parse($csv);
// echo $result->title . "\n";      // "name,age,city"
// print_r($result->body);          // ["Alice,30,Moscow", "Bob,25,SPb"]
// print_r($result->metadata);      // ["rows" => 2, "columns" => 3]
//
// $json = '{"title":"Users","items":["Alice","Bob"],"version":"1.0"}';
// $result = (new JsonParser())->parse($json);
// echo $result->title . "\n";      // "Users"
// print_r($result->body);          // ["Alice", "Bob"]
// print_r($result->metadata);      // ["version" => "1.0"]

Задача 2. Генератор отчётов

Реализуйте базовый класс ReportGenerator с шаблонным методом generate(). Шаги: сбор данных → фильтрация → агрегация → форматирование → сохранение. Создайте подклассы: SalesReport (продажи по регионам), UserActivityReport (активность пользователей). Хук after_aggregate позволяет добавить дополнительную статистику.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class ReportData:
    title: str
    rows: list[dict]
    summary: dict
    extra: dict


class ReportGenerator(ABC):
    def generate(self, filters: dict | None = None) -> ReportData:
        """Шаблонный метод."""
        raw_data = self.collect_data()
        filtered = self.filter_data(raw_data, filters or {})
        aggregated = self.aggregate(filtered)
        self.after_aggregate(aggregated)  # хук
        formatted = self.format_report(aggregated)
        self.save_report(formatted)
        return formatted

    # Хук
    def after_aggregate(self, data: ReportData) -> None:
        pass

    # Шаг с реализацией по умолчанию
    def filter_data(self, data: list[dict],
                    filters: dict) -> list[dict]:
        # TODO: фильтровать по совпадению ключ-значение из filters
        pass

    def save_report(self, report: ReportData) -> None:
        print(f"[Report] Сохранено: '{report.title}' "
              f"({len(report.rows)} строк)")

    # Абстрактные шаги
    @abstractmethod
    def collect_data(self) -> list[dict]: ...

    @abstractmethod
    def aggregate(self, data: list[dict]) -> ReportData: ...

    @abstractmethod
    def format_report(self, data: ReportData) -> ReportData: ...


class SalesReport(ReportGenerator):
    def __init__(self, sales_db: list[dict]) -> None:
        self._db = sales_db

    def collect_data(self) -> list[dict]:
        # TODO: вернуть данные о продажах из _db
        pass

    def aggregate(self, data: list[dict]) -> ReportData:
        # TODO: сгруппировать по region, посчитать total_sales и avg_order
        pass

    def format_report(self, data: ReportData) -> ReportData:
        # TODO: добавить в каждую строку поле "rank" (ранг по продажам)
        pass

    def after_aggregate(self, data: ReportData) -> None:
        # TODO: добавить в summary["top_region"] регион с макс. продажами
        pass


class UserActivityReport(ReportGenerator):
    def __init__(self, events_db: list[dict]) -> None:
        self._db = events_db

    def collect_data(self) -> list[dict]:
        # TODO: вернуть события из _db
        pass

    def aggregate(self, data: list[dict]) -> ReportData:
        # TODO: сгруппировать по user_id: кол-во событий, последняя активность
        pass

    def format_report(self, data: ReportData) -> ReportData:
        # TODO: отсортировать строки по убыванию кол-ва событий
        pass


# Ожидаемый результат:
# sales = [
#     {"region": "North", "amount": 1000, "order_id": 1},
#     {"region": "South", "amount": 1500, "order_id": 2},
#     {"region": "North", "amount": 800,  "order_id": 3},
# ]
# report = SalesReport(sales).generate()
# print(report.title)           # "Sales Report"
# print(report.summary)         # {"total": 3300, "top_region": "South"}
# print(report.rows[0]["rank"]) # 1
#
# report = SalesReport(sales).generate(filters={"region": "North"})
# print(len(report.rows))       # 1 (только North)
<?php
class ReportData
{
    public function __construct(
        public string $title = "",
        public array $rows = [],
        public array $summary = [],
        public array $extra = [],
    ) {}
}

abstract class ReportGenerator
{
    /** Шаблонный метод */
    final public function generate(array $filters = []): ReportData
    {
        $rawData = $this->collectData();
        $filtered = $this->filterData($rawData, $filters);
        $aggregated = $this->aggregate($filtered);
        $this->afterAggregate($aggregated);  // хук
        $formatted = $this->formatReport($aggregated);
        $this->saveReport($formatted);
        return $formatted;
    }

    // Хук
    protected function afterAggregate(ReportData $data): void {}

    // Шаг с реализацией по умолчанию
    protected function filterData(array $data, array $filters): array
    {
        // TODO: фильтровать по совпадению ключ-значение
    }

    protected function saveReport(ReportData $report): void
    {
        $count = count($report->rows);
        echo "[Report] Сохранено: '{$report->title}' ($count строк)\n";
    }

    // Абстрактные шаги
    abstract protected function collectData(): array;
    abstract protected function aggregate(array $data): ReportData;
    abstract protected function formatReport(ReportData $data): ReportData;
}

class SalesReport extends ReportGenerator
{
    public function __construct(private readonly array $db) {}

    protected function collectData(): array
    {
        // TODO: вернуть данные о продажах
    }

    protected function aggregate(array $data): ReportData
    {
        // TODO: сгруппировать по region, total_sales и avg_order
    }

    protected function formatReport(ReportData $data): ReportData
    {
        // TODO: добавить "rank" в каждую строку
    }

    protected function afterAggregate(ReportData $data): void
    {
        // TODO: summary["top_region"] = регион с макс. продажами
    }
}

class UserActivityReport extends ReportGenerator
{
    public function __construct(private readonly array $db) {}

    protected function collectData(): array
    {
        // TODO: вернуть события
    }

    protected function aggregate(array $data): ReportData
    {
        // TODO: сгруппировать по user_id: кол-во событий, последняя активность
    }

    protected function formatReport(ReportData $data): ReportData
    {
        // TODO: отсортировать по убыванию кол-ва событий
    }
}

// Ожидаемый результат:
// $sales = [
//     ["region" => "North", "amount" => 1000, "order_id" => 1],
//     ["region" => "South", "amount" => 1500, "order_id" => 2],
//     ["region" => "North", "amount" => 800,  "order_id" => 3],
// ];
// $report = (new SalesReport($sales))->generate();
// echo $report->title . "\n";              // "Sales Report"
// print_r($report->summary);               // ["total" => 3300, "top_region" => "South"]
// echo $report->rows[0]["rank"] . "\n";    // 1
//
// $report = (new SalesReport($sales))->generate(["region" => "North"]);
// echo count($report->rows) . "\n";        // 1

Задача 3. Пайплайн обработки изображений

Реализуйте абстрактный ImageProcessor с шаблонным методом process(). Шаги: загрузка → валидация → предобработка → применение фильтра → постобработка → сохранение. Создайте подклассы: ThumbnailProcessor (уменьшение + кадрирование), WatermarkProcessor (наложение водяного знака), GrayscaleProcessor (чёрно-белое + повышение контраста).

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class Image:
    width: int
    height: int
    data: list[list[int]]  # упрощённое представление пикселей (0-255)
    filename: str = ""
    format: str = "PNG"

    def clone(self) -> "Image":
        return Image(
            self.width, self.height,
            [row[:] for row in self.data],
            self.filename, self.format
        )


class ImageProcessor(ABC):
    def process(self, source: str, destination: str) -> Image:
        """Шаблонный метод."""
        image = self.load(source)
        self.validate(image)
        image = self.preprocess(image)
        image = self.apply_filter(image)
        image = self.postprocess(image)
        self.save(image, destination)
        return image

    # Шаг с реализацией по умолчанию
    def load(self, path: str) -> Image:
        print(f"[{self.__class__.__name__}] Загрузка: {path}")
        # Возвращаем тестовое изображение
        return Image(
            width=800, height=600,
            data=[[128] * 800 for _ in range(600)],
            filename=path
        )

    def validate(self, image: Image) -> None:
        if image.width == 0 or image.height == 0:
            raise ValueError("Изображение пустое")
        print(f"[{self.__class__.__name__}] Валидация OK: "
              f"{image.width}x{image.height}")

    def preprocess(self, image: Image) -> Image:
        # Хук — по умолчанию без изменений
        return image

    def postprocess(self, image: Image) -> Image:
        # Хук — по умолчанию без изменений
        return image

    def save(self, image: Image, path: str) -> None:
        print(f"[{self.__class__.__name__}] Сохранено: {path} "
              f"({image.width}x{image.height})")

    # Абстрактный шаг
    @abstractmethod
    def apply_filter(self, image: Image) -> Image: ...


class ThumbnailProcessor(ImageProcessor):
    def __init__(self, max_size: int = 200) -> None:
        self.max_size = max_size

    def apply_filter(self, image: Image) -> Image:
        # TODO: уменьшить изображение до max_size по большей стороне,
        #       сохраняя пропорции
        pass

    def postprocess(self, image: Image) -> Image:
        # TODO: кадрировать до квадрата max_size x max_size (обрезать центр)
        pass


class WatermarkProcessor(ImageProcessor):
    def __init__(self, text: str, opacity: float = 0.3) -> None:
        self.text = text
        self.opacity = opacity

    def preprocess(self, image: Image) -> Image:
        # TODO: убедиться что изображение достаточно большое для водяного знака
        pass

    def apply_filter(self, image: Image) -> Image:
        # TODO: "нанести" водяной знак (в data не меняем, просто логируем)
        print(f"[Watermark] Наносим '{self.text}' с прозрачностью {self.opacity}")
        return image


class GrayscaleProcessor(ImageProcessor):
    def apply_filter(self, image: Image) -> Image:
        # TODO: усреднить каждый пиксель (имитация grayscale)
        pass

    def postprocess(self, image: Image) -> Image:
        # TODO: повысить контраст: растянуть диапазон [min,max] → [0,255]
        pass


# Ожидаемый результат:
# thumb = ThumbnailProcessor(max_size=150)
# result = thumb.process("photo.jpg", "thumb.jpg")
# print(result.width, result.height)   # 150 150 (квадрат)
#
# wm = WatermarkProcessor("© MyBrand")
# result = wm.process("photo.jpg", "watermarked.jpg")
# # [Watermark] Наносим '© MyBrand' с прозрачностью 0.3
#
# gs = GrayscaleProcessor()
# result = gs.process("photo.jpg", "gray.jpg")
<?php
class Image
{
    public function __construct(
        public int $width,
        public int $height,
        public array $data,
        public string $filename = "",
        public string $format = "PNG",
    ) {}

    public function clone(): static
    {
        return new static($this->width, $this->height,
            array_map(fn($r) => $r, $this->data),
            $this->filename, $this->format);
    }
}

abstract class ImageProcessor
{
    /** Шаблонный метод */
    final public function process(string $source, string $destination): Image
    {
        $image = $this->load($source);
        $this->validate($image);
        $image = $this->preprocess($image);
        $image = $this->applyFilter($image);
        $image = $this->postprocess($image);
        $this->save($image, $destination);
        return $image;
    }

    protected function load(string $path): Image
    {
        echo "[" . static::class . "] Загрузка: $path\n";
        return new Image(800, 600, array_fill(0, 600, array_fill(0, 800, 128)), $path);
    }

    protected function validate(Image $image): void
    {
        if ($image->width === 0 || $image->height === 0) {
            throw new \InvalidArgumentException("Изображение пустое");
        }
        echo "[" . static::class . "] Валидация OK: {$image->width}x{$image->height}\n";
    }

    // Хуки
    protected function preprocess(Image $image): Image { return $image; }
    protected function postprocess(Image $image): Image { return $image; }

    protected function save(Image $image, string $path): void
    {
        echo "[" . static::class . "] Сохранено: $path ({$image->width}x{$image->height})\n";
    }

    // Абстрактный шаг
    abstract protected function applyFilter(Image $image): Image;
}

class ThumbnailProcessor extends ImageProcessor
{
    public function __construct(private readonly int $maxSize = 200) {}

    protected function applyFilter(Image $image): Image
    {
        // TODO: уменьшить до maxSize по большей стороне, сохранить пропорции
    }

    protected function postprocess(Image $image): Image
    {
        // TODO: кадрировать до квадрата maxSize x maxSize
    }
}

class WatermarkProcessor extends ImageProcessor
{
    public function __construct(
        private readonly string $text,
        private readonly float $opacity = 0.3,
    ) {}

    protected function preprocess(Image $image): Image
    {
        // TODO: проверить что изображение достаточно большое
    }

    protected function applyFilter(Image $image): Image
    {
        echo "[Watermark] Наносим '{$this->text}' с прозрачностью {$this->opacity}\n";
        return $image;
    }
}

class GrayscaleProcessor extends ImageProcessor
{
    protected function applyFilter(Image $image): Image
    {
        // TODO: имитация grayscale (усреднение пикселей)
    }

    protected function postprocess(Image $image): Image
    {
        // TODO: повышение контраста: растянуть [min,max] → [0,255]
    }
}

// Ожидаемый результат:
// $thumb = new ThumbnailProcessor(maxSize: 150);
// $result = $thumb->process("photo.jpg", "thumb.jpg");
// echo $result->width . "x" . $result->height . "\n";  // 150x150
//
// $wm = new WatermarkProcessor("© MyBrand");
// $wm->process("photo.jpg", "watermarked.jpg");
// // [Watermark] Наносим '© MyBrand' с прозрачностью 0.3
//
// $gs = new GrayscaleProcessor();
// $gs->process("photo.jpg", "gray.jpg");

Задача 4. Процесс оформления заказа (Checkout)

Реализуйте шаблонный метод для оформления заказа в интернет-магазине. Шаги: валидация корзины → применение скидок → расчёт доставки → обработка платежа → создание заказа → отправка уведомления. Создайте подклассы: StandardCheckout, GuestCheckout (без аккаунта, без сохранения), SubscriptionCheckout (повторный платёж по подписке).

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field


@dataclass
class CheckoutContext:
    items: list[dict]
    user_id: str | None
    promo_code: str | None = None
    subtotal: float = 0.0
    discount: float = 0.0
    shipping: float = 0.0
    total: float = 0.0
    order_id: str | None = None
    payment_method: str = "card"


class CheckoutProcess(ABC):
    def checkout(self, ctx: CheckoutContext) -> bool:
        """Шаблонный метод."""
        try:
            self.validate_cart(ctx)
            self.apply_discounts(ctx)
            self.calculate_shipping(ctx)
            ctx.total = ctx.subtotal - ctx.discount + ctx.shipping
            if not self.process_payment(ctx):
                return False
            self.create_order(ctx)
            self.send_notification(ctx)
            self.after_checkout(ctx)  # хук
            return True
        except ValueError as e:
            print(f"[Checkout] Ошибка: {e}")
            return False

    # Хук
    def after_checkout(self, ctx: CheckoutContext) -> None:
        pass

    # Шаги с реализацией по умолчанию
    def validate_cart(self, ctx: CheckoutContext) -> None:
        if not ctx.items:
            raise ValueError("Корзина пуста")
        ctx.subtotal = sum(i["price"] * i["qty"] for i in ctx.items)
        print(f"[Checkout] Корзина валидна, подытог: {ctx.subtotal:.2f}")

    def apply_discounts(self, ctx: CheckoutContext) -> None:
        ctx.discount = 0.0

    def send_notification(self, ctx: CheckoutContext) -> None:
        print(f"[Checkout] Уведомление отправлено для заказа {ctx.order_id}")

    # Абстрактные шаги
    @abstractmethod
    def calculate_shipping(self, ctx: CheckoutContext) -> None: ...

    @abstractmethod
    def process_payment(self, ctx: CheckoutContext) -> bool: ...

    @abstractmethod
    def create_order(self, ctx: CheckoutContext) -> None: ...


class StandardCheckout(CheckoutProcess):
    def apply_discounts(self, ctx: CheckoutContext) -> None:
        # TODO: применить промокод если есть (скидка 10%)
        pass

    def calculate_shipping(self, ctx: CheckoutContext) -> None:
        # TODO: бесплатно при subtotal > 1000, иначе 200
        pass

    def process_payment(self, ctx: CheckoutContext) -> bool:
        # TODO: имитировать оплату картой
        print(f"[Standard] Оплата картой: {ctx.total:.2f}")
        return True

    def create_order(self, ctx: CheckoutContext) -> None:
        # TODO: создать order_id = "ORD-{user_id}-{timestamp}"
        pass

    def after_checkout(self, ctx: CheckoutContext) -> None:
        # TODO: начислить бонусные баллы (1% от total)
        pass


class GuestCheckout(CheckoutProcess):
    def calculate_shipping(self, ctx: CheckoutContext) -> None:
        # TODO: фиксированная доставка 300 для гостей
        pass

    def process_payment(self, ctx: CheckoutContext) -> bool:
        print(f"[Guest] Оплата: {ctx.total:.2f}")
        return True

    def create_order(self, ctx: CheckoutContext) -> None:
        # TODO: order_id = "GUEST-{timestamp}", без сохранения в БД
        pass

    def send_notification(self, ctx: CheckoutContext) -> None:
        # TODO: уведомление только на email (без аккаунта нет push)
        print(f"[Guest] Email-уведомление для заказа {ctx.order_id}")


class SubscriptionCheckout(CheckoutProcess):
    def validate_cart(self, ctx: CheckoutContext) -> None:
        # TODO: для подписки корзина всегда содержит 1 товар
        super().validate_cart(ctx)

    def apply_discounts(self, ctx: CheckoutContext) -> None:
        # TODO: скидка 15% на подписку
        pass

    def calculate_shipping(self, ctx: CheckoutContext) -> None:
        ctx.shipping = 0.0  # доставка бесплатна для подписок
        print("[Subscription] Доставка: бесплатно")

    def process_payment(self, ctx: CheckoutContext) -> bool:
        # TODO: списание по сохранённой карте
        print(f"[Subscription] Повторный платёж: {ctx.total:.2f}")
        return True

    def create_order(self, ctx: CheckoutContext) -> None:
        # TODO: order_id = "SUB-{user_id}-{timestamp}"
        pass


# Ожидаемый результат:
# items = [{"name": "Book", "price": 500, "qty": 2}]
# ctx = CheckoutContext(items=items, user_id="user42", promo_code="SAVE10")
# StandardCheckout().checkout(ctx)
# print(ctx.order_id)    # "ORD-user42-..."
# print(ctx.discount)    # 100.0 (10% от 1000)
# print(ctx.shipping)    # 0.0 (бесплатно > 1000)
# print(ctx.total)       # 900.0
<?php
class CheckoutContext
{
    public float $subtotal = 0.0;
    public float $discount = 0.0;
    public float $shipping = 0.0;
    public float $total = 0.0;
    public ?string $orderId = null;

    public function __construct(
        public array $items,
        public ?string $userId,
        public ?string $promoCode = null,
        public string $paymentMethod = "card",
    ) {}
}

abstract class CheckoutProcess
{
    /** Шаблонный метод */
    final public function checkout(CheckoutContext $ctx): bool
    {
        try {
            $this->validateCart($ctx);
            $this->applyDiscounts($ctx);
            $this->calculateShipping($ctx);
            $ctx->total = $ctx->subtotal - $ctx->discount + $ctx->shipping;
            if (!$this->processPayment($ctx)) return false;
            $this->createOrder($ctx);
            $this->sendNotification($ctx);
            $this->afterCheckout($ctx);  // хук
            return true;
        } catch (\ValueError $e) {
            echo "[Checkout] Ошибка: {$e->getMessage()}\n";
            return false;
        }
    }

    // Хук
    protected function afterCheckout(CheckoutContext $ctx): void {}

    // Шаги с реализацией по умолчанию
    protected function validateCart(CheckoutContext $ctx): void
    {
        if (empty($ctx->items)) throw new \ValueError("Корзина пуста");
        $ctx->subtotal = array_sum(array_map(fn($i) => $i["price"] * $i["qty"], $ctx->items));
        echo "[Checkout] Корзина валидна, подытог: {$ctx->subtotal}\n";
    }

    protected function applyDiscounts(CheckoutContext $ctx): void { $ctx->discount = 0.0; }

    protected function sendNotification(CheckoutContext $ctx): void
    {
        echo "[Checkout] Уведомление для заказа {$ctx->orderId}\n";
    }

    // Абстрактные шаги
    abstract protected function calculateShipping(CheckoutContext $ctx): void;
    abstract protected function processPayment(CheckoutContext $ctx): bool;
    abstract protected function createOrder(CheckoutContext $ctx): void;
}

class StandardCheckout extends CheckoutProcess
{
    protected function applyDiscounts(CheckoutContext $ctx): void
    {
        // TODO: промокод SAVE10 → скидка 10%
    }

    protected function calculateShipping(CheckoutContext $ctx): void
    {
        // TODO: бесплатно при subtotal > 1000, иначе 200
    }

    protected function processPayment(CheckoutContext $ctx): bool
    {
        echo "[Standard] Оплата картой: {$ctx->total}\n";
        return true;
    }

    protected function createOrder(CheckoutContext $ctx): void
    {
        // TODO: orderId = "ORD-{userId}-{time()}"
    }

    protected function afterCheckout(CheckoutContext $ctx): void
    {
        // TODO: начислить бонусные баллы 1% от total
    }
}

class GuestCheckout extends CheckoutProcess
{
    protected function calculateShipping(CheckoutContext $ctx): void
    {
        // TODO: фиксированная доставка 300
    }

    protected function processPayment(CheckoutContext $ctx): bool
    {
        echo "[Guest] Оплата: {$ctx->total}\n";
        return true;
    }

    protected function createOrder(CheckoutContext $ctx): void
    {
        // TODO: orderId = "GUEST-{time()}", без сохранения
    }

    protected function sendNotification(CheckoutContext $ctx): void
    {
        echo "[Guest] Email-уведомление для заказа {$ctx->orderId}\n";
    }
}

class SubscriptionCheckout extends CheckoutProcess
{
    protected function applyDiscounts(CheckoutContext $ctx): void
    {
        // TODO: скидка 15%
    }

    protected function calculateShipping(CheckoutContext $ctx): void
    {
        $ctx->shipping = 0.0;
        echo "[Subscription] Доставка: бесплатно\n";
    }

    protected function processPayment(CheckoutContext $ctx): bool
    {
        echo "[Subscription] Повторный платёж: {$ctx->total}\n";
        return true;
    }

    protected function createOrder(CheckoutContext $ctx): void
    {
        // TODO: orderId = "SUB-{userId}-{time()}"
    }
}

// Ожидаемый результат:
// $items = [["name" => "Book", "price" => 500, "qty" => 2]];
// $ctx = new CheckoutContext($items, "user42", "SAVE10");
// (new StandardCheckout())->checkout($ctx);
// echo $ctx->orderId . "\n";   // "ORD-user42-..."
// echo $ctx->discount . "\n";  // 100.0
// echo $ctx->shipping . "\n";  // 0.0
// echo $ctx->total . "\n";     // 900.0

Задача 5. Тест с вариантами оценивания

Реализуйте систему тестирования с шаблонным методом run_test(). Шаги: показать инструкции → дать вопросы → собрать ответы → оценить → вывести результат. Создайте подклассы: MultipleChoiceTest (один правильный ответ), OpenAnswerTest (текстовые ответы с ключевыми словами), TimedTest (с ограничением времени через хук).

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import time


@dataclass
class Question:
    text: str
    answer: str
    options: list[str] = field(default_factory=list)  # для Multiple Choice
    keywords: list[str] = field(default_factory=list)  # для Open Answer
    points: int = 1


@dataclass
class TestResult:
    score: int = 0
    max_score: int = 0
    answers: list[str] = field(default_factory=list)
    feedback: list[str] = field(default_factory=list)
    passed: bool = False


class Test(ABC):
    def __init__(self, questions: list[Question],
                 pass_threshold: float = 0.6) -> None:
        self.questions = questions
        self.pass_threshold = pass_threshold

    def run_test(self, answers: list[str]) -> TestResult:
        """Шаблонный метод."""
        self.show_instructions()
        self.before_evaluation(answers)  # хук
        result = TestResult(max_score=sum(q.points for q in self.questions))
        for i, (question, answer) in enumerate(
                zip(self.questions, answers)):
            score, feedback = self.evaluate_answer(question, answer)
            result.score += score
            result.answers.append(answer)
            result.feedback.append(feedback)
        result.passed = (result.score / result.max_score
                         >= self.pass_threshold)
        self.show_result(result)
        return result

    # Хук
    def before_evaluation(self, answers: list[str]) -> None:
        pass

    # Шаги с реализацией по умолчанию
    def show_instructions(self) -> None:
        print(f"[Test] Тест из {len(self.questions)} вопросов. "
              f"Порог: {self.pass_threshold * 100:.0f}%")

    def show_result(self, result: TestResult) -> None:
        status = "ЗАЧЁТ" if result.passed else "НЕЗАЧЁТ"
        print(f"[Test] {status}: {result.score}/{result.max_score}")

    # Абстрактный шаг
    @abstractmethod
    def evaluate_answer(self, question: Question,
                        answer: str) -> tuple[int, str]:
        """Вернуть (набранные баллы, комментарий)."""
        ...


class MultipleChoiceTest(Test):
    def evaluate_answer(self, question: Question,
                        answer: str) -> tuple[int, str]:
        # TODO: сравнить ответ с question.answer (без учёта регистра)
        pass


class OpenAnswerTest(Test):
    def evaluate_answer(self, question: Question,
                        answer: str) -> tuple[int, str]:
        # TODO: проверить наличие ключевых слов из question.keywords в ответе
        #       балл = points * (найденных_ключей / всего_ключей)
        pass


class TimedTest(Test):
    def __init__(self, questions: list[Question],
                 time_limit_sec: int = 60,
                 pass_threshold: float = 0.6) -> None:
        super().__init__(questions, pass_threshold)
        self.time_limit_sec = time_limit_sec
        self._start_time: float = 0.0

    def before_evaluation(self, answers: list[str]) -> None:
        # TODO: запомнить время начала
        pass

    def evaluate_answer(self, question: Question,
                        answer: str) -> tuple[int, str]:
        # TODO: проверить не истекло ли время, затем обычная проверка
        pass

    def show_result(self, result: TestResult) -> None:
        elapsed = time.time() - self._start_time
        print(f"[TimedTest] Затрачено: {elapsed:.1f}s / {self.time_limit_sec}s")
        super().show_result(result)


# Ожидаемый результат:
# questions = [
#     Question("Столица России?", "Москва", options=["Москва","Питер","Казань"]),
#     Question("2 + 2 = ?", "4", options=["3","4","5","6"]),
# ]
# test = MultipleChoiceTest(questions, pass_threshold=0.5)
# result = test.run_test(["Москва", "3"])
# print(result.score)    # 1
# print(result.passed)   # True (1/2 = 50% >= 50%)
#
# open_q = [Question("Что такое ООП?", "",
#           keywords=["объект","класс","инкапсуляция"])]
# test2 = OpenAnswerTest(open_q)
# result2 = test2.run_test(["Программирование с объектами и классами"])
# print(result2.score)   # частичный балл
<?php
class Question
{
    public function __construct(
        public readonly string $text,
        public readonly string $answer,
        public readonly array $options = [],
        public readonly array $keywords = [],
        public readonly int $points = 1,
    ) {}
}

class TestResult
{
    public int $score = 0;
    public int $maxScore = 0;
    public array $answers = [];
    public array $feedback = [];
    public bool $passed = false;
}

abstract class Test
{
    public function __construct(
        protected readonly array $questions,
        protected readonly float $passThreshold = 0.6,
    ) {}

    /** Шаблонный метод */
    final public function runTest(array $answers): TestResult
    {
        $this->showInstructions();
        $this->beforeEvaluation($answers);  // хук
        $result = new TestResult();
        $result->maxScore = array_sum(array_map(fn($q) => $q->points, $this->questions));
        foreach ($this->questions as $i => $question) {
            $answer = $answers[$i] ?? "";
            [$score, $feedback] = $this->evaluateAnswer($question, $answer);
            $result->score += $score;
            $result->answers[] = $answer;
            $result->feedback[] = $feedback;
        }
        $result->passed = ($result->score / $result->maxScore) >= $this->passThreshold;
        $this->showResult($result);
        return $result;
    }

    // Хук
    protected function beforeEvaluation(array $answers): void {}

    // Шаги с реализацией по умолчанию
    protected function showInstructions(): void
    {
        $count = count($this->questions);
        $pct = $this->passThreshold * 100;
        echo "[Test] Тест из $count вопросов. Порог: {$pct}%\n";
    }

    protected function showResult(TestResult $result): void
    {
        $status = $result->passed ? "ЗАЧЁТ" : "НЕЗАЧЁТ";
        echo "[Test] $status: {$result->score}/{$result->maxScore}\n";
    }

    // Абстрактный шаг
    abstract protected function evaluateAnswer(Question $question, string $answer): array;
}

class MultipleChoiceTest extends Test
{
    protected function evaluateAnswer(Question $question, string $answer): array
    {
        // TODO: сравнить с question->answer без учёта регистра
    }
}

class OpenAnswerTest extends Test
{
    protected function evaluateAnswer(Question $question, string $answer): array
    {
        // TODO: проверить ключевые слова из question->keywords в ответе
        //       балл = points * (найденных / всего)
    }
}

class TimedTest extends Test
{
    private float $startTime = 0.0;

    public function __construct(
        array $questions,
        private readonly int $timeLimitSec = 60,
        float $passThreshold = 0.6,
    ) {
        parent::__construct($questions, $passThreshold);
    }

    protected function beforeEvaluation(array $answers): void
    {
        // TODO: сохранить время начала
    }

    protected function evaluateAnswer(Question $question, string $answer): array
    {
        // TODO: проверить время, затем обычная проверка MC
    }

    protected function showResult(TestResult $result): void
    {
        $elapsed = round(microtime(true) - $this->startTime, 1);
        echo "[TimedTest] Затрачено: {$elapsed}s / {$this->timeLimitSec}s\n";
        parent::showResult($result);
    }
}

// Ожидаемый результат:
// $questions = [
//     new Question("Столица России?", "Москва", ["Москва","Питер","Казань"]),
//     new Question("2 + 2 = ?", "4", ["3","4","5","6"]),
// ];
// $test = new MultipleChoiceTest($questions, passThreshold: 0.5);
// $result = $test->runTest(["Москва", "3"]);
// echo $result->score . "\n";   // 1
// var_dump($result->passed);    // true (1/2 = 50% >= 50%)
//
// $openQ = [new Question("Что такое ООП?", "", keywords: ["объект","класс","инкапсуляция"])];
// $test2 = new OpenAnswerTest($openQ);
// $result2 = $test2->runTest(["Программирование с объектами и классами"]);
// echo $result2->score . "\n";  // частичный балл