Шаблон проектирования «состояние» двадцать лет спустя

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

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

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

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

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

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


        [AttributeUsage(AttributeTargets.Field)]
        public class StateAttribute : Attribute
        {
            public Type StateType { get; }
    
            public StateAttribute(Type stateType)
            {
                StateType = stateType
                    ?? throw new ArgumentNullException(nameof(stateType));
            }
        }
    
        public abstract class State<T>
            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<T> ToState<T>(this Enum stateCode, object entity)
                where T : class
                // да, да reflection медленный. Замените компилируемыми expression tree
                // или IL Emit и будет быстро
                => (State<T>) Activator.CreateInstance(stateCode
                    .GetType()
                    .GetCustomAttribute<StateAttribute>()
                    .StateType, entity);
        }
    

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


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

    public interface IHasState<TStateCode, TEntity>
            where TEntity : class
        {
            TStateCode StateCode { get; }
            State<TEntity> State { get; }
        }
        
        public partial class Cart : IHasState<Cart.CartStateCode, Cart>
        {
            public User User { get; protected set; }
            
            public CartStateCode StateCode { get; protected set; }
    
            public State<Cart> State => StateCode.ToState<Cart>(this);
    
            public decimal Total { get; protected set; }
            
            protected virtual ICollection<Product> Products { get; set; }
                = new List<Product>();
    
            // ORM Only
            protected Cart()
            {            
            }
            
            public Cart(User user)
            {
                User = user ?? throw new ArgumentNullException(nameof(user));
                StateCode = StateCode = CartStateCode.Empty;
            }
            
            public Cart(User user, IEnumerable<Product> products)
                : this(user)
            {
                StateCode = StateCode = CartStateCode.Empty;
                foreach (var product in products)
                {
                    Products.Add(product);
                }
            }
    
            public Cart(User user, IEnumerable<Product> products, 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
            {
                [State(typeof(EmptyCartState))] Empty,
                [State(typeof(ActiveCartState))] Active,
                [State(typeof(PaidCartState))] Paid
            }
    
            public interface IAddableCartState
            {
                ActiveCartState Add(Product product);
                IEnumerable<Product> Products { get; }
            }
    
            public interface INotEmptyCartState
            {
                IEnumerable<Product> Products { get; }
                decimal Total { get; }
            }
            
            public abstract class AddableCartState: State<Cart>, 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 IEnumerable<Product> Products => 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<Cart> 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<Cart>, INotEmptyCartState
            {
                public IEnumerable<Product> Products => Entity.Products;
    
                public decimal Total => Entity.Total;
    
                public PaidCartState(Cart entity) : base(entity)
                {
                }
            }
        }
    

    Состояния объявлены вложенными (nested) классами не случайно. Вложенные классы имеют доступ к защищенным членам класса Cart, а значит нам не придется жертвовать инкапсуляцией сущности для реализации поведения. Чтобы не мусорить в файле класса сущности я разделил объявление на два: Cart.cs и CartStates.cs с помощью ключевого слова partial.

    Сопоставление с образцом


    Раз между разными состояниями нет общего поведения, мы не можем использовать полиморфизм для control flow. Здесь на помощь приходит pattern matching.

            public ActionResult GetViewResult(State<Cart> 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# только когда между типами нет ничего общего. В остальных случаях работа по базовой ссылке удобнее. Аналогичный прием можно применить не только для кодирования поведения сущности, но и для структуры данных.
    Поделиться публикацией
    Комментарии 24
      0
      Таким образом мы гарантируем отсутствие InvalidOperationException в нашем коде на этапе компиляции.

      Как эта идея уживается с default: throw new InvalidOperationException();?


      Кстати, в чем именно вы видите нарушение принципа подстановки Лисков?

        0
        Как эта идея уживается с default: throw new InvalidOperationException();?

        Возможно, получится написать анализтор на Roslyn для того чтобы заставить в pattern matching обработать всех наследников. Тогда от этого InvalidOperationException в этом месте можно будет отказаться. Я думаю, что лучше одно исключение в pattern matching, чем неизвестно сколько в реализациях состояний.

        Кстати, в чем именно вы видите нарушение принципа подстановки Лисков?

        Есть ICartState { Add, Remove, Pay}. Мы не можем заменить ActiveCartState: ICartState на PaidCartState: ICartState, потому что оплаченное состояние выбросит исключение. Основная идея избавиться именно от этих InvalidOperationException.
          +1

          Каким образом исключение мешает заменить ActiveCartState на PaidCartState? Исключение при попытке изменения оплаченной корзины — это часть бизнес-логики. Программа, которая выдает ошибку при попытке выполнения некорретной операции — работает корректно. Принцип подстановки тут не нарушается.


          Основная идея избавиться именно от этих InvalidOperationException.

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

            –2
            У вас это не получилось. Давайте рассмотрим не общий метод GetViewResult, а метод контроллера который отвечает за добавление элемента в корзину. Попробуйте избавиться от невозможных операций там.

            Добавляем шаблон «Форма не реализована» и вставляем в default и исключения не будет.

            Программа, которая выдает ошибку при попытке выполнения некорретной операции — работает корректно.

            Если для вас это корректное поведение и все устраивает, то вам и не нужно структурировать состояния таким образом.
              0

              Предлагаете перейти от исключений к особым возвращаемым значениям? Хорошо, вот вам встречное предложение. Делаем вот такой интерфейс:


                      public interface ICartState
                      {
                          bool TryAdd(Product product);
                          bool TryRemove(Product product);
                          bool TryPay(decimal total);
                          bool TryClear()
                      }

              Все! Исключения больше не нужны, все методы определены для всех состояний.


              PS какие нафиг шаблон «Форма не реализована» и default? Я говорю про обработчик нажатия на кнопку "положить в корзину". Как вы его будете делать?

        +1
        Статья на хорошую тему. Я думаю одна из важных моментов при проектировании предметной области. («Make illegal states unrepresentable»)

        Мне больше нравится с Union Type.

        Есть TabType, который реализован с помощю Uniont Type-а.
        Есть Tab у которого 3 состояния: DefaultTab, ClosedTab, OpendTab и соответствующие интерфейсы.

        А вот переход из одного состояния в другое.

          0
          Мне интересно, почему по ссылке на рефакторин гуру статья называется «заменяйте условные операторы полиморфизмом» предлагают использовать для этого паттерн стратегию, хотя он для этого не предназначен. Паттерн состояние — да, а стратегия — нет. И в приведенном там примере в итоге так и не избавились от условных операторов, так как все равно там придется их применить в месте где будем решать какой из классов стратегии использовать.
            +1
            Стратегия переносит ответственность за выбор алгоритма на клиента, а за исполнение на стратегии. В некоторых случаях она реально позволяет избавиться от условных операторов или карт полностью, а в общем выносит их из целевого класса.
              0
              Она то переносит, но принимать решение все равно придется и для данного вопроса это не важно где, важно что мы не избавились от условных операторов. Как принимать решение о параметризации контекста той или иной стартегией вообще выходит за рамки этого паттерна. Ну ок, единственный способ, который я в данный момент вижу — это сделать хэш таблицу в которой значения — это стратегии, тогда правда можно. Но опять таки ж это не заслуга паттерна стратегия, можно в качестве значения хэш таблицы задать и функцию в конце-концов, так что.
                +1

                Для данного как раз важно. Мы рефакторим конкретный класс с условиями, после рефакторинга в нём условий не остаётся. Будут ли они на уровне клиентов этого класса или их вобще не будет в вопросе рефакторинга этого класса не важно.


                И без карт/хешей можно избавиться от условий в частных случаях, когда тип задаётся статически, явно в коде. Если же тип вычисляется динамически, например по пользовательскому вводу, то тут без условий (или карты/хэш-таблицы,, или паттерн-матчинга, которые под капотом аналоги switch) не обойтись. Поведенческие паттерны лишь позволяют вынести эту логику в клиентские классы.

            –1
            И чего только люди ни придумают, лишь бы конечный автомат нормально не программировать.
              +2

              Простите, а "нормально" — это как?

                –1
                Нормально — выделить ответственность за реализацию собственно конечного автомата в отдельный класс.
                  0
                  А дальше?
                    –1
                    А что нужно дальше? Уже на этом этапе никаким паттерном State даже не пахнет.
                    State от банды четырех — это конечный автомат, размазанный по множеству классов.
                    Нарушение SRP с особым цинизмом, кошмар для сопровождения. К счастью, в реализации он тоже труден и поэтому встречается редко.
                      0

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

                        0
                        Я обычно делаю в лоб: автомат реализую как граф, где состояния — вершины, а ребра — переходы (параметризованные событиями, по которым осуществляется переход).
                        Очень удобно — можно создать любой автомат, не меняя кода его реализации, просто указав состояния и переходы между ними.
                        Полученный автомат можно тестировать отдельно от остального приложения, проверяя реакции на события.
                          –1
                          … и как вы храните переходы?
                            –1
                            Переходы храняться как ребра графа — я же уже написал.
                            Как хранить графы вообще — немного за пределами темы данной статьи, не так ли?
                              +2
                              Почему вы так скрываете свою реализацию?
                +2
                Конечный автомат и состояние дополняют друг друга. Автомат обеспечивает изменение состояния, а состояние — изменение поведения.
                +3

                Кроме принципа подстановки Лисков есть еще принцип открытости/закрытости. Предлагавшийся выше вариант с public interface ICartState куда лучше ему соответствует, ИМХО. В предлагаемом Вами варианте добавление новых состояний при изменении требований будет вызывать боль из-за разрастающихся switch-ей и необходимости изменять множество мест сразу — хотя именно этого хотелось бы избежать.


                Навскидку: от бизнеса может придти требование — "а давайте, пользователь может в уже оплаченной корзине добавлять или убирать бесплатные опции — доставку, например, или подарок...". И вся стройная система состояний ломается, т.к. в PaidCartState внезапно надо добавить методы Add(Product product) и Remove(Product product) с дополнительными валидациями, а точно ли можно этот product в данном состоянии Add или Remove. Так что SRP размывается тоже.

                  0
                  боль из-за разрастающихся switch-ей и необходимости изменять множество мест сразу — хотя именно этого хотелось бы избежать.

                  Вы правы по поводу switch'ей. Поэтому я в конце добавил, что использование полиморфизма предпочтительней для control flow. Pattern matching хорошо подходит там где между состояниями нет ничего общего. По идее такие switch'и должны встречаться только в передаче управления в слой представления. Если состояния абсолютно разные, то вам действительно необходимо отредактировать каждый switch, потому что общего поведения нет и в каждом контексте требуется своя обработка ситуации.

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

                  Вы реализуете в PaidCartState интерфейс IAddableCartState и продолжите работать по интерфейсной ссылке, используя полиморфизм.
                    +1
                    Полноценно решить такие проблемы можно в языках с зависимыми типами, которые позволяют описать зависимость интерфейса от значения состояния. Но получается довольно сложно.
                    Я набросал похожий пример на Idris и Scala. Там реализованы два состояний разлогинин/залогинин и набор операций, которые можно запрограммировать, разный для каждого состояния.
                    Собираюсь в статью оформить, но не знаю, когда руки дойдут.

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

                    Самое читаемое