State
Накратко: State превръща всяко състояние на обект (например поръчка) в отделен обект, който знае кои действия позволява и към кое състояние води, вместо if по статуса във всеки метод.
Проблем
Поръчката, която проверява статуса навсякъде
Поръчката в магазина минава през нова → платена → изпратена → доставена, а може и да се откаже. Статусът е текст, и всеки метод проверява него:
class Order { status = "нова";
pay() { if (this.status === "нова") { this.status = "платена"; } else { throw new Error(`Поръчката е ${this.status}: не може да се плати`); } }
ship() { if (this.status === "отказана") { throw new Error(`Поръчката е ${this.status}: не може да се изпрати`); } this.status = "изпратена"; }
deliver() { if (this.status === "изпратена") { this.status = "доставена"; } else { throw new Error(`Поръчката е ${this.status}: не може да се достави`); } }
cancel() { if (this.status === "доставена") { throw new Error(`Поръчката е ${this.status}: не може да се откаже`); } if (this.status !== "нова") { console.log("Връщаме парите на клиента"); } this.status = "отказана"; }}Две поръчки:
const first = new Order();first.ship();console.log("Първа: " + first.status);
const second = new Order();second.pay();second.ship();second.cancel();console.log("Втора: " + second.status);Първа: изпратенаВръщаме парите на клиентаВтора: отказанаПървата е изпратена, без да е платена: ship проверява само за „отказана“. Втората е отказана, докато пратката е на път, и клиентът получава парите обратно, а куриерът пак ще я достави.
Никой не е писал грешка нарочно. Четири събития по пет статуса са двайсет комбинации, и всеки метод помни само някои от тях. Нов статус „опакована“ значи да мислиш наново за всичките четири метода, а забравената комбинация пак ще мине тихо.
Идеята
State: всяко състояние знае какво позволява
Въпросът „може ли това събитие сега?“ има отговор, който зависи от състоянието. Затова отговорът отива в самото състояние.
Всяко състояние е обект от свой клас. Той казва кои събития позволява и кое е следващото състояние. Общият базов клас OrderState отказва всяко събитие с ясна грешка, а всяко състояние презаписва само позволените. Поръчката не проверява нищо: предава събитието на текущото състояние и запомня това, което то върне.
Това е шаблонът State (състояние). Ролите са три:
- поръчката пази текущото състояние. В книгите това се казва контекст;
- базовият клас
OrderState: всички събития, и всяко отказва по подразбиране; - конкретните състояния:
NewState,PaidState,ShippedState,DeliveredState,CancelledState.
Виж го
Петте състояния и поръчката
Базовият клас: всяко събитие отказва с текст, в който е името на състоянието.
class OrderState { pay() { throw this.refuse("да се плати"); }
ship() { throw this.refuse("да се изпрати"); }
deliver() { throw this.refuse("да се достави"); }
cancel() { throw this.refuse("да се откаже"); }
refuse(action) { return new Error(`Поръчката е ${this.name}: не може ${action}`); }}Всяко състояние презаписва само това, което позволява. Отказът от платена поръчка връща парите, а отказът от нова не връща нищо, защото нищо не е платено. Това поведение е в своето състояние, без if:
class NewState extends OrderState { name = "нова";
pay() { return new PaidState(); }
cancel() { return new CancelledState(); }}
class PaidState extends OrderState { name = "платена";
ship() { return new ShippedState(); }
cancel() { console.log("Връщаме парите на клиента"); return new CancelledState(); }}
class ShippedState extends OrderState { name = "изпратена";
deliver() { return new DeliveredState(); }}
class DeliveredState extends OrderState { name = "доставена";}
class CancelledState extends OrderState { name = "отказана";}Поръчката пази текущото състояние и предава всяко събитие на него:
class Order { state = new NewState();
get status() { return this.state.name; }
pay() { this.state = this.state.pay(); }
ship() { this.state = this.state.ship(); }
deliver() { this.state = this.state.deliver(); }
cancel() { this.state = this.state.cancel(); }}Двете комбинации от проблема и после целият път:
const order = new Order();try { order.ship();} catch (e) { console.log(e.message);}order.pay();order.ship();console.log(order.status);try { order.cancel();} catch (e) { console.log(e.message);}order.deliver();console.log(order.status);Поръчката е нова: не може да се изпратиизпратенаПоръчката е изпратена: не може да се откажедоставенаconst order = new Order();try { order.ship();} catch (e) { console.log(e.message);}order.pay();order.ship();console.log(order.status);try { order.cancel();} catch (e) { console.log(e.message);}order.deliver();console.log(order.status);Стъпка по стъпка, с думи
- Нова поръчка: полето state сочи NewState („нова“).
- ship() на нова поръчка. NewState няма стрелка ship, затова отказва базовият клас, а catch печата грешката.
- pay(): NewState го позволява и връща PaidState. Поръчката само запомня новото състояние.
- Същото ship() като преди, но сега отговаря PaidState: позволява го, а следващото е ShippedState.
- Статусът е името на текущото състояние: изпратена.
- cancel(), докато пратката е на път. ShippedState няма стрелка cancel: стена, и парите не се връщат.
- deliver(): от „изпратена“ към „доставена“.
- Никой не написа проверка за двете забранени комбинации. Те нямат стрелка, затова се отказват.
От „нова“ стрелка ship няма, затова изпращането се отказва. От „изпратена“ няма стрелка cancel. Никой не е написал проверка за тези две комбинации: те са забранени, защото никое състояние не ги е позволило.
Виж го
Ново събитие, без да пипаш другите състояния
Складът иска пратката да се опакова, преди да тръгне: платена → опакована → изпратена. В базовия клас идва новото събитие, и то отказва като останалите:
pack() { throw this.refuse("да се опакова"); }PaidState вече позволява pack вместо ship, а новото PackedState позволява ship и отказ с връщане на парите:
class PaidState extends OrderState { name = "платена";
pack() { return new PackedState(); }
cancel() { console.log("Връщаме парите на клиента"); return new CancelledState(); }}
class PackedState extends OrderState { name = "опакована";
ship() { return new ShippedState(); }
cancel() { console.log("Връщаме парите на клиента"); return new CancelledState(); }}Поръчката получава метода pack, който само предава събитието:
pack() { this.state = this.state.pack(); }const order = new Order();try { order.pack();} catch (e) { console.log(e.message);}order.pay();order.pack();order.ship();console.log(order.status);Поръчката е нова: не може да се опаковаизпратенаNewState, ShippedState, DeliveredState и CancelledState не са пипнати, а опаковането в тях е забранено, без да си написал ред за това. Платена поръчка вече не може да се изпрати направо, защото PaidState няма ship. При веригата от if новото събитие трябваше да мине през ума ти във всеки метод.
Виж го
Без клас за всяко състояние: enum и таблица
Тук отказът от платена поръчка връща парите: състоянието прави нещо, и това има място в клас. Ако състоянията не правят нищо, а само казват кой преход е позволен, стига по-малко.
В JavaScriptenum няма. Обичайното вместо него е замразен обект с имената (Object.freeze), за да не се появи статус с правописна грешка. Таблицата е Map от статус към Set от позволените следващи:const Status = Object.freeze({ NEW: "NEW", PAID: "PAID", SHIPPED: "SHIPPED", DELIVERED: "DELIVERED", CANCELLED: "CANCELLED",});
const NEXT = new Map([ [Status.NEW, new Set([Status.PAID, Status.CANCELLED])], [Status.PAID, new Set([Status.SHIPPED, Status.CANCELLED])], [Status.SHIPPED, new Set([Status.DELIVERED])], [Status.DELIVERED, new Set()], [Status.CANCELLED, new Set()],]);
class Order { status = Status.NEW;
moveTo(next) { if (!NEXT.get(this.status).has(next)) { throw new Error(`${this.status} → ${next}: не може`); } this.status = next; }}const order = new Order();order.moveTo(Status.PAID);order.moveTo(Status.SHIPPED);try { order.moveTo(Status.CANCELLED);} catch (e) { console.log(e.message);}console.log(order.status);SHIPPED → CANCELLED: не можеSHIPPEDПроверката е на едно място, а преходите са данни: един ред в таблицата за нов статус. Това е честният избор, когато състоянията нямат поведение. Щом някой преход трябва да прави нещо (да върне парите, да прати имейл, да запише кога е изпратено), таблицата започва да расте if-ове около себе си, и тогава State си заслужава класовете.
По-точно, за любопитните
Състоянията тук нямат полета, затова един обект от всяко би стигнал за всички поръчки. Тогава всяко състояние може да е една готова стойност в модула, вместо нов обект при всеки преход. Това спестява обекти, но има цена: щом състояние получи поле (сумата за връщане, датата на плащане), един общ обект ще го сподели между всички поръчки. Затова new при всеки преход е по-безопасното начало.
Пробвай
Предвиди изхода
Поръчката се плаща и веднага се отказва, а после някой опитва да я изпрати. Какво печата програмата?
const order = new Order();order.pay();order.cancel();console.log(order.status);try { order.ship();} catch (e) { console.log(e.message);}Пробвай
Намери грешката
Почти всяко състояние позволява отказ, затова някой решава да го напише веднъж, в базовия клас:
class OrderState { pay() { throw this.refuse("да се плати"); }
ship() { throw this.refuse("да се изпрати"); }
deliver() { throw this.refuse("да се достави"); }
cancel() { return new CancelledState(); }
refuse(action) { return new Error(`Поръчката е ${this.name}: не може ${action}`); }}const order = new Order();order.pay();order.ship();order.cancel();console.log(order.status);отказанаИзпратената поръчка се отказа без грешка, а пратката е на път. На кой ред е грешката?
Избери реда с грешката.
ShippedState и DeliveredState нямат свой cancel, затова наследяват този от базовия клас, а той вече позволява. Подразбирането в базовия клас трябва да отказва. Тогава забравено събитие спира с грешка, вместо да мине тихо. Позволеното се пише изрично, в състоянието, което го позволява, както NewState и PaidState го правят. Върни отказа на този ред и изпратената поръчка пак казва „Поръчката е изпратена: не може да се откаже“.
Идеята
Кога не ти трябва
State има цена: по един клас за всяко състояние и базов клас, вместо едно поле. Не си струва, когато:
- състоянията са две и преходът е един. „Платена или не“ е едно булево поле;
- състоянията нямат поведение, а само позволени преходи. Тогава стига enum и таблица от картата преди;
- преходите идват отвън (собственикът на магазина ги настройва в админ панела). Класовете са в кода, а данните могат да се сменят без нова версия на програмата.
Струва си, когато състоянията са повече, всяко прави нещо свое при събитие, а новите идват редовно.
Упражнение
Връщане до 14 дни
Реши задачата в редактора си.
Връщане. Клиентът може да върне доставена поръчка, а магазинът му връща парите. Добави събитие returnOrder и състояние „върната“. Позволено е само от „доставена“. Пусни една поръчка до доставена и я върни, а после опитай да върнеш нова поръчка.
Връщаме парите на клиентавърнатаПоръчката е нова: не може да се върнеОще задачи
- История. Поръчката помни статусите, през които е минала, и ги печата така:
нова → платена → изпратена → доставена. Къде записваш поредния статус: в поръчката или във всяко състояние? - Имейл при всяка смяна. След всеки позволен преход клиентът получава ред „Имейл: поръчката е платена“. Направи го така, че отказаното събитие да не праща имейл.
- Опакована с таблицата. Добави „опакована“ във версията с enum и таблица. Преброй колко места смени там и колко във версията с класове.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Хвърляне и свои изключения · Map и Set · Шаблони за дизайн
- Продължава в: Магазинът: кой шаблон къде · Миризмите: дълъг метод, голям клас, дублиране, примитиви вместо типове, дълга верига if · Refactoring на малки стъпки
- Задачи по темата: Шаблони за дизайн