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

State

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

Проблем

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

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

class Order:
def __init__(self):
self.status = "нова"
def pay(self):
if self.status == "нова":
self.status = "платена"
else:
raise RuntimeError(f"Поръчката е {self.status}: не може да се плати")
def ship(self):
if self.status == "отказана":
raise RuntimeError(f"Поръчката е {self.status}: не може да се изпрати")
self.status = "изпратена"
def deliver(self):
if self.status == "изпратена":
self.status = "доставена"
else:
raise RuntimeError(f"Поръчката е {self.status}: не може да се достави")
def cancel(self):
if self.status == "доставена":
raise RuntimeError(f"Поръчката е {self.status}: не може да се откаже")
if self.status != "нова":
print("Връщаме парите на клиента")
self.status = "отказана"

Две поръчки:

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

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

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

Идеята

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

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

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

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

  • поръчката пази текущото състояние. В книгите това се казва контекст;
  • базовият клас OrderState: всички събития, и всяко отказва по подразбиране;
  • конкретните състояния: NewState, PaidState, ShippedState, DeliveredState, CancelledState.
И тук, на Python, състоянията са класове, не функции: всяко има няколко събития, а базовият клас дава отказа на всички наведнъж. По-простата форма без класове е в картата „Без клас за всяко състояние“.

Виж го

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

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

class OrderState:
name = ""
def pay(self):
raise self.refuse("да се плати")
def ship(self):
raise self.refuse("да се изпрати")
def deliver(self):
raise self.refuse("да се достави")
def cancel(self):
raise self.refuse("да се откаже")
def refuse(self, action):
return RuntimeError(f"Поръчката е {self.name}: не може {action}")

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

class NewState(OrderState):
name = "нова"
def pay(self):
return PaidState()
def cancel(self):
return CancelledState()
class PaidState(OrderState):
name = "платена"
def ship(self):
return ShippedState()
def cancel(self):
print("Връщаме парите на клиента")
return CancelledState()
class ShippedState(OrderState):
name = "изпратена"
def deliver(self):
return DeliveredState()
class DeliveredState(OrderState):
name = "доставена"
class CancelledState(OrderState):
name = "отказана"

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

class Order:
def __init__(self):
self.state = NewState()
@property
def status(self):
return self.state.name
def pay(self):
self.state = self.state.pay()
def ship(self):
self.state = self.state.ship()
def deliver(self):
self.state = self.state.deliver()
def cancel(self):
self.state = self.state.cancel()

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

order = Order()
try:
order.ship()
except RuntimeError as e:
print(e)
order.pay()
order.ship()
print(order.status)
try:
order.cancel()
except RuntimeError as e:
print(e)
order.deliver()
print(order.status)
Изход
Поръчката е нова: не може да се изпрати
изпратена
Поръчката е изпратена: не може да се откаже
доставена

order = Order()
try:
order.ship()
except RuntimeError as e:
print(e)
order.pay()
order.ship()
print(order.status)
try:
order.cancel()
except RuntimeError as e:
print(e)
order.deliver()
print(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. Никой не е написал проверка за тези две комбинации: те са забранени, защото никое състояние не ги е позволило.

Виж го

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

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

def pack(self):
raise self.refuse("да се опакова")

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

class PaidState(OrderState):
name = "платена"
def pack(self):
return PackedState()
def cancel(self):
print("Връщаме парите на клиента")
return CancelledState()
class PackedState(OrderState):
name = "опакована"
def ship(self):
return ShippedState()
def cancel(self):
print("Връщаме парите на клиента")
return CancelledState()

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

def pack(self):
self.state = self.state.pack()
order = Order()
try:
order.pack()
except RuntimeError as e:
print(e)
order.pay()
order.pack()
order.ship()
print(order.status)
Изход
Поръчката е нова: не може да се опакова
изпратена

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

Виж го

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

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

Enum (от модула enum) е тип с предварително изброени стойности, а auto() им дава номера сам. Статусът е една от тях, а речникът казва към кои статуси може да се мине от всеки:
class Status(Enum):
NEW = auto()
PAID = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
NEXT = {
Status.NEW: {Status.PAID, Status.CANCELLED},
Status.PAID: {Status.SHIPPED, Status.CANCELLED},
Status.SHIPPED: {Status.DELIVERED},
Status.DELIVERED: set(),
Status.CANCELLED: set(),
}
class Order:
def __init__(self):
self.status = Status.NEW
def move_to(self, next_status):
if next_status not in NEXT[self.status]:
raise RuntimeError(f"{self.status.name} → {next_status.name}: не може")
self.status = next_status
order = Order()
order.move_to(Status.PAID)
order.move_to(Status.SHIPPED)
try:
order.move_to(Status.CANCELLED)
except RuntimeError as e:
print(e)
print(order.status.name)
Изход
SHIPPED → CANCELLED: не може
SHIPPED

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

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

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

Пробвай

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

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

order = Order()
order.pay()
order.cancel()
print(order.status)
try:
order.ship()
except RuntimeError as e:
print(e)
Отговор

Пробвай

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

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

class OrderState:
name = ""
def pay(self):
raise self.refuse("да се плати")
def ship(self):
raise self.refuse("да се изпрати")
def deliver(self):
raise self.refuse("да се достави")
def cancel(self):
return CancelledState()
def refuse(self, action):
return RuntimeError(f"Поръчката е {self.name}: не може {action}")
order = Order()
order.pay()
order.ship()
order.cancel()
print(order.status)
Изход
отказана

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

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

Идеята

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

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

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

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

Упражнение

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

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

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

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

Още задачи

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

Тест

Провери се

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

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