Шаблон проектирования «состояние» двадцать лет спустя
Состояние — поведенческий шаблон проектирования. Используется в тех случаях, когда во время выполнения программы объект должен менять своё поведение в зависимости от своего состояния. Классическая реализация предполагает создание базового абстрактного класса или интерфейса, содержащего все методы и по одному классу на каждое возможно состояние. Шаблон представляет собой частный случай рекомендации «заменяйте условные операторы полиморфизмом».
Казалось бы, все по книжке, но есть нюанс. Как правильно реализовать методы не релевантные для данного состояния? Например, как удалить товар из пустой корзины или оплатить пустую корзину? Обычно каждый 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
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 ICollection Products { 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, IEnumerable products)
: this(user)
{
StateCode = StateCode = CartStateCode.Empty;
foreach (var product in products)
{
Products.Add(product);
}
}
public Cart(User user, IEnumerable 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 Products { get; }
}
public interface INotEmptyCartState
{
IEnumerable Products { 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 IEnumerable 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 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 IEnumerable 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 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# только когда между типами нет ничего общего. В остальных случаях работа по базовой ссылке удобнее. Аналогичный прием можно применить не только для кодирования поведения сущности, но и для структуры данных.