State
Накратко: State превръща всяко състояние на обект (например поръчка) в отделен обект, който знае кои действия позволява и към кое състояние води, вместо if по статуса във всеки метод.
Проблем
Поръчката, която проверява статуса навсякъде
Поръчката в магазина минава през нова → платена → изпратена → доставена, а може и да се откаже. Статусът е текст, и всеки метод проверява него:
class Order{ public string Status { get; private set; } = "нова";
public void Pay() { if (Status == "нова") { Status = "платена"; } else { throw new InvalidOperationException($"Поръчката е {Status}: не може да се плати"); } }
public void Ship() { if (Status == "отказана") { throw new InvalidOperationException($"Поръчката е {Status}: не може да се изпрати"); } Status = "изпратена"; }
public void Deliver() { if (Status == "изпратена") { Status = "доставена"; } else { throw new InvalidOperationException($"Поръчката е {Status}: не може да се достави"); } }
public void Cancel() { if (Status == "доставена") { throw new InvalidOperationException($"Поръчката е {Status}: не може да се откаже"); } if (Status != "нова") { Console.WriteLine("Връщаме парите на клиента"); } Status = "отказана"; }}Две поръчки:
var first = new Order();first.Ship();Console.WriteLine("Първа: " + first.Status);
var second = new Order();second.Pay();second.Ship();second.Cancel();Console.WriteLine("Втора: " + second.Status);Първа: изпратенаВръщаме парите на клиентаВтора: отказанаПървата е изпратена, без да е платена: Ship проверява само за „отказана“. Втората е отказана, докато пратката е на път, и клиентът получава парите обратно, а куриерът пак ще я достави.
Никой не е писал грешка нарочно. Четири събития по пет статуса са двайсет комбинации, и всеки метод помни само някои от тях. Нов статус „опакована“ значи да мислиш наново за всичките четири метода, а забравената комбинация пак ще мине тихо.
Идеята
State: всяко състояние знае какво позволява
Въпросът „може ли това събитие сега?“ има отговор, който зависи от състоянието. Затова отговорът отива в самото състояние.
Всяко състояние е обект от свой клас. Той казва кои събития позволява и кое е следващото състояние. Общият базов клас OrderState отказва всяко събитие с ясна грешка, а всяко състояние презаписва само позволените. Поръчката не проверява нищо: предава събитието на текущото състояние и запомня това, което то върне.
Това е шаблонът State (състояние). Ролите са три:
- поръчката пази текущото състояние. В книгите това се казва контекст;
- базовият клас
OrderState: всички събития, и всяко отказва по подразбиране; - конкретните състояния:
NewState,PaidState,ShippedState,DeliveredState,CancelledState.
Виж го
Петте състояния и поръчката
Базовият клас: всяко събитие отказва с текст, в който е името на състоянието.
abstract class OrderState{ public abstract string Name { get; }
public virtual OrderState Pay() => throw Refuse("да се плати"); public virtual OrderState Ship() => throw Refuse("да се изпрати"); public virtual OrderState Deliver() => throw Refuse("да се достави"); public virtual OrderState Cancel() => throw Refuse("да се откаже");
protected InvalidOperationException Refuse(string action) { return new InvalidOperationException($"Поръчката е {Name}: не може {action}"); }}Всяко състояние презаписва само това, което позволява. Отказът от платена поръчка връща парите, а отказът от нова не връща нищо, защото нищо не е платено. Това поведение е в своето състояние, без if:
class NewState : OrderState{ public override string Name => "нова"; public override OrderState Pay() => new PaidState(); public override OrderState Cancel() => new CancelledState();}
class PaidState : OrderState{ public override string Name => "платена"; public override OrderState Ship() => new ShippedState();
public override OrderState Cancel() { Console.WriteLine("Връщаме парите на клиента"); return new CancelledState(); }}
class ShippedState : OrderState{ public override string Name => "изпратена"; public override OrderState Deliver() => new DeliveredState();}
class DeliveredState : OrderState{ public override string Name => "доставена";}
class CancelledState : OrderState{ public override string Name => "отказана";}Поръчката пази текущото състояние и предава всяко събитие на него:
class Order{ private OrderState state = new NewState();
public string Status => state.Name;
public void Pay() => state = state.Pay(); public void Ship() => state = state.Ship(); public void Deliver() => state = state.Deliver(); public void Cancel() => state = state.Cancel();}Двете комбинации от проблема и после целият път:
var order = new Order();try{ order.Ship();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Pay();order.Ship();Console.WriteLine(order.Status);try{ order.Cancel();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Deliver();Console.WriteLine(order.Status);Поръчката е нова: не може да се изпратиизпратенаПоръчката е изпратена: не може да се откажедоставенаvar order = new Order();try{ order.Ship();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Pay();order.Ship();Console.WriteLine(order.Status);try{ order.Cancel();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Deliver();Console.WriteLine(order.Status);Стъпка по стъпка, с думи
- Нова поръчка: полето state сочи NewState („нова“).
- Ship() на нова поръчка. NewState няма стрелка Ship, затова отказва базовият клас, а catch печата грешката.
- Pay(): NewState го позволява и връща PaidState. Поръчката само запомня новото състояние.
- Същото Ship() като преди, но сега отговаря PaidState: позволява го, а следващото е ShippedState.
- Статусът е името на текущото състояние: изпратена.
- Cancel(), докато пратката е на път. ShippedState няма стрелка Cancel: стена, и парите не се връщат.
- Deliver(): от „изпратена“ към „доставена“.
- Никой не написа проверка за двете забранени комбинации. Те нямат стрелка, затова се отказват.
От „нова“ стрелка Ship няма, затова изпращането се отказва. От „изпратена“ няма стрелка Cancel. Никой не е написал проверка за тези две комбинации: те са забранени, защото никое състояние не ги е позволило.
Виж го
Ново събитие, без да пипаш другите състояния
Складът иска пратката да се опакова, преди да тръгне: платена → опакована → изпратена. В базовия клас идва новото събитие, и то отказва като останалите:
public virtual OrderState Pack() => throw Refuse("да се опакова");PaidState вече позволява Pack вместо Ship, а новото PackedState позволява Ship и отказ с връщане на парите:
class PaidState : OrderState{ public override string Name => "платена"; public override OrderState Pack() => new PackedState();
public override OrderState Cancel() { Console.WriteLine("Връщаме парите на клиента"); return new CancelledState(); }}
class PackedState : OrderState{ public override string Name => "опакована"; public override OrderState Ship() => new ShippedState();
public override OrderState Cancel() { Console.WriteLine("Връщаме парите на клиента"); return new CancelledState(); }}Поръчката получава метода Pack, който само предава събитието:
public void Pack() => state = state.Pack();var order = new Order();try{ order.Pack();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Pay();order.Pack();order.Ship();Console.WriteLine(order.Status);Поръчката е нова: не може да се опаковаизпратенаNewState, ShippedState, DeliveredState и CancelledState не са пипнати, а опаковането в тях е забранено, без да си написал ред за това. Платена поръчка вече не може да се изпрати направо, защото PaidState няма Ship. При веригата от if новото събитие трябваше да мине през ума ти във всеки метод.
Виж го
Без клас за всяко състояние: enum и таблица
Тук отказът от платена поръчка връща парите: състоянието прави нещо, и това има място в клас. Ако състоянията не правят нищо, а само казват кой преход е позволен, стига по-малко.
enum е тип с предварително изброени стойности. Статусът е една от тях, а таблицата казва към кои статуси може да се мине от всеки:enum Status { New, Paid, Shipped, Delivered, Cancelled }
class Order{ private static readonly Dictionary<Status, Status[]> Next = new() { [Status.New] = [Status.Paid, Status.Cancelled], [Status.Paid] = [Status.Shipped, Status.Cancelled], [Status.Shipped] = [Status.Delivered], [Status.Delivered] = [], [Status.Cancelled] = [], };
public Status Status { get; private set; } = Status.New;
public void MoveTo(Status next) { if (!Next[Status].Contains(next)) { throw new InvalidOperationException($"{Status} → {next}: не може"); } Status = next; }}var order = new Order();order.MoveTo(Status.Paid);order.MoveTo(Status.Shipped);try{ order.MoveTo(Status.Cancelled);}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}Console.WriteLine(order.Status);Shipped → Cancelled: не можеShippedПроверката е на едно място, а преходите са данни: един ред в таблицата за нов статус. Това е честният избор, когато състоянията нямат поведение. Щом някой преход трябва да прави нещо (да върне парите, да прати имейл, да запише кога е изпратено), таблицата започва да расте if-ове около себе си, и тогава State си заслужава класовете.
По-точно, за любопитните
Състоянията тук нямат полета, затова един обект от всяко би стигнал за всички поръчки. В C# това може да е статично поле с готов обект за всяко състояние (static readonly OrderState Paid = new PaidState();). Това спестява обекти, но има цена: щом състояние получи поле (сумата за връщане, датата на плащане), един общ обект ще го сподели между всички поръчки. Затова new при всеки преход е по-безопасното начало.
Пробвай
Предвиди изхода
Поръчката се плаща и веднага се отказва, а после някой опитва да я изпрати. Какво печата програмата?
var order = new Order();order.Pay();order.Cancel();Console.WriteLine(order.Status);try{ order.Ship();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}Пробвай
Намери грешката
Почти всяко състояние позволява отказ, затова някой решава да го напише веднъж, в базовия клас:
abstract class OrderState{ public abstract string Name { get; }
public virtual OrderState Pay() => throw Refuse("да се плати"); public virtual OrderState Ship() => throw Refuse("да се изпрати"); public virtual OrderState Deliver() => throw Refuse("да се достави"); public virtual OrderState Cancel() => new CancelledState();
protected InvalidOperationException Refuse(string action) { return new InvalidOperationException($"Поръчката е {Name}: не може {action}"); }}var order = new Order();order.Pay();order.Ship();order.Cancel();Console.WriteLine(order.Status);отказанаИзпратената поръчка се отказа без грешка, а пратката е на път. На кой ред е грешката?
Избери реда с грешката.
ShippedState и DeliveredState нямат свой Cancel, затова наследяват този от базовия клас, а той вече позволява. Подразбирането в базовия клас трябва да отказва. Тогава забравено събитие спира с грешка, вместо да мине тихо. Позволеното се пише изрично, в състоянието, което го позволява, както NewState и PaidState го правят. Върни отказа на този ред и изпратената поръчка пак казва „Поръчката е изпратена: не може да се откаже“.
Идеята
Кога не ти трябва
State има цена: по един клас за всяко състояние и базов клас, вместо едно поле. Не си струва, когато:
- състоянията са две и преходът е един. „Платена или не“ е едно булево поле;
- състоянията нямат поведение, а само позволени преходи. Тогава стига enum и таблица от картата преди;
- преходите идват отвън (собственикът на магазина ги настройва в админ панела). Класовете са в кода, а данните могат да се сменят без нова версия на програмата.
Струва си, когато състоянията са повече, всяко прави нещо свое при събитие, а новите идват редовно.
Упражнение
Връщане до 14 дни
Реши задачата в редактора си.
Връщане. Клиентът може да върне доставена поръчка, а магазинът му връща парите. Добави събитие ReturnOrder и състояние „върната“. Позволено е само от „доставена“. Пусни една поръчка до доставена и я върни, а после опитай да върнеш нова поръчка.
Връщаме парите на клиентавърнатаПоръчката е нова: не може да се върнеОще задачи
- История. Поръчката помни статусите, през които е минала, и ги печата така:
нова → платена → изпратена → доставена. Къде записваш поредния статус: в поръчката или във всяко състояние? - Имейл при всяка смяна. След всеки позволен преход клиентът получава ред „Имейл: поръчката е платена“. Направи го така, че отказаното събитие да не праща имейл.
- Опакована с таблицата. Добави „опакована“ във версията с enum и таблица. Преброй колко места смени там и колко във версията с класове.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Хвърляне и свои изключения · Dictionary и HashSet · Шаблони за дизайн
- Продължава в: Магазинът: кой шаблон къде · Миризмите: дълъг метод, голям клас, дублиране, примитиви вместо типове, дълга верига if · Refactoring на малки стъпки
- Задачи по темата: Шаблони за дизайн