Паттерн состояние c#. Состояние на Java. Пример паттерна State из реальной жизни

Поведенческий шаблон проектирования. Используется в тех случаях, когда во время выполнения программы объект должен менять своё поведение в зависимости от своего состояния. Классическая реализация предполагает создание базового абстрактного класса или интерфейса, содержащего все методы и по одному классу на каждое возможно состояние. Шаблон представляет собой частный случай рекомендации «заменяйте условные операторы полиморфизмом ».

Казалось бы, все по книжке, но есть нюанс. Как правильно реализовать методы не релевантные для данного состояния? Например, как удалить товар из пустой корзины или оплатить пустую корзину? Обычно каждый state-класс реализует только релевантные методы, а в остальных случаях выбрасывает InvalidOperationException .

Нарушение принципа подстановки Лисков на лицо. Yaron Minsky предложил альтернативный подход : сделайте недопустимые состояния непредставимыми (make illegal states unrepresentable) . Это дает возможность перенести проверку ошибок со времени исполнения на время компиляции. Однако control flow в этом случае будет организован на основе сопоставления с образцом, а не с помощью полиморфизма. К счастью, .

Более подробно на примере F# тема make illegal states unrepresentable раскрыта на сайте Скотта Влашина .

Рассмотрим реализацию «состояния» на примере корзины. В C# нет встроенного типа union . Разделим данные и поведение. Само состояние будем кодировать с помощью enum, а поведение отдельным классом. Для удобства объявим атрибут, связывающий enum и соответствующий класс поведения, базовый класс «состояния» и допишем метод расширения для перехода от enum к классу поведения.

Инфраструктура

