Також відомий як: State

Стан

Суть патерну

Стан — це поведінковий патерн проектування, який дає змогу об'єктам змінювати поведінку в залежності від їхнього стану. Ззовні створюється враження, ніби змінився клас об'єкта.

Патерн Стан

Проблема

Патерн Стан неможливо розглядати у відриві від концепції машини станів, також відомої як стейт-машина або кінцевий автомат.

Кінцевий автомат

Кінцевий автомат.

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

Такий підхід можна застосувати і до окремих об'єктів. Наприклад, об'єкт Документ може приймати три стани: Чернетка, Модерація або Опублікований. У кожному з цих станів метод опублікувати працюватиме по-різному:

  • З чернетки він надішле документ на модерацію.
  • З модерації — в публікацію, але за умови, що це зробив адміністратор.
  • В опублікованому стані метод не буде робити нічого.
Можливі стани документу та переходи між ними

Можливі стани документу та переходи між ними.

Машину станів найчастіше реалізують за допомогою множини умовних операторів, if або switch, які перевіряють поточний стан об'єкта та виконують відповідну поведінку. Ймовірніше за все, ви вже реалізували у своєму житті хоча б одну машину станів, навіть не знаючи про це. Не вірите? Як щодо такого коду, виглядає знайомо?

class Document
    string state;
    // ...
    method publish() {
        switch (state) {
            "draft":
                state = "moderation";
                break;
            "moderation":
                if (currentUser.role == 'admin')
                    state = "published"
                break;
            "published":
                // Do nothing.
                break;
        }
    }
    // ...

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

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

Плутанина та нагромадження умов особливо сильно проявляється в старих проектах. Набір можливих станів буває важко визначити заздалегідь, тому вони увесь час додаються в процесі еволюції програми. Через це рішення, що здавалося простим і ефективним на початку розробки проекту, може згодом стати проекцією величезного макаронного монстра.

Рішення

Патерн Стан пропонує створити окремі класи для кожного стану, в якому може перебувати контекстний об'єкт, а потім винести туди поведінки, що відповідають цим станам.

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

Сторінка делегує виконання своєму активному стану

Сторінка делегує виконання своєму активному стану.

Завдяки тому, що об'єкти станів матимуть спільний інтерфейс, контекст зможе делегувати роботу стану, не прив'язуючись до його класу. Поведінку контексту можна буде змінити в будь-який момент, підключивши до нього інший об'єкт-стан.

Дуже важливим нюансом, який відрізняє цей патерн від Стратегії, є те, що і контекст, і конкретні стани можуть знати один про одного та ініціювати переходи від одного стану до іншого.

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

Ваш смартфон поводиться по-різному в залежності від поточного стану:

  • Якщо телефон розблоковано, натискання кнопок телефону призведе до якихось дій.
  • Якщо телефон заблоковано, натискання кнопок призведе до появи екрану розблокування.
  • Якщо телефон розряджено, натискання кнопок призведе до появи екрану зарядки.

Структура

Структура класів патерну СтанСтруктура класів патерну Стан
  1. Контекст зберігає посилання на об'єкт стану та делегує йому частину роботи, яка залежить від станів. Контекст працює з цим об'єктом через загальний інтерфейс станів. Контекст повинен мати метод для присвоєння йому нового об'єкта-стану.

  2. Стан описує спільний для всіх конкретних станів інтерфейс.

  3. Конкретні стани реалізують поведінки, пов'язані з певним станом контексту. Іноді доводиться створювати цілі ієрархії класів станів, щоб узагальнити дублюючий код.

    Стан може мати зворотнє посилання на об'єкт контексту. Через нього не тільки зручно отримувати з контексту потрібну інформацію, але й здійснювати зміну стану.

  4. І контекст, і об'єкти конкретних станів можуть вирішувати, коли і який стан буде обрано наступним. Щоб перемкнути стан, потрібно подати інший об'єкт-стан до контексту.

Псевдокод

У цьому прикладі патерн Стан змінює функціональність одних і тих самих елементів керування музичним програвачем, залежно від стану, в якому зараз знаходиться програвач.

Структура класів прикладу патерну Стан

Приклад зміни поведінки програвача за допомогою станів.

Об'єкт програвача містить об'єкт-стан, якому й делегує головну роботу. Змінюючи стан, можна впливати на те, як поводяться елементи керування програвача.

// Загальний інтерфейс усіх станів.
abstract class State is
    protected field player: Player

    // Контекст передає себе до конструктора стану, щоб стан міг
    // звертатися до його даних та методів у майбутньому, якщо
    // буде потрібно.
    constructor State(player) is
        this.player = player

    abstract method clickLock()
    abstract method clickPlay()
    abstract method clickNext()
    abstract method clickPrevious()


// Конкретні стани реалізують методи загального стану по-своєму.
class LockedState extends State is

    // При розблокуванні програвача із заблокованими клавішами,
    // він може прийняти один з двох станів.
    method clickLock() is
        if (player.playing)
            player.changeState(new PlayingState(player))
        else
            player.changeState(new ReadyState(player))

    method clickPlay() is
        // Нічого не робити.

    method clickNext() is
        // Нічого не робити.

    method clickPrevious() is
        // Нічого не робити.


