📜 ⬆️ ⬇️

Design pattern "state" twenty years later

State is a behavioral design pattern. It is used in cases when, during the execution of a program, an object must change its behavior depending on its state. The classic implementation involves creating a base abstract class or interface containing all the methods and one class for each possible state. The pattern is a special case of the recommendation “ replace conditional statements with polymorphism ”.

It would seem that everything is on the book, but there is a nuance. How to implement methods that are not relevant for this state? For example, how to remove goods from an empty basket or pay for an empty basket? Typically, each state class implements only relevant methods, and in other cases throws an InvalidOperationException .

Violation of the principle of substitution Liskov on the face. Yaron Minsky suggested an alternative approach : make illegal states unrepresentable (make illegal states unrepresentable) . This makes it possible to postpone error checking from runtime to compile time. However, the control flow in this case will be organized on the basis of pattern matching, rather than using polymorphism. Fortunately, partial matching of pattern matching appeared in C # 7 .

For more details on the example of F #, the topic make illegal states unrepresentable is disclosed on the Scott Vlashin website .

Consider the implementation of the "state" on the example of the basket. There is no built -in union in C #. Separate data and behavior. The state itself will be encoded using enum, and the behavior of a separate class. For convenience, we will declare an attribute connecting the enum and the corresponding behavior class, the base class of the “state”, and we will add an extension method to switch from enum to the behavior class.
')

Infrastructure


  [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); } 

Subject area


Let's declare the “basket” 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; } } 

We implement one class for each state of the basket: empty, active and paid, but we will not declare a common interface. Let each state implement only relevant behavior. This does not mean that the classes EmptyCartState , ActiveCartState and PaidCartState cannot implement one interface. They can, but such an interface should contain only the methods available in each state. In our case, the Add method is available in EmptyCartState and ActiveCartState , so you can inherit them from the abstract AddableCartStateBase . However, you can add goods only to the unpaid shopping cart, so there will not be a common interface for all states. Thus, we guarantee the absence of InvalidOperationException in our code at the compilation stage.

  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) { } } } 

States declared nested ( nested ) classes are not accidental. Nested classes have access to the protected members of the Cart class, which means we don’t have to sacrifice the encapsulation of the entity to implement the behavior. In order not to litter the entity class file, I divided the declaration into two: Cart.cs and CartStates.cs using the partial keyword.

Pattern matching


Since there is no common behavior between different states, we cannot use polymorphism for control flow. Here pattern matching comes to the rescue.

  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(); } } 

Depending on the state of the basket, we will use different views. For an empty basket, we will display the message “Your basket is empty”. In the active basket will be a list of products, the ability to change the quantity of goods and remove some of them, the button "checkout" and the total amount of the purchase.

The paid basket will look the same as the active one, but without the possibility to edit something. This fact can be noted by highlighting the INotEmptyCartState interface. Thus, we not only got rid of the violation of the Liskov substitution principle, but also applied the principle of interface separation.

Conclusion


In the application code, we can work on the IAddableCartState and INotEmptyCartState interface links to reuse the code responsible for adding products to the cart and displaying the items in the cart. I believe that pattern matching is suitable for control flow in C # only when there is nothing in common between types. In other cases, the work on the base link is more convenient. A similar technique can be applied not only to encode the behavior of an entity, but also to the data structure .

Source: https://habr.com/ru/post/341134/


All Articles