Як заборонити некоректні стани в програмі на етапі компіляції
Привіт, я нещодавно писав про те, чому програмісти мають вивчити мову з розвиненою системою типів, навіть якщо нічого не будуть на ній писати. Одним з ключових моментів, на якому я намагався зосередитись було те, що дуже корисно унеможливлювати некоректні стани в коді так, щоб програми, які до них приводять, не компілювались взагалі.
Чимала кількість отриманих коментарів була про те, що «в мейнстрім-мовах це непотрібно, і там роблять не так».
Я вважаю, що чужий досвід завжди корисний, тоже треба його збирати. Придумав маленьку задачу, яка зводиться до обмеження можливості використання тих чи інших функцій з API в залежності від того, які функціі було викликано до того. Я покажу вам свою реалізацію, і сподіваюсь, що ви покажете мені свою.
Задача
Уявіть невелику базу на Місяці з астронавтом, який живе на ній. Він може вдягати або знімати свій скафандр, відкривати або закривати шлюз, залишати базу і повертатися назад, а також, перебуваючи ззовні, збирати зразки реголіту, які поміщаються в спеціальну кишеню на скафандрі. Пізніше кишеню можна очистити. Також астронавт може нарікати на своє життя, коли його ніхто не чує.
Ми моделюємо це як модуль/бібліотеку/клас/... (в залежності від вибору вашої мови програмування), яку називаємо Moonbase, в API якої входять наступні функції:
begin_day— починає новий день (з астронавтом всередині бази, без скафандра, із закритим шлюзом. Це може бути конструктор класу або схожий механізм ініціалізації).open_lockclose_lockspacesuit_onspacesuit_offleave_baseenter_basemoantake_sampleempty_pocketend_day— закінчує день на базі. Це може бути деструктор класу або схожий механізм завершення.
Для простоти домовимось, що ці функції виводять повідомлення в stdout і нічого більше. З точки зору поведінки, така реалізація на Python буде вичерпною і достатньою:
class Moonbase:
def __init__(self):
print ("DAY BEGINS")
def open_lock(self):
print ("OPENING LOCK")
def close_lock(self):
print ("CLOSING LOCK")
def spacesuit_on(self):
print ("SPACESUIT ON")
def spacesuit_off(self):
print ("SPACESUIT OFF")
def leave_base(self):
print ("LEAVING BASE")
def enter_base(self):
print ("ENTERING BASE")
def moan(self):
print (". o O (Ugh! Damn moon! I am too old for this!)")
def take_sample(self):
print ("MINING REGOLITH")
def empty_pocket(self):
print ("EMPTYING POCKET")
def end_day(self):
print ("DAY ENDS")
Якщо ви хочете, щоб ці функції приймали додаткові аргументи або повертали значення, ви можете це зробити. Загалом робіть те, що є ідіоматичним/природним у вашій мові.
Додаткові правила
Додатково є набір правил, виконання яких ми хочемо забезпечити на етапі компіляції. Вони визначають, коли конкретну функцію можна або не можна викликати.
- Скафандр можна одягнути тільки якщо він не одягнутий.
- Скафандр можна зняти тільки якщо він одягнутий.
- Шлюз можна відкрити тільки якщо він закритий.
- Шлюз можна закрити тільки якщо він відкритий.
- Щоб відкрити шлюз, скафандр має бути одягнутий.
- Кишені скафандра мають бути порожніми, щоб його можна було одягнути (інакше крихти реголіту можуть пошкодити скафандр).
- Скафандр не можна знімати ззовні.
- Скафандр не можна знімати, якщо шлюз відкритий.
- Скафандр не можна знімати, якщо його кишені повні (інакше крихти реголіту можуть пошкодити скафандр).
- Базу можна залишити тільки якщо астронавт всередині, у скафандрі, і шлюз відкритий.
- Якщо ви вийшли назовні, ви обов’язково повинні взяти зразок реголіту. Тобто ви можете увійти на базу тільки з повною кишенею скафандра і відкритим шлюзом.
- Астронавт не повинен нарікати на життя на Місяці у скафандрі — це буде записано і погано вплине на його кар’єру.
- Збір зразків реголіту можна здійснювати тільки ззовні. Це створює багато пилу, тому це потрібно робити тільки з закритим шлюзом. Кишеня скафандра має бути порожньою.
- Кишеню скафандра можна очистити будь-коли. Якщо це зроблено ззовні, це означає, що астронавт більше не має зразка і, можливо, йому доведеться отримати новий для повернення на базу.
- Наприкінці дня астронавт повинен бути всередині бази, без скафандра, із закритим шлюзом.
Ми можемо додати стан до наївної реалізації Python вище і використовувати його для забезпечення цих правил (але лише на етапі виконання, тож це лише ілюстрація, а не бажане рішення):
class Moonbase:
lockOpen = False
suitOn = False
isOutside = False
pocketsFull = False
def __init__(self):
print ("DAY BEGINS")
def open_lock(self):
assert(not self.lockOpen)
assert(self.suitOn)
print ("OPENING LOCK")
self.lockOpen=True
def close_lock(self):
assert(self.lockOpen)
print ("CLOSING LOCK")
self.lockOpen=False
def spacesuit_on(self):
assert(not self.suitOn)
assert(not self.pocketsFull)
print ("SPACESUIT ON")
self.suitOn=True
def spacesuit_off(self):
assert(self.suitOn)
assert(not self.isOutside)
assert(not self.lockOpen)
assert(not self.pocketsFull)
print ("SPACESUIT OFF")
self.suitOn=False
def leave_base(self):
assert(self.lockOpen)
assert(self.suitOn)
assert(not self.isOutside)
print ("LEAVING BASE")
self.isOutside=True
def enter_base(self):
assert(self.isOutside)
assert(self.pocketsFull)
assert(self.lockOpen)
print ("ENTERING BASE")
self.isOutside=False
def moan(self):
assert(not self.suitOn)
print (". o O (Ugh! Damn moon! I am too old for this!)")
def take_sample(self):
assert(self.isOutside)
assert(not self.lockOpen)
assert(not self.pocketsFull)
print ("TAKING REGOLITH SAMPLE")
self.pocketsFull=True
def empty_pockets(self):
assert(self.pocketsFull)
print ("EMPTYING POCKETS")
self.pocketsFull=False
def end_day(self):
assert(not self.isOutside)
assert(not self.lockOpen)
assert(not self.suitOn)
assert(not self.pocketsFull)
print ("DAY ENDS")
Відкрите питання
Чи можемо ми забезпечити виконання всіх цих правил на етапі компіляції — так, щоб код, який порушує їх, взагалі не можна було б скомпілювати та запустити? Наприклад, щоб отака програма (псевдокод) не компілювалась:
begin_day () spacesuit_on open_lock leave_base close_lock /* Cannot take spacesuit off, will suffocate, so we should get compile error on the next line */ spacesuit_off end_day
Я зробив маленький проєкт на GitHub, який містить реалізації на Haskell, та на OCaml (інтерфейс бібліотеки, та його імплементація). В них обох коректність переходів контролюється за рахунок кодування поточного стану системи лише в сигнатурах функцій. Що лишає реалізацію API дуже простою, майже як в моєму першому прикладі на Python (який не перевіряє ніяких правил взагалі). Для Haskell код модуля Moonbase займає 53 (непустих) рядки, для OCaml — 24. Ось, наприклад, імплементація Moonbase на OCaml:
type ('lock, 'suit, 'location, 'pockets) state = State
let begin_day () = print_endline "DAY BEGINS"; State
let open_lock _ = print_endline "OPENING LOCK"; State
let close_lock _ = print_endline "CLOSING LOCK"; State
let spacesuit_on _ = print_endline "SPACESUIT ON"; State
let spacesuit_off _ = print_endline "SPACESUIT OFF"; State
let leave_base _ = print_endline "LEAVING BASE"; State
let enter_base _ = print_endline "ENTERING BASE"; State
let moan _ = print_endline ". o O (Ugh! Damn moon! I am too old for this!)"; State
let take_sample _ = print_endline "TAKING REGOLITH SAMPLE"; State
let empty_pockets _ = print_endline "EMPTYING POCKETS"; State
let end_day _ = print_endline "DAY ENDS"
І ось сігнатури, які, власне, і забезпечують дотримання правил:
type ('lock, 'suit, 'location, 'pockets) state
val begin_day : unit -> ([`lock_closed], [`spacesuit_off], [`inside], [`empty]) state
val open_lock : ([`lock_closed], [`spacesuit_on], 'location, 'pockets) state
-> ([`lock_open], [`spacesuit_on], 'location, 'pockets) state
val close_lock : ([`lock_open], [`spacesuit_on], 'location, 'pockets) state
-> ([`lock_closed], [`spacesuit_on], 'location, 'pockets) state
val spacesuit_on : ([`lock_closed], [`spacesuit_off], [`inside], [`empty]) state
-> ([`lock_closed], [`spacesuit_on], [`inside], [`empty]) state
val spacesuit_off : ([`lock_closed], [`spacesuit_on], [`inside], [`empty]) state
-> ([`lock_closed], [`spacesuit_off], [`inside], [`empty]) state
val leave_base : ([`lock_open], [`spacesuit_on], [`inside], 'pockets) state
-> ([`lock_open], [`spacesuit_on], [`outside], 'pockets) state
val enter_base : ([`lock_open], [`spacesuit_on], [`outside], [`full]) state
-> ([`lock_open], [`spacesuit_on], [`inside], [`full]) state
val moan : ('lock, [`spacesuit_off], 'location, 'pockets) state
-> ('lock, [`spacesuit_off], 'location, 'pockets) state
val take_sample : ([`lock_closed], [`spacesuit_on], [`outside], [`empty]) state
-> ([`lock_closed], [`spacesuit_on], [`outside], [`full]) state
val empty_pockets : ('lock, 'suit, 'location, [`full]) state
-> ('lock, 'suit, 'location, [`empty]) state
val end_day : ([`lock_closed], [`spacesuit_off], [`inside], [`empty]) state -> unit
Я додав до проєкту простий Makefile, який дозволяє скомпілювати код за допомогою dockerized compiler, щоб ви могли за бажанням спробувати мій код, не встановлюючи собі весь toolchain Haskell або OCaml.
Як це роблять у мейнстрімі
Я сподіваюсь, що ви мені покажете :)
Якщо цей іграшковий приклад вас зацікавив, я був би радий побачити реалізації на інших мовах програмування — форкайте мій проєкт, додавайте свою мову, відкривайте PR.
53 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів