Към съдържанието
wannacode

State

Накратко: State превръща всяко състояние на обект (например поръчка) в отделен обект, който знае кои действия позволява и към кое състояние води, вместо if по статуса във всеки метод.

Проблем

Поръчката, която проверява статуса навсякъде

Поръчката в магазина минава през нова → платена → изпратена → доставена, а може и да се откаже. Статусът е текст, и всеки метод проверява него:

class Order {
private String status = "нова";
public void pay() {
if (status.equals("нова")) {
status = "платена";
} else {
throw new IllegalStateException("Поръчката е " + status + ": не може да се плати");
}
}
public void ship() {
if (status.equals("отказана")) {
throw new IllegalStateException("Поръчката е " + status + ": не може да се изпрати");
}
status = "изпратена";
}
public void deliver() {
if (status.equals("изпратена")) {
status = "доставена";
} else {
throw new IllegalStateException("Поръчката е " + status + ": не може да се достави");
}
}
public void cancel() {
if (status.equals("доставена")) {
throw new IllegalStateException("Поръчката е " + status + ": не може да се откаже");
}
if (!status.equals("нова")) {
System.out.println("Връщаме парите на клиента");
}
status = "отказана";
}
public String status() {
return status;
}
}

Две поръчки:

Order first = new Order();
first.ship();
System.out.println("Първа: " + first.status());
Order second = new Order();
second.pay();
second.ship();
second.cancel();
System.out.println("Втора: " + second.status());
Изход
Първа: изпратена
Връщаме парите на клиента
Втора: отказана

Първата е изпратена, без да е платена: ship проверява само за „отказана“. Втората е отказана, докато пратката е на път, и клиентът получава парите обратно, а куриерът пак ще я достави.

Никой не е писал грешка нарочно. Четири събития по пет статуса са двайсет комбинации, и всеки метод помни само някои от тях. Нов статус „опакована“ значи да мислиш наново за всичките четири метода, а забравената комбинация пак ще мине тихо.

Идеята

State: всяко състояние знае какво позволява

Въпросът „може ли това събитие сега?“ има отговор, който зависи от състоянието. Затова отговорът отива в самото състояние.

Всяко състояние е обект от свой клас. Той казва кои събития позволява и кое е следващото състояние. Общият базов клас OrderState отказва всяко събитие с ясна грешка, а всяко състояние презаписва само позволените. Поръчката не проверява нищо: предава събитието на текущото състояние и запомня това, което то върне.

Това е шаблонът State (състояние). Ролите са три:

  • поръчката пази текущото състояние. В книгите това се казва контекст;
  • базовият клас OrderState: всички събития, и всяко отказва по подразбиране;
  • конкретните състояния: NewState, PaidState, ShippedState, DeliveredState, CancelledState.

Виж го

Петте състояния и поръчката

Базовият клас: всяко събитие отказва с текст, в който е името на състоянието.

abstract class OrderState {
abstract String name();
OrderState pay() {
throw refuse("да се плати");
}
OrderState ship() {
throw refuse("да се изпрати");
}
OrderState deliver() {
throw refuse("да се достави");
}
OrderState cancel() {
throw refuse("да се откаже");
}
IllegalStateException refuse(String action) {
return new IllegalStateException("Поръчката е " + name() + ": не може " + action);
}
}

Всяко състояние презаписва само това, което позволява. Отказът от платена поръчка връща парите, а отказът от нова не връща нищо, защото нищо не е платено. Това поведение е в своето състояние, без if:

class NewState extends OrderState {
@Override
String name() {
return "нова";
}
@Override
OrderState pay() {
return new PaidState();
}
@Override
OrderState cancel() {
return new CancelledState();
}
}
class PaidState extends OrderState {
@Override
String name() {
return "платена";
}
@Override
OrderState ship() {
return new ShippedState();
}
@Override
OrderState cancel() {
System.out.println("Връщаме парите на клиента");
return new CancelledState();
}
}
class ShippedState extends OrderState {
@Override
String name() {
return "изпратена";
}
@Override
OrderState deliver() {
return new DeliveredState();
}
}
class DeliveredState extends OrderState {
@Override
String name() {
return "доставена";
}
}
class CancelledState extends OrderState {
@Override
String name() {
return "отказана";
}
}