public class StateAttribute: Attribute { public Type StateType { get; } public StateAttribute(Type stateType) { StateType = stateType ?? throw new ArgumentNullException(nameof(stateType)); } } public abstract class State where T: class { protected State(T entity) { Entity = entity ?? throw new ArgumentNullException(nameof(entity)); } protected T Entity { get; } } public static class StateCodeExtensions { public static State ToState(this Enum stateCode, object entity) where T: class // да, да reflection медленный. Замените компилируемыми expression tree // или IL Emit и будет быстро => (State) Activator.CreateInstance(stateCode .GetType() .GetCustomAttribute() .StateType, entity); }

Предметная область

Объявим сущность «корзина»:

Public interface IHasState where TEntity: class { TStateCode StateCode { get; } State State { get; } } public partial class Cart: IHasState { public User User { get; protected set; } public CartStateCode StateCode { get; protected set; } public State State => StateCode.ToState(this); public decimal Total { get; protected set; } protected virtual ICollectionProducts { get; set; } = new List(); // ORM Only protected Cart() { } public Cart(User user) { User = user ?? throw new ArgumentNullException(nameof(user)); StateCode = StateCode = CartStateCode.Empty; } public Cart(User user, IEnumerableProducts) : this(user) { StateCode = StateCode = CartStateCode.Empty; foreach (var product in products) { Products.Add(product); } } public Cart(User user, IEnumerableProducts, decimal total) : this(user, products) { if (total <= 0) { throw new ArgumentException(nameof(total)); } Total = total; } }
Реализуем по одному классу на каждое состояние корзины: пустую, активную и оплаченную, но не будем объявлять общий интерфейс. Пусть каждое состояние реализует только релевантное поведение. Это не значит, что классы EmptyCartState , ActiveCartState и PaidCartState не могут реализовать один интерфейс. Они могут, но такой интерфейс должен содержать только методы, доступные в каждом состоянии. В нашем случае метод Add доступен в EmptyCartState и ActiveCartState , поэтому можно унаследовать их от абстрактного AddableCartStateBase . Однако, добавлять товары можно только в неоплаченную корзину, поэтому общего интерфейса для всех состояний не будет. Таким образом мы гарантируем отсутствие InvalidOperationException в нашем коде на этапе компиляции.

Public partial class Cart { public enum CartStateCode: byte { Empty, Active, Paid } public interface IAddableCartState { ActiveCartState Add(Product product); IEnumerableProducts { get; } } public interface INotEmptyCartState { IEnumerableProducts { get; } decimal Total { get; } } public abstract class AddableCartState: State, IAddableCartState { protected AddableCartState(Cart entity): base(entity) { } public ActiveCartState Add(Product product) { Entity.Products.Add(product); Entity.StateCode = CartStateCode.Active; return (ActiveCartState)Entity.State; } public IEnumerableProducts => Entity.Products; } public class EmptyCartState: AddableCartState { public EmptyCartState(Cart entity): base(entity) { } } public class ActiveCartState: AddableCartState, INotEmptyCartState { public ActiveCartState(Cart entity): base(entity) { } public PaidCartState Pay(decimal total) { Entity.Total = total; Entity.StateCode = CartStateCode.Paid; return (PaidCartState)Entity.State; } public State Remove(Product product) { Entity.Products.Remove(product); if(!Entity.Products.Any()) { Entity.StateCode = CartStateCode.Empty; } return Entity.State; } public EmptyCartState Clear() { Entity.Products.Clear(); Entity.StateCode = CartStateCode.Empty; return (EmptyCartState)Entity.State; } public decimal Total => Products.Sum(x => x.Price); } public class PaidCartState: State, INotEmptyCartState { public IEnumerableProducts => Entity.Products; public decimal Total => Entity.Total; public PaidCartState(Cart entity) : base(entity) { } } }
Состояния объявлены вложенными (nested ) классами не случайно. Вложенные классы имеют доступ к защищенным членам класса Cart , а значит нам не придется жертвовать инкапсуляцией сущности для реализации поведения. Чтобы не мусорить в файле класса сущности я разделил объявление на два: Cart.cs и CartStates.cs с помощью ключевого слова partial .

Public ActionResult GetViewResult(State cartState) { switch (cartState) { case Cart.ActiveCartState activeState: return View("Active", activeState); case Cart.EmptyCartState emptyState: return View("Empty", emptyState); case Cart.PaidCartState paidCartState: return View("Paid", paidCartState); default: throw new InvalidOperationException(); } }
В зависимости от состояния корзины будем использовать разные представления. Для пустой корзины выведем сообщение «ваша корзина пуста». В активной корзине будет список товаров, возможность изменить количество товаров и удалить часть из них, кнопка «оформить заказ» и общая сумма покупки.

Оплаченная корзина будет выглядеть также, как и активная, но без возможности что-либо отредактировать. Этот факт можно отметить выделением интерфейса INotEmptyCartState . Таким образом мы не только избавились от нарушения принципа подстановки Лисков, но и применили принцип разделения интерфейса.

Заключение

В прикладном коде мы можем работать по интерфейсным ссылкам IAddableCartState и INotEmptyCartState , чтобы повторно использовать код, отвечающий за добавление товаров в корзину и вывод товаров в корзине. Я считаю, что pattern matching подходит для control flow в C# только когда между типами нет ничего общего. В остальных случаях работа по базовой ссылке удобнее. Аналогичный прием можно применить не только для кодирования поведения сущности, но и для .

Пришло время исповедаться: я немного перестарался с этой главной. Предполагалось, что она посвящена шаблону проектирования Состояние (State) GoF . Но я не могу говорить о его применении в играх, не затрагивая концепцию конечных автоматов (finite state machines) (или "FSM"). Но как только я в нее углубился, я понял, что мне придется вспомнить иерархическую машину состояний (hierarchical state machine) или иерархический автомат и автомат с магазинной памятью (pushdown automata) .

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

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

Все это отголоски старых ранних деньков искусственного интеллекта. В 50-е и 60-е искусственный интеллект в основном фокусировался на обработке языковых конструкций. Многие используемые в современных компиляторах технологии были изобретены для парсинга человеческих языков.

Все мы там были

Допустим мы работаем над небольшим платформером сайд-скроллером. Наша задача заключается в моделировании героини, которая будет аватаром игрока в игровом мире. Это значит, что она должна реагировать на пользовательский ввод. Нажмите B и она прыгнет. Довольно просто:

void Heroine::handleInput(Input input) { if (input == PRESS_B) { yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } }

Заметили баг?

Здесь нет никакого кода, предотвращающего "прыжок в воздухе"; продолжайте нажимать B пока она в воздухе и она будет подлетать снова и снова. Проще всего решить это добавлением булевского флага isJumping_ в Heroine , который будет следить за тем когда героиня прыгнула:

void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_) { isJumping_ = true ; // Прыжок... } } }

Нам нужен еще и код, который будет устанавливать isJumping_ обратно в false , когда героиня снова коснется земли. Для простоты я опускаю этот код.

void Heroine::handleInput(Input input) { if (input == PRESS_B) { // Прыгаем если уже не прыгнули... } else if (input == PRESS_DOWN) { if (!isJumping_) { setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { setGraphics(IMAGE_STAND); } }

А здесь баг заметили?

С помощью этого кода игрок может:

  1. Нажать вниз для приседания.
  2. Нажать B для прыжка из сидячей позиции.
  3. Отпустить вниз, находясь в воздухе.

При этом героиня переключится на графику стояния прямо в воздухе. Придется добавить еще один флаг...

void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Прыжок... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true ; setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { if (isDucking_) { isDucking_ = false ; setGraphics(IMAGE_STAND); } } }

Теперь будет здорово добавить героине способность атаковать подкатом, когда игрок нажимает вниз, находясь в воздухе:

void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Прыжок... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true ; setGraphics(IMAGE_DUCK); } else { isJumping_ = false ; setGraphics(IMAGE_DIVE); } } else if (input == RELEASE_DOWN) { if (isDucking_) { // Стояние... } } }

Снова ищем баги. Нашли?

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

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

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

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

Конечные автоматы — наше спасение

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

Поздравляю, вы только что создали конечный автомат (finite state machine ). Они пришли из области компьютерных наук, называемой теория автоматов (automata theory ), в семейство структур которой также входит знаменитая машина Тьюринга. FSM - простейший член этого семейства.

Суть заключается в следующем:

    У нас есть фиксированный набор состояний , в которых может находиться автомат. В нашем примере это стояние, прыжок, приседание и подкат.

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

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

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

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

В чистой форме это и есть целый банан: состояния, ввод и переходы. Можно изобразить их в виде блок-схемы. К сожалению, компилятор таких каракулей не поймет. Так как же в таком случае реализовать конечный автомат? Банда Четырех предлагает свой вариант, но начнем мы с еще более простого.

Моя любимая аналогия FSM — это старый текстовый квест Zork . У вас есть мир, состоящий из комнат, которые соединены между собой переходами. И вы можете исследовать их, вводя команды типа "идти на север".

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

Перечисления и переключатели

Одна из проблем нашего старого класса Heroine заключается в том, что он допускает некорректную комбинацию булевских ключей: isJumping_ и isDucking_ , они не могут быть истинными одновременно. А если у вас есть несколько булевских флагов, только один из которых может быть true , не лучше ли заменить их все на enum .

В нашем случае с помощью enum можно полностью описать все состояния нашей FSM таким образом:

enum State { STATE_STANDING, STATE_JUMPING, STATE_DUCKING, STATE_DIVING };

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

void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_B) { state_ = STATE_JUMPING; yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } else if (input == PRESS_DOWN) { state_ = STATE_DUCKING; setGraphics(IMAGE_DUCK); } break ; case STATE_JUMPING: if (input == PRESS_DOWN) { state_ = STATE_DIVING; setGraphics(IMAGE_DIVE); } break ; case STATE_DUCKING: if (input == RELEASE_DOWN) { state_ = STATE_STANDING; setGraphics(IMAGE_STAND); } break ; } }

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

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

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

