Весняний РОЗПРОДАЖ

Знімок

Також відомий як: 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) складно гарантувати, щоб лише вихідний об’єкт мав доступ до стану знімка.

Відносини з іншими патернами

  • Команду та Знімок можна використовувати спільно для реалізації скасування операцій. У цьому випадку об’єкти команд відповідатимуть за виконання дії над об’єктом, а знімки зберігатимуть резервну копію стану цього об’єкта, зроблену перед запуском команди.

  • Знімок можна використовувати разом з Ітератором, щоб зберегти поточний стан обходу структури даних та повернутися до нього в майбутньому, якщо буде потрібно.

  • Знімок іноді можна замінити Прототипом, якщо об’єкт, чий стан потрібно зберігати в історії, досить простий, не має посилань на зовнішні ресурси або їх можна легко відновити.

Приклади реалізації патерна

Знімок на C# Знімок на C++ Знімок на Go Знімок на Java Знімок на PHP Знімок на Python Знімок на Ruby Знімок на Rust Знімок на Swift Знімок на TypeScript