Поръчката пази текущото състояние и предава всяко събитие на него:

class Order {
private OrderState state = new NewState();
public void pay() {
state = state.pay();
}
public void ship() {
state = state.ship();
}
public void deliver() {
state = state.deliver();
}
public void cancel() {
state = state.cancel();
}
public String status() {
return state.name();
}
}

Двете комбинации от проблема и после целият път:

Order order = new Order();
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.ship();
System.out.println(order.status());
try {
order.cancel();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.deliver();
System.out.println(order.status());
Изход
Поръчката е нова: не може да се изпрати
изпратена
Поръчката е изпратена: не може да се откаже
доставена

Order order = new Order();
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.ship();
System.out.println(order.status());
try {
order.cancel();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.deliver();
System.out.println(order.status());
Стъпка по стъпка, с думи
  1. Нова поръчка: полето state сочи NewState („нова“).
  2. ship() на нова поръчка. NewState няма стрелка ship, затова отказва базовият клас, а catch печата грешката.
  3. pay(): NewState го позволява и връща PaidState. Поръчката само запомня новото състояние.
  4. Същото ship() като преди, но сега отговаря PaidState: позволява го, а следващото е ShippedState.
  5. Статусът е името на текущото състояние: изпратена.
  6. cancel(), докато пратката е на път. ShippedState няма стрелка cancel: стена, и парите не се връщат.
  7. deliver(): от „изпратена“ към „доставена“.
  8. Никой не написа проверка за двете забранени комбинации. Те нямат стрелка, затова се отказват.

От „нова“ стрелка ship няма, затова изпращането се отказва. От „изпратена“ няма стрелка cancel. Никой не е написал проверка за тези две комбинации: те са забранени, защото никое състояние не ги е позволило.

Виж го

Ново събитие, без да пипаш другите състояния

Складът иска пратката да се опакова, преди да тръгне: платена → опакована → изпратена. В базовия клас идва новото събитие, и то отказва като останалите:

OrderState pack() {
throw refuse("да се опакова");
}

PaidState вече позволява pack вместо ship, а новото PackedState позволява ship и отказ с връщане на парите:

class PaidState extends OrderState {
@Override
String name() {
return "платена";
}
@Override
OrderState pack() {
return new PackedState();
}
@Override
OrderState cancel() {
System.out.println("Връщаме парите на клиента");
return new CancelledState();
}
}
class PackedState extends OrderState {
@Override
String name() {
return "опакована";
}
@Override
OrderState ship() {
return new ShippedState();
}
@Override
OrderState cancel() {
System.out.println("Връщаме парите на клиента");
return new CancelledState();
}
}

Поръчката получава метода pack, който само предава събитието:

public void pack() {
state = state.pack();
}
Order order = new Order();
try {
order.pack();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.pack();
order.ship();
System.out.println(order.status());
Изход
Поръчката е нова: не може да се опакова
изпратена

NewState, ShippedState, DeliveredState и CancelledState не са пипнати, а опаковането в тях е забранено, без да си написал ред за това. Платена поръчка вече не може да се изпрати направо, защото PaidState няма ship. При веригата от if новото събитие трябваше да мине през ума ти във всеки метод.

Виж го

Без клас за всяко състояние: enum и таблица

Тук отказът от платена поръчка връща парите: състоянието прави нещо, и това има място в клас. Ако състоянията не правят нищо, а само казват кой преход е позволен, стига по-малко.

enum е тип с предварително изброени стойности. Статусът е една от тях, а таблицата казва към кои статуси може да се мине от всеки:
enum Status { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
class Order {
private static final Map<Status, Set<Status>> NEXT = Map.of(
Status.NEW, Set.of(Status.PAID, Status.CANCELLED),
Status.PAID, Set.of(Status.SHIPPED, Status.CANCELLED),
Status.SHIPPED, Set.of(Status.DELIVERED),
Status.DELIVERED, Set.of(),
Status.CANCELLED, Set.of());
private Status status = Status.NEW;
public void moveTo(Status next) {
if (!NEXT.get(status).contains(next)) {
throw new IllegalStateException(status + " → " + next + ": не може");
}
status = next;
}
public Status status() {
return status;
}
}
Order order = new Order();
order.moveTo(Status.PAID);
order.moveTo(Status.SHIPPED);
try {
order.moveTo(Status.CANCELLED);
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
System.out.println(order.status());
Изход
SHIPPED → CANCELLED: не може
SHIPPED

Проверката е на едно място, а преходите са данни: един ред в таблицата за нов статус. Това е честният избор, когато състоянията нямат поведение. Щом някой преход трябва да прави нещо (да върне парите, да прати имейл, да запише кога е изпратено), таблицата започва да расте if-ове около себе си, и тогава State си заслужава класовете.

По-точно, за любопитните

Състоянията тук нямат полета, затова един обект от всяко би стигнал за всички поръчки. В Java това често се пише като enum, в който всяка стойност има свои методи (NEW { OrderState pay() { return PAID; } }). Това спестява обекти, но има цена: щом състояние получи поле (сумата за връщане, датата на плащане), един общ обект ще го сподели между всички поръчки. Затова new при всеки преход е по-безопасното начало.

Пробвай

Предвиди изхода

Поръчката се плаща и веднага се отказва, а после някой опитва да я изпрати. Какво печата програмата?

Order order = new Order();
order.pay();
order.cancel();
System.out.println(order.status());
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
Отговор

Пробвай

Намери грешката

Почти всяко състояние позволява отказ, затова някой решава да го напише веднъж, в базовия клас:

abstract class OrderState {
abstract String name();
OrderState pay() {
throw refuse("да се плати");
}
OrderState ship() {
throw refuse("да се изпрати");
}
OrderState deliver() {
throw refuse("да се достави");
}
OrderState cancel() {
return new CancelledState();
}
IllegalStateException refuse(String action) {
return new IllegalStateException("Поръчката е " + name() + ": не може " + action);
}
}
Order order = new Order();
order.pay();
order.ship();
order.cancel();
System.out.println(order.status());
Изход
отказана

Изпратената поръчка се отказа без грешка, а пратката е на път. На кой ред е грешката?

Избери реда с грешката.

Идеята

Кога не ти трябва

State има цена: по един клас за всяко състояние и базов клас, вместо едно поле. Не си струва, когато:

  • състоянията са две и преходът е един. „Платена или не“ е едно булево поле;
  • състоянията нямат поведение, а само позволени преходи. Тогава стига enum и таблица от картата преди;
  • преходите идват отвън (собственикът на магазина ги настройва в админ панела). Класовете са в кода, а данните могат да се сменят без нова версия на програмата.

Струва си, когато състоянията са повече, всяко прави нещо свое при събитие, а новите идват редовно.

Упражнение

Връщане до 14 дни

Реши задачата в редактора си.

Връщане. Клиентът може да върне доставена поръчка, а магазинът му връща парите. Добави събитие returnOrder и състояние „върната“. Позволено е само от „доставена“. Пусни една поръчка до доставена и я върни, а после опитай да върнеш нова поръчка.

Изход
Връщаме парите на клиента
върната
Поръчката е нова: не може да се върне

Още задачи

  • История. Поръчката помни статусите, през които е минала, и ги печата така: нова → платена → изпратена → доставена. Къде записваш поредния статус: в поръчката или във всяко състояние?
  • Имейл при всяка смяна. След всеки позволен преход клиентът получава ред „Имейл: поръчката е платена“. Направи го така, че отказаното събитие да не праща имейл.
  • Опакована с таблицата. Добави „опакована“ във версията с enum и таблица. Преброй колко места смени там и колко във версията с класове.

Тест

Провери се

1. Магазинът добавя събитие „опаковай“. Какво пишеш в състоянията, в които то не е позволено?
2. Какво пази поръчката при State?
3. Някой прави отказа на поръчка позволен по подразбиране, в базовия клас, за да не се пише във всяко състояние. Какво става с изпратена поръчка?
4. Кога стига enum и таблица на преходите, без клас за всяко състояние?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·