Добавляем в Heroine поле chargeTime_ для хранения времени зарядки. Допустим у нас уже есть метод update() , вызываемый на каждом кадре. Добавим в него следующий код:

void Heroine::update() { if (state_ == STATE_DUCKING) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { superBomb(); } } }

Если вы угадали, что это шаблон Метод обновления (Update Method) , вы выиграли приз!

Каждый раз, когда мы приседаем заново, нам нужно обнулять этот таймер. Для этого нам нужно изменить handleInput() :

void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_DOWN) { state_ = STATE_DUCKING; chargeTime_ = 0 ; setGraphics(IMAGE_DUCK); } // Обработка оставшегося ввода... break ; // Другие состояния... } }

В конце концов, для добавления этой атаки с подзарядкой, нам пришлось изменить два метода и добавить поле chargeTime_ в Heroine , даже если оно используется только в состоянии приседания. Хотелось бы иметь весь этот код и данные в одном месте. Банда Четырех может нам в этом помочь.

Шаблон состояние

Для людей, хорошо разбирающихся в объектно-ориентированной парадигме, каждое условное ветвление — это возможность для использования динамической диспетчеризации (другими словами, вызова виртуального метода в C++). Думаю нам нужно спуститься в эту кроличью нору еще глубже. Иногда if — это все что нам нужно.

Этому есть историческое обоснование. Многие из старых апостолов объектно-ориентированной парадигмы, такие как Банда Четырех со своими Паттернами программирования и Мартин Фулер с его Рефакторингом пришли из Smalltalk . А там ifThen — это всего лишь метод, которым вы обрабатываете условие и который реализуется по разному для объектов true и false .

В нашем примере мы уже добрались до той критической точки, когда нам стоит обратить внимание на что-то объектно-ориентированное. Это подводит нас к шаблону Состояние. Цитирую Банду Четырех:

Позволяет объектам менять свое поведение в соответствии с изменением внутреннего состояния. При этом объект будет вести себя как другой класс.

Не очень то и понятно. В конце концов и switch с этим справляется. Применительно к нашему примеру с героиней шаблон будет выглядеть следующим образом:

Интерфейс состояния

Для начала определим интерфейс для состояния. Каждый бит поведения, зависящий от состояния — т.е. все что мы раньше реализовывали при помощи switch — превращается в виртуальный метод этого интерфейса. В нашем случае это handleInput() и update() .

class HeroineState { public : virtual ~HeroineState() {} virtual void handleInput {} {} };

Классы для каждого из состояний

Для каждого состояния мы определяем класс, реализующий интерфейс. Его методы определяют поведение героини в данном состоянии. Другими словами берем все варианты из switch в предыдущем примере превращаем их в класс состояния. Например:

class DuckingState: public HeroineState { public : DuckingState() : chargeTime_(0 ) {} virtual void handleInput (Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Переход в состояние стояния... heroine.setGraphics(IMAGE_STAND); } } virtual void update (Heroine& heroine) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { heroine.superBomb(); } } private : int chargeTime_; };

Обратите внимание, что мы перенесли chargeTime_ из класса самой героини в класс DuckingState . И это очень хорошо, потому что этот кусок данных имеет значение только в этом состоянии и наша модель данных явно об этом свидетельствует.

Делегирование к состоянию

class Heroine { public : virtual void handleInput (Input input) { state_->handleInput(*this , input); } virtual void update () { state_->update(*this ); } // Другие методы... private : HeroineState* state_; };

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

Выглядит довольно похоже на шаблоны Стратегия (Strategy) GoF и Объект тип (Type Object) . Во всех трёх у нас есть главный объект, делегирующий к подчиненному. Различие в назначении .