// Конкретні стани самі можуть переводити контекст в інші стани.
class ReadyState extends State is
    method clickLock() is
        player.changeState(new LockedState(player))

    method clickPlay() is
        player.startPlayback()
        player.changeState(new PlayingState(player))

    method clickNext() is
        player.nextSong()

    method clickPrevious() is
        player.previousSong()


class PlayingState extends State is
    method clickLock() is
        player.changeState(new LockedState(player))

    method clickPlay() is
        player.stopPlayback()
        player.changeState(new ReadyState(player))

    method clickNext() is
        if (event.doubleclick)
            player.nextSong()
        else
            player.fastForward(5)

    method clickPrevious() is
        if (event.doubleclick)
            player.previous()
        else
            player.rewind(5)


// Програвач виступає в ролі контексту.
class Player is
    field state: State
    field UI, volume, playlist, currentSong

    constructor Player() is
        this.state = new ReadyState(this)

        // Контекст змушує стан реагувати на користувацький ввід
        // замість себе. Реакція може бути різною, залежно від
        // того, який стан зараз активний.
        UI = new UserInterface()
        UI.lockButton.onClick(this.clickLock)
        UI.playButton.onClick(this.clickPlay)
        UI.nextButton.onClick(this.clickNext)
        UI.prevButton.onClick(this.clickPrevious)

    // Інші об'єкти теж повинні мати можливість замінити стан
    // програвача.
    method changeState(state: State) is
        this.state = state

    // Методи UI делегуватимуть роботу активному стану.
    method clickLock() is
        state.clickLock()
    method clickPlay() is
        state.clickPlay()
    method clickNext() is
        state.clickNext()
    method clickPrevious() is
        state.clickPrevious()

    // Сервісні методи контексту, що викликаються станами.
    method startPlayback() is
        // ...
    method stopPlayback() is
        // ...
    method nextSong() is
        // ...
    method previousSong() is
        // ...
    method fastForward(time) is
        // ...
    method rewind(time) is
        // ...

Застосування

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

Патерн пропонує виділити в окремі класи всі поля й методи, пов'язані з визначеним станом. Початковий об'єкт буде постійно посилатися на один з об'єктів-станів, делегуючи йому частину своєї роботи. Для зміни стану до контексту достатньо буде підставляти інший об'єкт-стан.

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

Патерн пропонує перемістити кожну гілку такого умовного оператора до власного класу. Сюди ж можна поселити й усі поля, пов'язані з цим станом.

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

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

Кроки реалізації

  1. Визначтеся з класом, який відіграватиме роль контексту. Це може бути як існуючий клас, який вже має залежність від стану, так і новий клас, якщо код станів «розмазаний» по кількох класах.

  2. Створіть загальний інтерфейс станів. Він повинен описувати методи, спільні для всіх станів, виявлених у контексті. Зверніть увагу, що не всю поведінку контексту потрібно переносити до стану, а тільки ту, яка залежить від станів.

  3. Для кожного фактичного стану створіть клас, який реалізує інтерфейс стану. Перемістіть код, пов'язаний з конкретними станами, до потрібних класів. Зрештою, всі методи інтерфейсу стану повинні бути реалізовані в усіх класах станів.

    При перенесенні поведінки з контексту ви можете зіткнутися з тим, що ця поведінка залежить від приватних полів або методів контексту, до яких немає доступу з об'єкта стану. Є кілька способів, щоб обійти цю проблему.

    Найпростіший — залишити поведінку всередині контексту, викликаючи його з об'єкта стану. З іншого боку, ви можете зробити класи станів вкладеними до класу контексту, і тоді вони отримають доступ до всіх приватних частин контексту. Останній спосіб, щоправда, доступний лише в деяких мовах програмування (наприклад, Java, C#).

  4. Створіть в контексті поле для зберігання об'єктів-станів, а також публічний метод для зміни значення цього поля.

  5. Старі методи контексту, в яких перебував залежний від стану код, замініть на виклики відповідних методів об'єкта-стану.

  6. В залежності від бізнес-логіки, розмістіть код, який перемикає стан контексту, або всередині контексту, або всередині класів конкретних станів.

Переваги та недоліки

  • Позбавляє від безлічі великих умовних операторів машини станів.
  • Концентрує в одному місці код, пов'язаний з певним станом.
  • Спрощує код контексту.
  • Може невиправдано ускладнити код, якщо станів мало, і вони рідко змінюються.

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

  • Міст, Стратегія та Стан (а також трохи і Адаптер) мають схожі структури класів — усі вони побудовані за принципом «композиції», тобто делегування роботи іншим об'єктам. Проте вони відрізняються тим, що вирішують різні проблеми. Пам'ятайте, що патерни — це не тільки рецепт побудови коду певним чином, але й описування проблем, які призвели до такого рішення.

  • Стан можна розглядати як надбудову над Стратегією. Обидва патерни використовують композицію, щоб змінювати поведінку головного об'єкта, делегуючи роботу вкладеним об'єктам-помічникам. Проте в Стратегії ці об'єкти не знають один про одного і жодним чином не пов'язані. У Стані конкретні стани самостійно можуть перемикати контекст.

Реалізація в різних мовах програмування

Стан Java Стан PHP