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

Снимок

Также известен как: Хранитель, Memento

Снимок — это поведенческий паттерн проектирования, который позволяет сохранять и восстанавливать прошлые состояния объектов, не раскрывая подробностей их реализации.

Паттерн Снимок

Проблема

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

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

Схема отмены операций в редакторе

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

Чтобы сделать копию состояния объекта, достаточно скопировать значение его полей. Таким образом, если вы сделали класс редактора достаточно открытым, то любой другой класс сможет заглянуть внутрь, чтобы скопировать его состояние.

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

Как команде создать снимок состояния редактора, если все его поля приватные?

Как команде создать снимок состояния редактора, если все его поля приватные?

Но это ещё не все. Давайте теперь рассмотрим сами копии состояния редактора. Из чего состоит состояние редактора? Даже самый примитивный редактор должен иметь несколько полей для хранения текущего текста, позиции курсора и прокрутки экрана. Чтобы сделать копию состояния, вам нужно записать значения всех этих полей в некий «контейнер».

Скорее всего, вам понадобится хранить массу таких контейнеров в качестве истории операций, поэтому удобнее всего сделать их объектами одного класса. Этот класс должен иметь много полей, но практически никаких методов. Чтобы другие объекты могли записывать и читать из него данные, вам придётся сделать его поля публичными. Но это приведёт к той же проблеме, что и с открытым классом редактора. Другие классы станут зависимыми от любых изменений в классе контейнера, который подвержен тем же изменениям, что и класс редактора.

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

Решение

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

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

Паттерн предлагает держать копию состояния в специальном объекте-снимке с ограниченным интерфейсом, позволяющим, например, узнать дату изготовления или название снимка. Но, с другой стороны, снимок должен быть открыт для своего создателя, позволяя прочесть и восстановить его внутреннее состояние.

Снимок полностью открыт для создателя, но лишь частично открыт для опекунов

Снимок полностью открыт для создателя, но лишь частично открыт для опекунов.

Такая схема позволяет создателям производить снимки и отдавать их для хранения другим объектам, называемым опекунами. Опекунам будет доступен только ограниченный интерфейс снимка, поэтому они никак не смогут повлиять на «внутренности» самого снимка. В нужный момент опекун может попросить создателя восстановить своё состояние, передав ему соответствующий снимок.

В примере с редактором вы можете сделать опекуном отдельный класс, который будет хранить список выполненных операций. Ограниченный интерфейс снимков позволит демонстрировать пользователю красивый список с названиями и датами выполненных операций. А когда пользователь решит откатить операцию, класс истории возьмёт последний снимок из стека и отправит его объекту редактор для восстановления.

Структура

Классическая реализация на вложенных классах