  • Цель Стратегии заключается в уменьшении связности (decouple) между главным классом и его поведением.
  • Целью Объект тип (Type Object) является создание некоторого количества объектов, ведущих себя одинаково с помощью разделения между собой общего объекта типа.
  • Целью Состояния является изменение поведения главного объекта через изменение объекта к которому он делегирует.

А где же эти объекты состояния?

Я вам кое-что не сказал. Чтобы изменить состояние, нам нужно присвоить state_ новое значение, указывающее на новое состояние, но откуда этот объект возьмется? В нашем примере с enum думать не о чем: значения enum — это просто примитивы наподобие чисел. Но теперь наши состояния представлены классами и это значит, что нам нужны указатели на реальные экземпляры. Существует два самых распространенных ответа:

Статические состояния

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

Если у вашего состояния нет полей и только один виртуальный метод, можно еще сильнее упростить шаблон. Заменим каждый класс состояния функцией состояния — обычной функцией верхнего уровня. И соответственно поле state_ в нашем главном классе превратится в простой указатель на функцию.

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

Куда вы поместите статический экземпляр — это уже ваше дело. Найдите такое место, где это будет уместно. Давайте поместим наш экземпляр в базовый класс. Без всякой причины.

class HeroineState { public : static StandingState standing; static DuckingState ducking; static JumpingState jumping; static DivingState diving; // Остальной код... };

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

if (input == PRESS_B) { heroine.state_ = &HeroineState::jumping; heroine.setGraphics(IMAGE_JUMP); }

Экземпляры состояний

Иногда предыдущий вариант не взлетает. Статическое состояние не подойдет для состояния присядки. У него есть поле chargeTime_ и оно специфично для героини, которая будет приседать. Это еще худо бедно сработает в нашем случае, потому что у нас всего одна героиня, но если мы захотим добавить кооператив для двух игроков, у нас будут большие проблемы.

В таком случае, нам следует создавать объект состояния, когда мы переходим в него. Это позволит каждому FSM иметь собственный экземпляр состояния. Конечно, если мы выделяем память под новое состояние, это значит нам следует освободить занимаемую память текущего. Мы должны быть осторожны, так как код, который вызывает изменения находится в методее текущего состоянии. Мы не хотим, чтобы удалить this из-под себя.

Вместо этого, мы позволим handleInput() в HeroineState опционально возвращать новое состояние. Когда это произойдет, Heroine удалит старое состояние и поменяет его на новое, например, так:

void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this , input); if (state != NULL ) { delete state_; state_ = state; } }

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

HeroineState* StandingState::handleInput(Heroine& heroine, Input input) { if (input == PRESS_DOWN) { // Other code... return new DuckingState(); } // Stay in this state. return NULL ; }

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

Конечно, когда вы выделяете память под состояние динамически, вам стоит подумать о возможной фрагментации памяти. Помочь может шаблон Пул объектов (Object Pool) .

Действия для входа и выхода

Шаблон Состояние предназначен для инкапсуляции всего поведения и связанных с ним данных внутри одного класса. У нас довольно неплохо получается, но остались некоторые невыясненные детали.

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

HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { heroine.setGraphics(IMAGE_STAND); return new StandingState(); } // Other code... }

То, что мы действительно хотим, каждое состояние контролировало свою собственную графику. Мы можем добиться этого, добавив в состояние входное действие (entry action ):

class StandingState: public HeroineState { public : virtual void enter (Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); } // Other code... };

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

void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this , input); if (state != NULL ) { delete state_; state_ = state; // Вызов входного действия нового состояния. state_->enter(*this ); } }

Это позволит упростить код состояния DuckingState:

HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { return new StandingState(); } // Other code... }

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

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

Можно по аналогии сделать и выходное действие (exit action ). Это будет просто метод, который мы будем вызывать для состояния, перед тем, как покидаем его и переключаемся на новое состояние.

И чего же мы добились?

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

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

Конечный автомат не обладает полнотой по Тьюрингу (Turing complete). Теория автоматов описывает полноту через серию абстрактных моделей, каждая из которых сложнее предыдущей. Машина Тьюринга — одна из самых выразительных.

"Полнота по Тьюригну" означает систему (обычно язык программирования), обладающую достаточной выразительностью для реализации машины Тьюринга. В свою очередь это означает что все полные по Тьюрингу языки примерно одинаково выразительны. FSM недостаточно выразительны чтобы войти в этот клуб.

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

Машина конкурентных состояний

Мы решили добавить нашей героине возможность носить оружие. Хотя она теперь вооружена, она по прежнему может делать все, что делала раньше: бегать, прыгать, приседать и т.д. Но теперь, делая все это, она еще может и стрелять из оружия.

Если мы захотим вместить такое поведение в рамки FSM , нам придется удвоить количество состояний. Для каждого из состояний нам придется завести еще одно такое же, но уже для героини с оружием: стояние, стояние с оружием, прыжок, прыжок с оружием.... Ну вы поняли.

Если добавить еще несколько видов оружия, количество состояний увеличится комбинаторно. И это не просто куча состояний, а еще и куча повторов: вооруженное и безоружное состояния практически идентичны за исключением части кода, отвечающей за стрельбу.

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

Если мы хотим объединить n состояний действия и m состояний того, что держим в руках в один конечный автомат — нам нужно n × m состояний. Если у нас будет два автомата — нам понадобится n + m состояний.

Наш первый конечный автомат с действиями мы оставим без изменений. А в дополнение к нему создадим еще один автомат для описания того, что героиня держит. Теперь у Heroine будет две ссылки на "состояние", по одной для каждого автомата.

class Heroine { // Остальной код... private : HeroineState* state_; HeroineState* equipment_; };

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

Когда героиня делегирует ввод состояниям, она передает перевод обеим конечным автоматам:

void Heroine::handleInput(Input input) { state_->handleInput(*this , input); equipment_->handleInput(*this , input); }

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

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

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

Иерархическая машина состояний

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

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

Если бы это был просто объектно-ориентированный код, а не конечный автомат, можно было бы использовать такой прием разделения кода между состояниями, как наследование. Можно определить класс для состояния "на земле", который будет обрабатывать подпрыгивание и приседание. Стояние, ходьба, бег и скатывание для него наследуется и добавляет свое дополнительное поведение.

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

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

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

class OnGroundState: public HeroineState { public : virtual void handleInput (Heroine& heroine, Input input) { if (input == PRESS_B) { // Подпрыгнуть... } else if (input == PRESS_DOWN) { // Присесть... } } };

А теперь каждый подкласс будет его наследовать:

class DuckingState: public OnGroundState { public : virtual void handleInput (Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Встаем... } else { // Ввод не обработан. Поэтому передаем его выше по иерархии. OnGroundState::handleInput(heroine, input); } } };

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

Текущее состояние будет находится вверху стека, под ним его суперсостояние, дальше суперсостояние для этого суперсостояния и т.д. И когда вам нужно будет реализовать специфичное для состояния поведение, вы начнете с верха стека спускаться по нему вниз, пока состояние его не обработает. (А если не обработает — значит вы его просто игнорируете).

Автомат с магазинной памятью

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

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

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

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

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

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

Что нам на самом деле нужно — так это возможность хранить состояние, в котором мы находились до стрельбы и после стрельбы вспоминать его снова. Здесь нам снова может помочь теория автоматов. Соответствующая структура данных называется Автомат с магазинной памятью (Pushdown Automaton) .

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

    Вы можете поместить (push ) новое состояние в стек. Текущее состояние всегда будет находиться вверху стека, так что это и есть операция перехода в новое состояние. Но при этом старое состояние остается прямо под текущим в стеке, а не исчезает бесследно.

    Вы можете извлечь (pop ) верхнее состояние из стека. Состояние пропадает и текущим становится то что находилось под ним.

Это все что нам нужно для стрельбы. Мы создаем единственное состояние стрельбы. Когда мы нажимаем кнопку стрельбы, находясь в другом состоянии, мы помещаем (push ) состояние стрельбы в стек. Когда анимация стрельбы заканчивается, мы извлекаем (pop ) состояние и автомат с магазинной памятью автоматически возвращает нас в предыдущее состояние.

Насколько они реально полезны?

Даже с этим расширением конечных автоматов, их возможности все равно довольно ограничены. В AI сегодня преобладает тренд использования вещей типа деревьев поведения (behavior trees) и систем планирования (planning systems). И если вам интересна именно область AI , вся эта глава должна просто раздразнить ваш аппетит. Чтобы его удовлетворить, вам придется обратиться к другим книгам.

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

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

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

using System; namespace State { ///

/// Заблокированное состояние счета. /// public class Blocked: IState { /// /// Пополнить счет. /// /// Пополняемый счет. /// Сумма пополнения. public void Deposit(Card card, decimal money) { // Проверяем входные аргументы на корректность. if (card == null) { throw new ArgumentNullException(nameof(card)); } if (money <= 0) { throw new ArgumentException("Вносимая сумма должна быть больше нуля.", nameof(money)); } // Вычисляем сумму сверхлимитной задолженности. var overdraft = card.CreditLimit - card.Credit; // Вычисляем насколько сумма пополнения перекрывает задолженность. var difference = money - overdraft; if (difference < 0) { // Если сумма пополнения не перекрывает задолженность, // то просто уменьшаем сумму задолженности. card.Credit += money; // Вычисляем процент оставшейся суммы на счете. var limit = card.Credit / card.CreditLimit * 100; if (limit < 10) { // Если после пополнения на счете все еще меньше десяти процентов от лимита, // то просто сообщаем об этом пользователю. Console.WriteLine($"Ваш счет пополнен на сумму {money}. " + $"Сумма на вашем счете все еще составляет менее 10%. Ваш счет остался заблокирован. Пополните счет на большую сумму. {card.ToString()}"); } else if (limit >= 10 && limit < 100) { // Если задолженность перекрыта не полностью, то переводим в состояние расходования кредитных средств. card.State = new UsingCreditFunds(); Console.WriteLine($"Ваш счет пополнен на сумму {money}. Задолженность частично погашена. " + $"Погасите задолженность в размере {Math.Abs(difference)} рублей. {card.ToString()}"); } else { // Иначе задолженность полностью погашена, переводим в состояние расходования собственных средств. card.State = new UsingOwnFunds(); Console.WriteLine($"Ваш счет пополнен на {money} рублей. Задолженность полностью погашена. {card.ToString()}"); } } else { // Иначе закрываем задолженность, а оставшиеся средства переводим в собственные средства. card.Credit = card.CreditLimit; card.Debit = difference; // Переводим карту в состояние использования собственных средств. card.State = new UsingOwnFunds(); Console.WriteLine($"Ваш счет пополнен на {money} рублей. " + $"Кредитная задолженность погашена. {card.ToString()}"); } } /// /// Расходование со счета. /// /// Счет списания. /// Стоимость покупки. /// Успешность выполнения операции. public bool Spend(Card card, decimal price) { // Отказываем в операции. Console.WriteLine($"Ваш счет заблокирован. Пополните счет. {card.ToString()}"); return false; } } }

Для того, чтобы правильно использовать паттерны Состояние и Стратегия в ядре Java приложений, важно для Java-программистов четко понимать разницу между ними. Хотя оба шаблона, Состояние и Стратегия, имеют схожую структуру, и оба основаны на принципе открытости/закрытости, представляющие ”O” в SOLID принципах , они совершенно разные по намерениям . Паттерн Стратегия в Java используется для инкапсуляции связанных наборов алгоритмов для обеспечения гибкости исполнения для клиента. Клиент может выбрать любой алгоритм во время выполнения без изменения контекста класса, который использует объект Strategy . Некоторые популярные примеры паттерна Стратегия – это написание кода, который использует алгоритмы, например, шифрование, сжатие или сортировки. С другой стороны, паттерн Состояние позволяет объекту вести себя по-разному в разном состоянии. Поскольку в реальном мире объект часто имеет состояния, и он ведет себя по-разному в разных состояниях, например, торговый автомат продает товары только если он в состоянии hasCoin , он не продает до тех пор пока вы не положите в него монету. Сейчас вы можете ясно видеть разницу между паттернами Стратегия и Состояние, это различные намерения. Паттерн Состояние помогает объекту управлять состоянием, тогда как паттерн Стратегия позволяет выбрать клиенту другое поведение. Еще одно отличие, которое не так легко увидеть, это кто управляет изменением в поведении. В случае паттерна Стратегия, это клиент, который предоставляет различные стратегии к контексту, в паттерне Состояние переходом управляет контекст или состояние объекта самостоятельно. Кроме того, если вы управляете изменениями состояний в объекте Состояние самостоятельно, должна быть ссылка на контекст, например, в торговом автомате должна быть возможность вызвать метод setState() для изменения текущего состояния контекста. С другой стороны, объект Стратегия никогда не содержит ссылку на контекст, сам клиент передает Стратегию своего выбора в контекст. Разница между паттернами Состояние и Стратегия один из популярных вопросов о паттернах Java на интервью , в этой статье о паттернах Java мы подробней рассмотрим это. Мы будем исследовать некоторые сходства и различия между паттернами Стратегия и Состояние в Java, которые помогут вам улучшить ваше понимание этих паттернов.

Сходства между паттернами Состояние и Стратегия

Если вы посмотрите на UML-диаграмму паттернов Состояние и Стратегия, можно заметить, что оба выглядят похоже друг на друга. Объект, который использует Состояние для изменения своего поведения известен как Context -объект, аналогично объект, который использует Стратегию чтобы изменить свое поведение упоминается как Context -объект. Запомните, что клиент взаимодействует с Context -объектом. В случае паттерна Состояние контекст делегирует методы вызова объекту Состояние, который удерживается в виде текущего объекта, а в случае паттерна Стратегия контекст использует объект Стратегии в качестве параметра или предоставляется во время создания контекста объекта. UML диаграмма паттерна Состояние в Java
Эта UML диаграмма для паттерна Состояние, изображает классическую проблему создания объектно-ориентированного дизайна торгового аппарата в Java. Вы можете видеть, что состояние торгового аппарата представлено с использованием интерфейса, который далее имеет реализацию для представления конкретного состояния. Каждое состояние также имеет ссылки на контекст объекта, чтобы сделать переход в другое состояние в результате действий вызванных в контексте.
Эта UML диаграмма для паттерна Стратегия содержит функциональные реализации сортировок. Поскольку есть много алгоритмов сортировки, этот шаблон проектирования позволяет клиенту выбрать алгоритм при сортировке объектов. На самом деле Java Collection framework использует этот паттерн реализуя метод Collections.sort() , который используется для сортировки объектов в Java. Единственная разница в том, что вместо разрешения клиенту выбирать алгоритм сортировки он позволяет ему указать стратегию сравнения передавая экземпляр интерфейса Comparator или Comparable в Java . Давайте посмотрим на несколько сходств между этими двумя основными шаблонами проектирования в Java:
  1. Оба паттерна, Состояние и Стратегия, делают несложным добавление нового состояния и стратегии не затрагивая контекст объекта, который использует их.

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

  3. Также как контекст объекта начинается с состояния инициализации объекта в паттерне Состояние, контекст объекта также имеет стратегию по умолчанию в случае паттерна Стратегия в Java.

  4. Паттерн Состояние представляет различные поведения в форме различных состояний объекта, в то время как паттерн Стратегия представляет различное поведение в виде различных стратегий объекта.

  5. Оба паттерна, Стратегия и Состояние, зависят от подклассов реализации поведения. Каждая конкретная стратегия расширяет Абстрактную Стратегию, каждое состояние есть подкласс интерфейса или абстрактного класса , который используется для преставления Состояния.

Различия между паттернами Стратегия и Состояние в Java

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

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

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

  4. Реализация Стратегии может быть передана как параметр объекту, который будет использовать ее, например, Collection.sort() принимает Comparator , который является стратегией. С другой стороны, состояние является частью самого контекста объекта, и в течение долгого времени контекст объекта переходит из одного состояния в другое.

  5. Хотя и Стратегия и Состояние следуют принципу открытости/закрытости, Стратегия также следует Принципу Единственной Обязанности так как каждая Стратегия содержит индивидуальный алгоритм, различные стратегии независимы друг от друга. Изменение одной стратегии не требует изменения другой стратегии.

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

  7. Порядок перехода состояния хорошо определен в паттерне Состояние, такого требования нет к паттерну Стратегия. Клиент волен в выборе любой реализации Стратегии на его выбор.

  8. Некоторые из общих примеров паттерна Стратегия – это инкапсуляция алгоритмов, например, алгоритмы сортировки, шифрования или алгоритм сжатия. Если вы видите, что ваш код должен использовать различные виды связанных алгоритмов, следует подумать об использовании паттерна Стратегия. С другой стороны, распознать возможность использования паттерна Состояние довольно легко, если вам нужно управлять состоянием и переходами между состояниями без большого количества вложенных условных операторов паттерн Состояние – нужный паттерн для использования.

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

Это все про разницу между паттернами Состояние и Стратегия в Java . Как я сказал, оба выглядят похоже в своих классах и UML диаграммах, оба обеспечивают открыто/закрытый принцип и инкапсулируют поведение. Используйте паттерн Стратегия для инкапсулирования алгоритма или стратегии, который предоставляется контексту во время выполнения, возможно как параметр или составной объект и используйте паттерн Состояние для управления переходами между состояниями в Java. Оригинал

15.02.2016
21:30

Паттерн Состояние (State) предназначен для проектирования классов, которые имеют несколько независимых логических состояний. Давайте сразу перейдем к рассмотрению примера.

Допустим, мы разрабатываем класс управления веб-камерой. Камера может находиться в трех Состояниях:

  1. Не инициализирована. Назовем NotConnectedState ;
  2. Инициализирована и готова к работе, но кадры еще не захватываются. Пусть это будет ReadyState ;
  3. Активный режим захвата кадров. Обозначим ActiveState .

Поскольку мы работаем с паттером Состояние, то лучше всего начать с изображения Диаграммы состояний:

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

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

#include #define DECLARE_GET_INSTANCE(ClassName) \ static ClassName* getInstance() {\ static ClassName instance;\ return &instance;\ } class WebCamera { public: typedef std::string Frame; public: // ************************************************** // Exceptions // ************************************************** class NotSupported: public std::exception { }; public: // ************************************************** // States // ************************************************** class NotConnectedState; class ReadyState; class ActiveState; class State { public: virtual ~State() { } virtual void connect(WebCamera*) { throw NotSupported(); } virtual void disconnect(WebCamera* cam) { std::cout << "Деинициализируем камеру..." << std::endl; // ... cam->changeState(NotConnectedState::getInstance()); } virtual void start(WebCamera*) { throw NotSupported(); } virtual void stop(WebCamera*) { throw NotSupported(); } virtual Frame getFrame(WebCamera*) { throw NotSupported(); } protected: State() { } }; // ************************************************** class NotConnectedState: public State { public: DECLARE_GET_INSTANCE(NotConnectedState) void connect(WebCamera* cam) { std::cout << "Инициализируем камеру..." << std::endl; // ... cam->changeState(ReadyState::getInstance()); } void disconnect(WebCamera*) { throw NotSupported(); } private: NotConnectedState() { } }; // ************************************************** class ReadyState: public State { public: DECLARE_GET_INSTANCE(ReadyState) void start(WebCamera* cam) { std::cout << "Запускаем видео-поток..." << std::endl; // ... cam->changeState(ActiveState::getInstance()); } private: ReadyState() { } }; // ************************************************** class ActiveState: public State { public: DECLARE_GET_INSTANCE(ActiveState) void stop(WebCamera* cam) { std::cout << "Останавливаем видео-поток..." << std::endl; // ... cam-> << "Получаем текущий кадр..." << std::endl; // ... return "Current frame"; } private: ActiveState() { } }; public: explicit WebCamera(int camID) : m_camID(camID), m_state(NotConnectedState::getInstance()) { } ~WebCamera() { try { disconnect(); } catch(const NotSupported& e) { // Обрабатываем исключение } catch(...) { // Обрабатываем исключение } } void connect() { m_state->connect(this); } void disconnect() { m_state->disconnect(this); } void start() { m_state->start(this); } void stop() { m_state->stop(this); } Frame getFrame() { return m_state->getFrame(this); } private: void changeState(State* newState) { m_state = newState; } private: int m_camID; State* m_state; };

Обращаю внимание на макрос DECLARE_GET_INSTANCE . Конечно, использование макросов в C++ не поощряется. Однако это относится к случаям, когда макрос выступает в роли аналога шаблонной функции. В этом случае всегда отдавайте предпочтение последним.

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

Классы-Состояния мы объявляем в главном классе - WebCamera . Для краткости я использовал inline -определения функций-членов всех классов. Однако в реальных приложениях лучше следовать рекомендациям о разделении объявления и реализации по h и cpp файлам.

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

Основу иерархии классов состояний образует абстрактный класс WebCamera::State:

Class State { public: virtual ~State() { } virtual void connect(WebCamera*) { throw NotSupported(); } virtual void disconnect(WebCamera* cam) { std::cout << "Деинициализируем камеру..." << std::endl; // ... cam->changeState(NotConnectedState::getInstance()); } virtual void start(WebCamera*) { throw NotSupported(); } virtual void stop(WebCamera*) { throw NotSupported(); } virtual Frame getFrame(WebCamera*) { throw NotSupported(); } protected: State() { } };

Все его функции-члены соответствуют функциям самого класса WebCamera . Происходит непосредственное делегирование:

Class WebCamera { // ... void connect() { m_state->connect(this); } void disconnect() { m_state->disconnect(this); } void start() { m_state->start(this); } void stop() { m_state->stop(this); } Frame getFrame() { return m_state->getFrame(this); } // ... State* m_state; }

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

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

Большинство функций-членов WebCamera::State выбрасывают наше собственное WebCamera::NotSupported . Это вполне уместное поведение по умолчанию. Например, если кто-то попытается инициализировать камеру, когда она уже инициализирована, то вполне закономерно получит исключение.

При этом для WebCamera::State::disconnect() мы предусматриваем реализацию по умолчанию. Такое поведение подойдет для двух состояний из трех. В результате мы предотвращаем дублирование кода.

Для смены состояния предназначена закрытая функция-член WebCamera::changeState() :

Void changeState(State* newState) { m_state = newState; }

Теперь к реализации конкретных Состояний. Для WebCamera::NotConnectedState достаточно переопределить операции connect() и disconnect() :

Class NotConnectedState: public State { public: DECLARE_GET_INSTANCE(NotConnectedState) void connect(WebCamera* cam) { std::cout << "Инициализируем камеру..." << std::endl; // ... cam->changeState(ReadyState::getInstance()); } void disconnect(WebCamera*) { throw NotSupported(); } private: NotConnectedState() { } };

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

Другим важным элементом представленной реализации является то, что в новое Состояние мы переходим лишь в случае успеха. Например, если во время инициализации камеры произойдет сбой, то в Состояние ReadyState переходить рано. Главная мысль - полное соответствие фактического состояния камеры (в нашем случае) и объекта-Состояния.

Итак, камера готова к работе. Заведем соответствующий класс Состояния WebCamera::ReadyState:

Class ReadyState: public State { public: DECLARE_GET_INSTANCE(ReadyState) void start(WebCamera* cam) { std::cout << "Запускаем видео-поток..." << std::endl; // ... cam->changeState(ActiveState::getInstance()); } private: ReadyState() { } };

Из Состояния готовности мы можем попасть в активное Состояние захвата кадров. Для этого предусмотрена операция start() , которую мы и реализовали.

Наконец мы дошли до последнего логического Состояния работы камеры WebCamera::ActiveState:

Class ActiveState: public State { public: DECLARE_GET_INSTANCE(ActiveState) void stop(WebCamera* cam) { std::cout << "Останавливаем видео-поток..." << std::endl; // ... cam->changeState(ReadyState::getInstance()); } Frame getFrame(WebCamera*) { std::cout << "Получаем текущий кадр..." << std::endl; // ... return "Current frame"; } private: ActiveState() { } };

В этом Состоянии можно прервать захват кадров с помощью stop() . В результате мы попадем обратно в Состояние WebCamera::ReadyState . Кроме того, мы можем получать кадры, которые накапливаются в буфере камеры. Для простоты под "кадром" мы понимаем обычную строку. В реальности это будет некоторый байтовый массив.

А теперь мы можем записать типичный пример работы с нашим классом WebCamera:

Int main() { WebCamera cam(0); try { // cam в Состоянии NotConnectedState cam.connect(); // cam в Состоянии ReadyState cam.start(); // cam в Состоянии ActiveState std::cout << cam.getFrame() << std::endl; cam.stop(); // Можно было сразу вызвать disconnect() // cam в Состоянии ReadyState cam.disconnect(); // cam в Состоянии NotConnectedState } catch(const WebCamera::NotSupported& e) { // Обрабатываем исключение } catch(...) { // Обрабатываем исключение } return 0; }

Вот что в результате будет выведено на консоль:

Инициализируем камеру... Запускаем видео-поток... Получаем текущий кадр... Current frame Останавливаем видео-поток... Деинициализируем камеру...

А теперь попробуем спровоцировать ошибку. Вызовем connect() два раза подряд:

Int main() { WebCamera cam(0); try { // cam в Состоянии NotConnectedState cam.connect(); // cam в Состоянии ReadyState // Но для этого Состояния операция connect() не предусмотрена! cam.connect(); // Выбрасывает исключение NotSupported } catch(const WebCamera::NotSupported& e) { std::cout << "Произошло исключение!!!" << std::endl; // ... } catch(...) { // Обрабатываем исключение } return 0; }

Вот что из этого получится:

Инициализируем камеру... Произошло исключение!!! Деинициализируем камеру...

Обратите внимание, что камера все же была деинициализирована. Вызов disconnect() произошел в деструкторе WebCamera . Т.е. внутреннее Состояние объекта осталось абсолютно корректным.

Выводы

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