Классическая реализация паттерна полагается на механизм вложенных классов, который доступен лишь в некоторых языках программирования (C++, C#, Java).

Структура классов паттерна Снимок (Хранитель)

  1. Создатель может производить снимки своего состояния, а также воспроизводить прошлое состояние, если подать в него готовый снимок.
  2. Снимок — это простой объект данных, содержащий состояние создателя. Надёжнее всего сделать объекты снимков неизменяемыми, передавая в них состояние только через конструктор.
  3. Опекун должен знать, когда делать снимок создателя и когда его нужно восстанавливать.
    Опекун может хранить историю прошлых состояний создателя в виде стека из снимков. Когда понадобится отменить выполненную операцию, он возьмёт «верхний» снимок из стека и передаст его создателю для восстановления.
  4. В данной реализации снимок — это внутренний класс по отношению к классу создателя. Именно поэтому он имеет полный доступ к полям и методам создателя, даже приватным. С другой стороны, опекун не имеет доступа ни к состоянию, ни к методам снимков и может всего лишь хранить ссылки на эти объекты.

Реализация с пустым промежуточным интерфейсом

Подходит для языков, не имеющих механизма вложенных классов (например, PHP).

Структура классов паттерна Снимок (Хранитель)

  1. В этой реализации создатель работает напрямую с конкретным классом снимка, а опекун — только с его ограниченным интерфейсом.
  2. Благодаря этому достигается тот же эффект, что и в классической реализации. Создатель имеет полный доступ к снимку, а опекун — нет.

Снимки с повышенной защитой

Когда нужно полностью исключить возможность доступа к состоянию создателей и снимков.

Снимок с повышенной защитой

  1. Эта реализация разрешает иметь несколько видов создателей и снимков. Каждому классу создателей соответствует свой класс снимков. Ни создатели, ни снимки не позволяют другим объектам прочесть своё состояние.
  2. Здесь опекун ещё более жёстко ограничен в доступе к состоянию создателей и снимков. Но, с другой стороны, опекун становится независим от создателей, поскольку метод восстановления теперь находится в самих снимках.
  3. Снимки теперь связаны с теми создателями, из которых они сделаны. Они по-прежнему получают состояние через конструктор. Благодаря близкой связи между классами, снимки знают, как восстановить состояние своих создателей.

Псевдокод

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

Структура классов примера паттерна Снимок

Пример сохранения снимков состояния текстового редактора.

Объекты команд выступают в роли опекунов и запрашивают снимки у редактора перед тем, как выполнить своё действие. Если потребуется отменить операцию, команда сможет восстановить состояние редактора, используя сохранённый снимок.

При этом снимок не имеет публичных полей, поэтому другие объекты не имеют доступа к его внутренним данным. Снимки связаны с определённым редактором, который их создал. Они же и восстанавливают состояние своего редактора. Это позволяет программе иметь одновременно несколько объектов редакторов, например, разбитых по разным вкладкам программы.

// Класс создателя должен иметь специальный метод, который
// сохраняет состояние создателя в новом объекте-снимке.
class Editor is
    private field text, curX, curY, selectionWidth

    method setText(text) is
        this.text = text

    method setCursor(x, y) is
        this.curX = x
        this.curY = y

    method setSelectionWidth(width) is
        this.selectionWidth = width

    method createSnapshot():Snapshot is
        // Снимок — неизменяемый объект, поэтому Создатель
        // передаёт все своё состояние через параметры
        // конструктора.
        return new Snapshot(this, text, curX, curY, selectionWidth)

// Снимок хранит прошлое состояние редактора.
class Snapshot is
    private field editor: Editor
    private field text, curX, curY, selectionWidth

    constructor Snapshot(editor, text, curX, curY, selectionWidth) is
        this.editor = editor
        this.text = text
        this.curX = x
        this.curY = y
        this.selectionWidth = selectionWidth

    // В нужный момент владелец снимка может восстановить
    // состояние редактора.
    method restore() is
        editor.setText(text)
        editor.setCursor(curX, curY)
        editor.setSelectionWidth(selectionWidth)

// Опекуном может выступать класс команд (см. паттерн Команда).
// В этом случае команда сохраняет снимок состояния объекта-
// получателя, перед тем как передать ему своё действие. А в
// случае отмены команда вернёт объект в прежнее состояние.
class Command is
    private field backup: Snapshot

    method makeBackup() is
        backup = editor.createSnapshot()

    method undo() is
        if (backup != null)
            backup.restore()
    // ...

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

Когда вам нужно сохранять мгновенные снимки состояния объекта (или его части), чтобы впоследствии объект можно было восстановить в том же состоянии.

Паттерн Снимок позволяет создавать любое количество снимков объекта и хранить их, независимо от объекта, с которого делают снимок. Снимки часто используют не только для реализации операции отмены, но и для транзакций, когда состояние объекта нужно «откатить», если операция не удалась.

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

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

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

  1. Определите класс создателя, объекты которого должны создавать снимки своего состояния.
  2. Создайте класс снимка и опишите в нём все те же поля, которые имеются в оригинальном классе-создателе.
  3. Сделайте объекты снимков неизменяемыми. Они должны получать начальные значения только один раз, через свой конструктор.
  4. Если ваш язык программирования это позволяет, сделайте класс снимка вложенным в класс создателя. Если нет, извлеките из класса снимка пустой интерфейс, который будет доступен остальным объектам программы. Впоследствии вы можете добавить в этот интерфейс некоторые вспомогательные методы, дающие доступ к метаданным снимка, однако прямой доступ к данным создателя должен быть исключён.
  5. Добавьте в класс создателя метод получения снимков. Создатель должен создавать новые объекты снимков, передавая значения своих полей через конструктор.
    Сигнатура метода должна возвращать снимки через ограниченный интерфейс, если он у вас есть. Сам класс должен работать с конкретным классом снимка.
  6. Добавьте в класс создателя метод восстановления из снимка. Что касается привязки к типам, руководствуйтесь той же логикой, что и в пункте 4.
  7. Опекуны, будь то история операций, объекты команд или нечто иное, должны знать о том, когда запрашивать снимки у создателя, где их хранить и когда восстанавливать.
  8. Связь опекунов с создателями можно перенести внутрь снимков. В этом случае каждый снимок будет привязан к своему создателю и должен будет сам восстанавливать его состояние. Но это будет работать либо если классы снимков вложены в классы создателей, либо если создатели имеют соответствующие сеттеры для установки значений своих полей.

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

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

  • Не нарушает инкапсуляции исходного объекта.
  • Упрощает структуру исходного объекта. Ему не нужно хранить историю версий своего состояния.

Недостатки

  • Требует много памяти, если клиенты слишком часто создают снимки.
  • Может повлечь дополнительные издержки памяти, если объекты, хранящие историю, не освобождают ресурсы, занятые устаревшими снимками.
  • В некоторых языках (например, PHP, Python, JavaScript) сложно гарантировать, чтобы только исходный объект имел доступ к состоянию снимка.

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

Python

  • Сложность: 3/3
  • Популярность: 1/3
  • Применимость: Снимок на Python чаще всего реализуют с помощью сериализации. Но это не единственный, да и не самый эффективный метод сохранения состояния объектов во время выполнения программы.

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

from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from random import sample
from string import ascii_letters


class Originator:
    """
    Создатель содержит некоторое важное состояние, которое может со
    временем меняться. Он также объявляет метод сохранения состояния
    внутри снимка и метод восстановления состояния из него.
    """

    _state = None
    """
    Для удобства состояние создателя хранится внутри одной переменной.
    """

    def __init__(self, state: str) -> None:
        self._state = state
        print(f"Originator: My initial state is: {self._state}")

    def do_something(self) -> None:
        """
        Бизнес-логика Создателя может повлиять на его внутреннее
        состояние. Поэтому клиент должен выполнить резервное
        копирование состояния с помощью метода save перед запуском
        методов бизнес-логики.
        """

        print("Originator: I'm doing something important.")
        self._state = self._generate_random_string(30)
        print("Originator: and my state has changed to: "
            + self._state)

    @staticmethod
    def _generate_random_string(length: int = 10) -> str:
        return "".join(sample(ascii_letters, length))

    def save(self) -> Memento:
        """
        Сохраняет текущее состояние внутри снимка.
        """

        return ConcreteMemento(self._state)

    def restore(self, memento: Memento) -> None:
        """
        Восстанавливает состояние Создателя из объекта снимка.
        """

        self._state = memento.get_state()
        print(f"Originator: My state has changed to: {self._state}")


class Memento(ABC):
    """
    Интерфейс Снимка предоставляет способ извлечения метаданных
    снимка, таких как дата создания или название. Однако он не
    раскрывает состояние Создателя.
    """

    @abstractmethod
    def get_name(self) -> str:
        pass

    @abstractmethod
    def get_date(self) -> str:
        pass


class ConcreteMemento(Memento):
    def __init__(self, state: str) -> None:
        self._state = state
        self._date = str(datetime.now())[:19]

    def get_state(self) -> str:
        """
        Создатель использует этот метод, когда восстанавливает своё
        состояние.
        """
        return self._state

    def get_name(self) -> str:
        """
        Остальные методы используются Опекуном для отображения
        метаданных.
        """

        return f"{self._date} / ({self._state[0:9]}...)"

    def get_date(self) -> str:
        return self._date


class Caretaker:
    """
    Опекун не зависит от класса Конкретного Снимка. Таким образом, он 
    не имеет доступа к состоянию создателя, хранящемуся внутри снимка.
    Он работает со всеми снимками через базовый интерфейс Снимка.
    """

    def __init__(self, originator: Originator) -> None:
        self._mementos = []
        self._originator = originator

    def backup(self) -> None:
        print("\nCaretaker: Saving Originator's state...")
        self._mementos.append(self._originator.save())

    def undo(self) -> None:
        if not len(self._mementos):
            return

        memento = self._mementos.pop()
        print(f"Caretaker: Restoring state to: {memento.get_name()}")
        try:
            self._originator.restore(memento)
        except Exception:
            self.undo()

    def show_history(self) -> None:
        print("Caretaker: Here's the list of mementos:")
        for memento in self._mementos:
            print(memento.get_name())


if __name__ == "__main__":
    originator = Originator("Super-duper-super-puper-super.")
    caretaker = Caretaker(originator)

    caretaker.backup()
    originator.do_something()

    caretaker.backup()
    originator.do_something()

    caretaker.backup()
    originator.do_something()

    print()
    caretaker.show_history()

    print("\nClient: Now, let's rollback!\n")
    caretaker.undo()

    print("\nClient: Once more!\n")
    caretaker.undo()
Originator: My initial state is: Super-duper-super-puper-super.

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to:
wQAehHYOqVSlpEXjyIcgobrxsZUnat

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to:
lHxNORKcsgMWYnJqoXjVCbQLEIeiSp

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to:
cvIYsRilNOtwynaKdEZpDCQkFAXVMf

Caretaker: Here's the list of mementos:
2019-01-26 21:11:24 / (Super-dup...)
2019-01-26 21:11:24 / (wQAehHYOq...)
2019-01-26 21:11:24 / (lHxNORKcs...)

Client: Now, let's rollback!

Caretaker: Restoring state to: 2019-01-26 21:11:24 / (lHxNORKcs...)
Originator: My state has changed to: lHxNORKcsgMWYnJqoXjVCbQLEIeiSp

Client: Once more!

Caretaker: Restoring state to: 2019-01-26 21:11:24 / (wQAehHYOq...)
Originator: My state has changed to: wQAehHYOqVSlpEXjyIcgobrxsZUnat

PHP

  • Сложность: 3/3
  • Популярность: 1/3
  • Применимость: Реальная применимость паттерна Снимок в мире PHP под большим вопросом. Чаще всего задачу хранения копии состояния можно решить куда проще при помощи сериализации.

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

<?php
/**
 * Создатель содержит некоторое важное состояние, которое может со
 * временем меняться. Он также объявляет метод сохранения состояния
 * внутри снимка и метод восстановления состояния из него.
 */
class Originator
{
    /**
     * @var string Для удобства состояние создателя хранится внутри
     * одной переменной.
     */
    private $state;

    public function __construct(string $state)
    {
        $this->state = $state;
        echo "Originator: My initial state is: {$this->state}\n";
    }

    /**
     * Бизнес-логика Создателя может повлиять на его внутреннее
     * состояние. Поэтому клиент должен выполнить резервное
     * копирование состояния с помощью метода save перед запуском
     * методов бизнес-логики.
     */
    public function doSomething(): void
    {
        echo "Originator: I'm doing something important.\n";
        $this->state = $this->generateRandomString(30);
        echo "Originator: and my state has changed to: {$this->state}"
            . "\n";
    }

    private function generateRandomString(int $length = 10): string
    {
        return substr(
            str_shuffle(
                str_repeat(
                    $x = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ'
                        . 'RSTUVWXYZ',
                    ceil($length / strlen($x))
                )
            ),
            1,
            $length,
        );
    }

    /**
     * Сохраняет текущее состояние внутри снимка.
     */
    public function save(): Memento
    {
        return new ConcreteMemento($this->state);
    }

    /**
     * Восстанавливает состояние Создателя из объекта снимка.
     */
    public function restore(Memento $memento): void
    {
        $this->state = $memento->getState();
        echo "Originator: My state has changed to: {$this->state}\n";
    }
}

/**
 * Интерфейс Снимка предоставляет способ извлечения метаданных снимка,
 * таких как дата создания или название. Однако он не раскрывает
 * состояние Создателя.
 */
interface Memento
{
    public function getName(): string;

    public function getDate(): string;
}

/**
 * Конкретный снимок содержит инфраструктуру для хранения состояния
 * Создателя.
 */
class ConcreteMemento implements Memento
{
    private $state;

    private $date;

    public function __construct(string $state)
    {
        $this->state = $state;
        $this->date = date('Y-m-d H:i:s');
    }

    /**
     * Создатель использует этот метод, когда восстанавливает своё
     * состояние.
     */
    public function getState(): string
    {
        return $this->state;
    }

    /**
     * Остальные методы используются Опекуном для отображения
     * метаданных.
     */
    public function getName(): string
    {
        return $this->date . " / (" . substr($this->state, 0, 9) 
            . "...)";
    }

    public function getDate(): string
    {
        return $this->date;
    }
}

/**
 * Опекун не зависит от класса Конкретного Снимка. Таким образом, он
 * не имеет доступа к состоянию создателя, хранящемуся внутри снимка.
 * Он работает со всеми снимками через базовый интерфейс Снимка.
 */
class Caretaker
{
    /**
     * @var Memento[]
     */
    private $mementos = [];

    /**
     * @var Originator
     */
    private $originator;

    public function __construct(Originator $originator)
    {
        $this->originator = $originator;
    }

    public function backup(): void
    {
        echo "\nCaretaker: Saving Originator's state...\n";
        $this->mementos[] = $this->originator->save();
    }

    public function undo(): void
    {
        if (!count($this->mementos)) {
            return;
        }
        $memento = array_pop($this->mementos);

        echo "Caretaker: Restoring state to: " . $memento->getName()
            . "\n";
        try {
            $this->originator->restore($memento);
        } catch (\Exception $e) {
            $this->undo();
        }
    }

    public function showHistory(): void
    {
        echo "Caretaker: Here's the list of mementos:\n";
        foreach ($this->mementos as $memento) {
            echo $memento->getName() . "\n";
        }
    }
}

/**
 * Клиентский код.
 */
$originator = new Originator("Super-duper-super-puper-super.");
$caretaker = new Caretaker($originator);

$caretaker->backup();
$originator->doSomething();

$caretaker->backup();
$originator->doSomething();

$caretaker->backup();
$originator->doSomething();

echo "\n";
$caretaker->showHistory();

echo "\nClient: Now, let's rollback!\n\n";
$caretaker->undo();

echo "\nClient: Once more!\n\n";
$caretaker->undo();
Originator: My initial state is: Super-duper-super-puper-super. 

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to:
srGIngezAEboNPDjBkuvymJKUtMSFX

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to:
UwCZQaHJOiERLlchyVuMbXNtpqTgWF

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to:
incqsdoJXkbDUuVOvRFYyKBgfzwZCQ

Caretaker: Here's the list of mementos:
2018-06-04 14:50:39 / (Super-dup...)
2018-06-04 14:50:39 / (srGIngezA...)
2018-06-04 14:50:39 / (UwCZQaHJO...)

Client: Now, let's rollback!

Caretaker: Restoring state to: 2018-06-04 14:50:39 / (UwCZQaHJO...)
Originator: My state has changed to: UwCZQaHJOiERLlchyVuMbXNtpqTgWF

Client: Once more!

Caretaker: Restoring state to: 2018-06-04 14:50:39 / (srGIngezA...)
Originator: My state has changed to: srGIngezAEboNPDjBkuvymJKUtMSFX

Задачи

Задача 1. Текстовый редактор с undo/redo

Реализуйте текстовый редактор с полноценной поддержкой undo и redo. Редактор поддерживает операции: ввод текста, удаление, изменение позиции курсора. Каждое действие сохраняет снимок состояния перед выполнением.

from __future__ import annotations
from dataclasses import dataclass


@dataclass(frozen=True)
class EditorMemento:
    text: str
    cursor_pos: int
    selection_start: int
    selection_end: int
    label: str = ""


class TextEditor:
    def __init__(self) -> None:
        self._text = ""
        self._cursor_pos = 0
        self._selection_start = 0
        self._selection_end = 0

    def save(self, label: str = "") -> EditorMemento:
        # TODO: сохранить текущее состояние в снимок
        pass

    def restore(self, memento: EditorMemento) -> None:
        # TODO: восстановить состояние из снимка
        pass

    def type_text(self, text: str) -> None:
        # TODO: вставить text в позицию курсора, сдвинуть курсор
        pass

    def delete_char(self) -> None:
        # TODO: удалить символ перед курсором (backspace)
        pass

    def move_cursor(self, pos: int) -> None:
        self._cursor_pos = max(0, min(pos, len(self._text)))

    def select(self, start: int, end: int) -> None:
        self._selection_start = start
        self._selection_end = end

    def delete_selection(self) -> None:
        # TODO: удалить выделенный текст
        pass

    @property
    def text(self) -> str:
        return self._text

    @property
    def cursor_pos(self) -> int:
        return self._cursor_pos


class EditorHistory:
    def __init__(self, editor: TextEditor) -> None:
        self._editor = editor
        self._undo_stack: list[EditorMemento] = []
        self._redo_stack: list[EditorMemento] = []

    def save(self, label: str = "") -> None:
        # TODO: сохранить снимок, очистить redo-стек
        pass

    def undo(self) -> bool:
        # TODO: восстановить предыдущее состояние,
        #       текущее положить в redo-стек
        pass

    def redo(self) -> bool:
        # TODO: применить отменённое действие,
        #       текущее положить в undo-стек
        pass

    def show_history(self) -> None:
        for m in self._undo_stack:
            print(f"  - {m.label or '(без метки)'}: '{m.text}'")


# Ожидаемый результат:
# editor = TextEditor()
# history = EditorHistory(editor)
# history.save("initial")
# editor.type_text("Hello")
# history.save("typed Hello")
# editor.type_text(", World")
# history.save("typed , World")
# print(editor.text)   # "Hello, World"
# history.undo()
# print(editor.text)   # "Hello"
# history.undo()
# print(editor.text)   # ""
# history.redo()
# print(editor.text)   # "Hello"
<?php
class EditorMemento
{
    public function __construct(
        private readonly string $text,
        private readonly int $cursorPos,
        private readonly int $selectionStart,
        private readonly int $selectionEnd,
        private readonly string $label = "",
    ) {}

    public function getText(): string { return $this->text; }
    public function getCursorPos(): int { return $this->cursorPos; }
    public function getSelectionStart(): int { return $this->selectionStart; }
    public function getSelectionEnd(): int { return $this->selectionEnd; }
    public function getLabel(): string { return $this->label; }
}

class TextEditor
{
    private string $text = "";
    private int $cursorPos = 0;
    private int $selectionStart = 0;
    private int $selectionEnd = 0;

    public function save(string $label = ""): EditorMemento
    {
        // TODO: сохранить состояние в снимок
    }

    public function restore(EditorMemento $memento): void
    {
        // TODO: восстановить состояние из снимка
    }

    public function typeText(string $text): void
    {
        // TODO: вставить text в позицию курсора
    }

    public function deleteChar(): void
    {
        // TODO: backspace — удалить символ перед курсором
    }

    public function moveCursor(int $pos): void
    {
        $this->cursorPos = max(0, min($pos, mb_strlen($this->text)));
    }

    public function select(int $start, int $end): void
    {
        $this->selectionStart = $start;
        $this->selectionEnd = $end;
    }

    public function deleteSelection(): void
    {
        // TODO: удалить выделенный текст
    }

    public function getText(): string { return $this->text; }
    public function getCursorPos(): int { return $this->cursorPos; }
}

class EditorHistory
{
    private array $undoStack = [];
    private array $redoStack = [];

    public function __construct(private readonly TextEditor $editor) {}

    public function save(string $label = ""): void
    {
        // TODO: сохранить снимок, очистить redoStack
    }

    public function undo(): bool
    {
        // TODO: откатить к предыдущему состоянию,
        //       положить текущее в redoStack
    }

    public function redo(): bool
    {
        // TODO: применить отменённое действие,
        //       положить текущее в undoStack
    }

    public function showHistory(): void
    {
        foreach ($this->undoStack as $m) {
            $label = $m->getLabel() ?: "(без метки)";
            echo "  - $label: '{$m->getText()}'\n";
        }
    }
}

// Ожидаемый результат:
// $editor = new TextEditor();
// $history = new EditorHistory($editor);
// $history->save("initial");
// $editor->typeText("Hello");
// $history->save("typed Hello");
// $editor->typeText(", World");
// $history->save("typed , World");
// echo $editor->getText() . "\n";  // "Hello, World"
// $history->undo();
// echo $editor->getText() . "\n";  // "Hello"
// $history->undo();
// echo $editor->getText() . "\n";  // ""
// $history->redo();
// echo $editor->getText() . "\n";  // "Hello"

Задача 2. Настройки игры с несколькими слотами сохранения

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

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from copy import deepcopy


@dataclass
class GameMemento:
    level: int
    hp: int
    position: tuple[float, float]
    inventory: list[str]
    saved_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    slot_name: str = ""

    def __post_init__(self) -> None:
        # Инвентарь должен быть независимой копией
        self.inventory = list(self.inventory)


class GameCharacter:
    def __init__(self, name: str) -> None:
        self.name = name
        self.level = 1
        self.hp = 100
        self.position = (0.0, 0.0)
        self.inventory: list[str] = []

    def save(self, slot_name: str = "") -> GameMemento:
        # TODO: создать снимок текущего состояния
        pass

    def restore(self, memento: GameMemento) -> None:
        # TODO: восстановить состояние из снимка
        pass

    def level_up(self) -> None:
        self.level += 1
        self.hp = min(100, self.hp + 20)

    def move_to(self, x: float, y: float) -> None:
        self.position = (x, y)

    def pick_up(self, item: str) -> None:
        self.inventory.append(item)

    def take_damage(self, amount: int) -> None:
        self.hp = max(0, self.hp - amount)

    def __str__(self) -> str:
        return (f"{self.name} | Lvl:{self.level} HP:{self.hp} "
                f"Pos:{self.position} Inv:{self.inventory}")


class SaveManager:
    def __init__(self) -> None:
        self._slots: dict[str, GameMemento] = {}

    def save(self, character: GameCharacter, slot: str) -> None:
        # TODO: сохранить снимок персонажа в слот
        pass

    def load(self, character: GameCharacter, slot: str) -> bool:
        # TODO: загрузить снимок из слота, вернуть False если слот пуст
        pass

    def list_slots(self) -> None:
        # TODO: вывести все слоты с метаданными (имя, дата, уровень)
        pass

    def delete_slot(self, slot: str) -> bool:
        # TODO: удалить слот
        pass


# Ожидаемый результат:
# hero = GameCharacter("Aragorn")
# saves = SaveManager()
# hero.pick_up("Sword"); hero.move_to(10, 5)
# saves.save(hero, "slot1")
# hero.level_up(); hero.pick_up("Shield"); hero.move_to(50, 30)
# saves.save(hero, "slot2")
# hero.take_damage(80)
# print(hero)   # Aragorn | Lvl:2 HP:20 Pos:(50, 30) Inv:['Sword', 'Shield']
# saves.load(hero, "slot1")
# print(hero)   # Aragorn | Lvl:1 HP:100 Pos:(10, 5) Inv:['Sword']
# saves.list_slots()
<?php
class GameMemento
{
    private readonly string $savedAt;

    public function __construct(
        private readonly int $level,
        private readonly int $hp,
        private readonly array $position,
        private readonly array $inventory,
        private readonly string $slotName = "",
    ) {
        $this->savedAt = date("Y-m-d H:i:s");
    }

    public function getLevel(): int { return $this->level; }
    public function getHp(): int { return $this->hp; }
    public function getPosition(): array { return $this->position; }
    public function getInventory(): array { return $this->inventory; }
    public function getSavedAt(): string { return $this->savedAt; }
    public function getSlotName(): string { return $this->slotName; }
}

class GameCharacter
{
    public int $level = 1;
    public int $hp = 100;
    public array $position = [0.0, 0.0];
    public array $inventory = [];

    public function __construct(private readonly string $name) {}

    public function save(string $slotName = ""): GameMemento
    {
        // TODO: создать снимок текущего состояния
    }

    public function restore(GameMemento $memento): void
    {
        // TODO: восстановить состояние из снимка
    }

    public function levelUp(): void { $this->level++; $this->hp = min(100, $this->hp + 20); }
    public function moveTo(float $x, float $y): void { $this->position = [$x, $y]; }
    public function pickUp(string $item): void { $this->inventory[] = $item; }
    public function takeDamage(int $amount): void { $this->hp = max(0, $this->hp - $amount); }

    public function __toString(): string
    {
        $pos = implode(", ", $this->position);
        $inv = implode(", ", $this->inventory);
        return "{$this->name} | Lvl:{$this->level} HP:{$this->hp} Pos:($pos) Inv:[$inv]";
    }
}

class SaveManager
{
    private array $slots = [];

    public function save(GameCharacter $character, string $slot): void
    {
        // TODO: сохранить снимок в слот
    }

    public function load(GameCharacter $character, string $slot): bool
    {
        // TODO: загрузить из слота, вернуть false если пусто
    }

    public function listSlots(): void
    {
        // TODO: вывести все слоты с метаданными
    }

    public function deleteSlot(string $slot): bool
    {
        // TODO: удалить слот
    }
}

// Ожидаемый результат:
// $hero = new GameCharacter("Aragorn");
// $saves = new SaveManager();
// $hero->pickUp("Sword"); $hero->moveTo(10, 5);
// $saves->save($hero, "slot1");
// $hero->levelUp(); $hero->pickUp("Shield"); $hero->moveTo(50, 30);
// $saves->save($hero, "slot2");
// $hero->takeDamage(80);
// echo $hero . "\n";   // Aragorn | Lvl:2 HP:20 Pos:(50, 30) Inv:[Sword, Shield]
// $saves->load($hero, "slot1");
// echo $hero . "\n";   // Aragorn | Lvl:1 HP:100 Pos:(10, 5) Inv:[Sword]
// $saves->listSlots();

Задача 3. Транзакционный менеджер конфигурации

Реализуйте менеджер конфигурации приложения с поддержкой транзакций. Можно начать транзакцию (begin), вносить изменения, а затем либо зафиксировать (commit), либо откатить (rollback). Поддерживаются вложенные транзакции (savepoint).

from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field


@dataclass
class ConfigMemento:
    data: dict
    savepoint_name: str = ""

    def __post_init__(self) -> None:
        self.data = deepcopy(self.data)


class ConfigManager:
    def __init__(self, initial: dict | None = None) -> None:
        self._config: dict = deepcopy(initial or {})
        self._transaction_stack: list[ConfigMemento] = []
        self._in_transaction = False

    def get(self, key: str, default=None):
        # TODO: получить значение по ключу (поддержка вложенных: "db.host")
        pass

    def set(self, key: str, value) -> None:
        # TODO: установить значение (поддержка вложенных: "db.host")
        pass

    def delete(self, key: str) -> bool:
        # TODO: удалить ключ
        pass

    def begin(self, savepoint: str = "") -> None:
        # TODO: начать транзакцию — сохранить снимок текущего состояния
        pass

    def commit(self) -> None:
        # TODO: зафиксировать транзакцию — убрать снимок из стека
        pass

    def rollback(self, savepoint: str = "") -> None:
        # TODO: откатить к снимку (по имени savepoint или к последнему)
        pass

    def all(self) -> dict:
        return deepcopy(self._config)


# Ожидаемый результат:
# cfg = ConfigManager({"db": {"host": "localhost", "port": 5432}})
# print(cfg.get("db.host"))   # localhost
# cfg.begin("before_update")
# cfg.set("db.host", "prod.example.com")
# cfg.set("app.debug", False)
# print(cfg.get("db.host"))   # prod.example.com
# cfg.rollback("before_update")
# print(cfg.get("db.host"))   # localhost
# print(cfg.get("app.debug")) # None
#
# cfg.begin()
# cfg.set("db.port", 5433)
# cfg.commit()
# print(cfg.get("db.port"))   # 5433
<?php
class ConfigMemento
{
    private readonly array $data;

    public function __construct(array $data, private readonly string $savepointName = "")
    {
        $this->data = $data;  // массивы копируются по значению в PHP
    }

    public function getData(): array { return $this->data; }
    public function getSavepointName(): string { return $this->savepointName; }
}

class ConfigManager
{
    private array $config;
    private array $transactionStack = [];

    public function __construct(array $initial = [])
    {
        $this->config = $initial;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        // TODO: получить значение по ключу (поддержка "db.host")
    }

    public function set(string $key, mixed $value): void
    {
        // TODO: установить значение (поддержка "db.host")
    }

    public function delete(string $key): bool
    {
        // TODO: удалить ключ
    }

    public function begin(string $savepoint = ""): void
    {
        // TODO: сохранить снимок текущего состояния
    }

    public function commit(): void
    {
        // TODO: зафиксировать — убрать снимок из стека
    }

    public function rollback(string $savepoint = ""): void
    {
        // TODO: откатить к снимку по имени или к последнему
    }

    public function all(): array
    {
        return $this->config;
    }
}

// Ожидаемый результат:
// $cfg = new ConfigManager(["db" => ["host" => "localhost", "port" => 5432]]);
// echo $cfg->get("db.host") . "\n";   // localhost
// $cfg->begin("before_update");
// $cfg->set("db.host", "prod.example.com");
// $cfg->set("app.debug", false);
// echo $cfg->get("db.host") . "\n";   // prod.example.com
// $cfg->rollback("before_update");
// echo $cfg->get("db.host") . "\n";   // localhost
// var_dump($cfg->get("app.debug"));   // NULL
//
// $cfg->begin();
// $cfg->set("db.port", 5433);
// $cfg->commit();
// echo $cfg->get("db.port") . "\n";   // 5433

Задача 4. Версионирование документа

Реализуйте систему версионирования документа в стиле Git: каждый commit сохраняет снимок с сообщением и автором, поддерживаются checkout (переключение на версию), diff (список изменений между версиями) и log (история коммитов).

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime


@dataclass(frozen=True)
class DocumentSnapshot:
    content: str
    title: str
    author: str
    message: str
    timestamp: str = field(
        default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    )
    commit_hash: str = field(default="")

    def __post_init__(self) -> None:
        import hashlib
        object.__setattr__(
            self, "commit_hash",
            hashlib.md5(
                f"{self.content}{self.timestamp}".encode()
            ).hexdigest()[:8]
        )


class Document:
    def __init__(self, title: str, author: str) -> None:
        self.title = title
        self.author = author
        self.content = ""

    def edit(self, new_content: str) -> None:
        self.content = new_content

    def save(self, message: str) -> DocumentSnapshot:
        # TODO: создать снимок текущего состояния
        pass

    def restore(self, snapshot: DocumentSnapshot) -> None:
        # TODO: восстановить состояние из снимка
        pass


class VersionControl:
    def __init__(self, document: Document) -> None:
        self._document = document
        self._history: list[DocumentSnapshot] = []
        self._current_index = -1

    def commit(self, message: str) -> DocumentSnapshot:
        # TODO: сохранить снимок, обрезать историю после current_index
        pass

    def checkout(self, commit_hash: str) -> bool:
        # TODO: переключиться на версию по хешу
        pass

    def log(self) -> None:
        # TODO: вывести историю коммитов (от новых к старым)
        pass

    def diff(self, hash1: str, hash2: str) -> list[str]:
        # TODO: вернуть список строк, которые добавились/удалились
        #       (простое построчное сравнение)
        pass

    @property
    def current(self) -> DocumentSnapshot | None:
        if self._current_index >= 0:
            return self._history[self._current_index]
        return None


# Ожидаемый результат:
# doc = Document("README", "Alice")
# vc = VersionControl(doc)
# doc.edit("# Hello\nInitial content")
# v1 = vc.commit("Initial commit")
# doc.edit("# Hello\nUpdated content\nNew line")
# v2 = vc.commit("Add new line")
# vc.log()
# # [abc12345] 2026-01-01 12:00:00 Alice: Add new line
# # [def67890] 2026-01-01 11:59:00 Alice: Initial commit
# vc.checkout(v1.commit_hash)
# print(doc.content)   # "# Hello\nInitial content"
<?php
class DocumentSnapshot
{
    public readonly string $commitHash;
    public readonly string $timestamp;

    public function __construct(
        private readonly string $content,
        private readonly string $title,
        private readonly string $author,
        private readonly string $message,
    ) {
        $this->timestamp = date("Y-m-d H:i:s");
        $this->commitHash = substr(md5($this->content . $this->timestamp), 0, 8);
    }

    public function getContent(): string { return $this->content; }
    public function getTitle(): string { return $this->title; }
    public function getAuthor(): string { return $this->author; }
    public function getMessage(): string { return $this->message; }
}

class Document
{
    public string $content = "";

    public function __construct(
        public string $title,
        public string $author,
    ) {}

    public function edit(string $newContent): void
    {
        $this->content = $newContent;
    }

    public function save(string $message): DocumentSnapshot
    {
        // TODO: создать снимок текущего состояния
    }

    public function restore(DocumentSnapshot $snapshot): void
    {
        // TODO: восстановить состояние из снимка
    }
}

class VersionControl
{
    private array $history = [];
    private int $currentIndex = -1;

    public function __construct(private readonly Document $document) {}

    public function commit(string $message): DocumentSnapshot
    {
        // TODO: сохранить снимок, обрезать историю после currentIndex
    }

    public function checkout(string $commitHash): bool
    {
        // TODO: переключиться на версию по хешу
    }

    public function log(): void
    {
        // TODO: вывести историю от новых к старым
    }

    public function diff(string $hash1, string $hash2): array
    {
        // TODO: построчное сравнение двух версий
    }

    public function getCurrent(): ?DocumentSnapshot
    {
        return $this->currentIndex >= 0 ? $this->history[$this->currentIndex] : null;
    }
}

// Ожидаемый результат:
// $doc = new Document("README", "Alice");
// $vc = new VersionControl($doc);
// $doc->edit("# Hello\nInitial content");
// $v1 = $vc->commit("Initial commit");
// $doc->edit("# Hello\nUpdated content\nNew line");
// $v2 = $vc->commit("Add new line");
// $vc->log();
// // [abc12345] 2026-01-01 12:00:00 Alice: Add new line
// // [def67890] 2026-01-01 11:59:00 Alice: Initial commit
// $vc->checkout($v1->commitHash);
// echo $doc->content;   // "# Hello\nInitial content"

Задача 5. Снимки состояния сложного объекта (глубокое копирование)

Реализуйте систему снимков для объекта Canvas (холст), содержащего список фигур. Каждая фигура — это объект со своими свойствами. Снимок должен быть независимой глубокой копией. Поддержите операции draw, move, resize, delete с возможностью отмены через снимки.

from __future__ import annotations
from abc import ABC, abstractmethod
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any


@dataclass
class Shape:
    shape_id: str
    kind: str  # "rect", "circle", "line"
    x: float
    y: float
    width: float = 0.0
    height: float = 0.0
    color: str = "black"
    extra: dict = field(default_factory=dict)

    def move(self, dx: float, dy: float) -> None:
        self.x += dx
        self.y += dy

    def resize(self, factor: float) -> None:
        self.width *= factor
        self.height *= factor


class CanvasMemento:
    def __init__(self, shapes: list[Shape]) -> None:
        # TODO: сохранить независимую глубокую копию списка фигур
        pass

    def get_shapes(self) -> list[Shape]:
        # TODO: вернуть глубокую копию сохранённых фигур
        pass


class Canvas:
    def __init__(self) -> None:
        self._shapes: list[Shape] = []

    def draw(self, shape: Shape) -> None:
        self._shapes.append(deepcopy(shape))

    def move(self, shape_id: str, dx: float, dy: float) -> bool:
        # TODO: переместить фигуру по id
        pass

    def resize(self, shape_id: str, factor: float) -> bool:
        # TODO: изменить размер фигуры по id
        pass

    def delete(self, shape_id: str) -> bool:
        # TODO: удалить фигуру по id
        pass

    def save(self) -> CanvasMemento:
        # TODO: создать снимок текущего состояния холста
        pass

    def restore(self, memento: CanvasMemento) -> None:
        # TODO: восстановить состояние из снимка
        pass

    def render(self) -> None:
        for s in self._shapes:
            print(f"  [{s.shape_id}] {s.kind} at ({s.x},{s.y}) "
                  f"size={s.width}x{s.height} color={s.color}")

    @property
    def shapes(self) -> list[Shape]:
        return self._shapes


class CanvasHistory:
    def __init__(self, canvas: Canvas) -> None:
        self._canvas = canvas
        self._stack: list[CanvasMemento] = []

    def push(self) -> None:
        # TODO: сохранить снимок перед изменением
        pass

    def undo(self) -> bool:
        # TODO: откатить к предыдущему снимку
        pass


# Ожидаемый результат:
# canvas = Canvas()
# history = CanvasHistory(canvas)
# r1 = Shape("r1", "rect", x=0, y=0, width=100, height=50, color="red")
# c1 = Shape("c1", "circle", x=200, y=200, width=30, height=30, color="blue")
# history.push(); canvas.draw(r1)
# history.push(); canvas.draw(c1)
# history.push(); canvas.move("r1", 10, 20)
# canvas.render()
# # [r1] rect at (10,20) size=100x50 color=red
# # [c1] circle at (200,200) size=30x30 color=blue
# history.undo()   # отмена move
# canvas.render()
# # [r1] rect at (0,0) size=100x50 color=red
# # [c1] circle at (200,200) size=30x30 color=blue
# history.undo()   # отмена draw c1
# canvas.render()
# # [r1] rect at (0,0) size=100x50 color=red
<?php
class Shape
{
    public function __construct(
        public readonly string $shapeId,
        public readonly string $kind,
        public float $x,
        public float $y,
        public float $width = 0.0,
        public float $height = 0.0,
        public string $color = "black",
        public array $extra = [],
    ) {}

    public function move(float $dx, float $dy): void
    {
        $this->x += $dx;
        $this->y += $dy;
    }

    public function resize(float $factor): void
    {
        $this->width *= $factor;
        $this->height *= $factor;
    }
}

class CanvasMemento
{
    private array $shapes;

    public function __construct(array $shapes)
    {
        // TODO: сохранить глубокую копию фигур (clone каждой)
    }

    public function getShapes(): array
    {
        // TODO: вернуть глубокую копию сохранённых фигур
    }
}

class Canvas
{
    private array $shapes = [];

    public function draw(Shape $shape): void
    {
        $this->shapes[] = clone $shape;
    }

    public function move(string $shapeId, float $dx, float $dy): bool
    {
        // TODO: переместить фигуру по id
    }

    public function resize(string $shapeId, float $factor): bool
    {
        // TODO: изменить размер фигуры по id
    }

    public function delete(string $shapeId): bool
    {
        // TODO: удалить фигуру по id
    }

    public function save(): CanvasMemento
    {
        // TODO: создать снимок
    }

    public function restore(CanvasMemento $memento): void
    {
        // TODO: восстановить из снимка
    }

    public function render(): void
    {
        foreach ($this->shapes as $s) {
            echo "  [{$s->shapeId}] {$s->kind} at ({$s->x},{$s->y}) "
                . "size={$s->width}x{$s->height} color={$s->color}\n";
        }
    }
}

class CanvasHistory
{
    private array $stack = [];

    public function __construct(private readonly Canvas $canvas) {}

    public function push(): void
    {
        // TODO: сохранить снимок
    }

    public function undo(): bool
    {
        // TODO: откатить к предыдущему снимку
    }
}

// Ожидаемый результат:
// $canvas = new Canvas();
// $history = new CanvasHistory($canvas);
// $r1 = new Shape("r1", "rect", x: 0, y: 0, width: 100, height: 50, color: "red");
// $c1 = new Shape("c1", "circle", x: 200, y: 200, width: 30, height: 30, color: "blue");
// $history->push(); $canvas->draw($r1);
// $history->push(); $canvas->draw($c1);
// $history->push(); $canvas->move("r1", 10, 20);
// $canvas->render();
// // [r1] rect at (10,20) size=100x50 color=red
// // [c1] circle at (200,200) size=30x30 color=blue
// $history->undo();   // отмена move
// $canvas->render();
// // [r1] rect at (0,0) size=100x50 color=red
// // [c1] circle at (200,200) size=30x30 color=blue
// $history->undo();   // отмена draw c1
// $canvas->render();
// // [r1] rect at (0,0) size=100x50 color=red