Як заборонити некоректні стани в програмі на етапі компіляції
Привіт, я нещодавно писав про те, чому програмісти мають вивчити мову з розвиненою системою типів, навіть якщо нічого не будуть на ній писати. Одним з ключових моментів, на якому я намагався зосередитись було те, що дуже корисно унеможливлювати некоректні стани в коді так, щоб програми, які до них приводять, не компілювались взагалі.
Чимала кількість отриманих коментарів була про те, що «в мейнстрім-мовах це непотрібно, і там роблять не так».
Я вважаю, що чужий досвід завжди корисний, тоже треба його збирати. Придумав маленьку задачу, яка зводиться до обмеження можливості використання тих чи інших функцій з 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 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівздається моя стаття має цитування, класний фідбек:)
ваша задача також може бути вирішена за допомогою індексної монади. тоді не потрібні ніякі розширення компілятора (flexible instance потрібен щоб зробити трансформер)
{-# LANGUAGE FlexibleInstances #-} module Moonbase2 where import Control.Monad.Trans newtype IxMonad a b m x = IxMonad {uM :: a -> m (x,b)} ireturn :: Monad m => x -> IxMonad a a m x ireturn x = IxMonad $ \s -> return (x,s) (>>>=) :: Monad m => IxMonad a b m x -> (x -> IxMonad b c m y) -> IxMonad a c m y abmx >>>= a_bcmy = IxMonad $ \a -> uM abmx a >>= \(b,x) -> uM (a_bcmy b) x instance MonadTrans (IxMonad a a) where lift mx = IxMonad $ \a -> mx >>= \x -> return (x,a) lift_io :: MonadIO m => IO x -> IxMonad a a m x lift_io = lift . liftIO data Opened = Opened data Closed = Closed data SuitOn = SuitOn data SuitOff = SuitOff data Inside = Inside data Outside = Outside data Empty = Empty data Full = Full do_beginday :: IxMonad a (Closed, SuitOff, Inside,Empty) IO () do_beginday = lift_io (print "day start") >>>= \() -> IxMonad $ \_ -> return ((), (Closed, SuitOff, Inside, Empty)) do_open_lock :: IxMonad (Closed,SuitOn,a,b) (Opened,SuitOn,a,b) IO () do_open_lock = lift_io (print "open door") >>>= \() -> IxMonad $ \(_,_,a,b) -> return ((), (Opened,SuitOn,a,b)) do_close_lock :: IxMonad (Opened,SuitOn,a,b) (Closed,SuitOn,a,b) IO () do_close_lock = lift_io (print "close door") >>>= \() -> IxMonad $ \(_,_,a,b) -> return ((), (Closed,SuitOn,a,b)) do_suiton :: IxMonad (Closed,SuitOff,Inside,Empty) (Closed,SuitOn,Inside,Empty) IO () do_suiton = lift_io (print "wearing a space suit") >>>= \() -> IxMonad $ \_ -> return ((), (Closed,SuitOn,Inside,Empty)) do_suitoff :: IxMonad (Closed,SuitOn,Inside,Empty) (Closed,SuitOff,Inside,Empty) IO () do_suitoff = lift_io (print "put off a space suit") >>>= \() -> IxMonad $ \_ -> return ((), (Closed,SuitOff,Inside,Empty)) do_leave_base :: IxMonad (Opened,SuitOn,Inside,Empty) (Opened,SuitOn,Outside,Empty) IO () do_leave_base = lift_io (print "leaving station") >>>= \() -> IxMonad $ \_ -> return ((), (Opened,SuitOn,Outside,Empty)) do_enter_base :: IxMonad (Opened,SuitOn,Outside,Full) (Opened,SuitOn,Inside,Full) IO () do_enter_base = lift_io (print "entering station") >>>= \() -> IxMonad $ \_ -> return ((), (Opened,SuitOn,Inside,Full)) take_sample :: IxMonad (Closed,SuitOn,Outside,Empty) (Closed,SuitOn,Outside,Full) IO () take_sample = lift_io (print "taking sample") >>>= \() -> IxMonad $ \_ -> return ((), (Closed,SuitOn,Outside,Full)) empty_pocket :: IxMonad (Closed,SuitOn,Inside,Full) (Closed,SuitOn,Inside,Empty) IO () empty_pocket = lift_io (print "taking sample") >>>= \() -> IxMonad $ \_ -> return ((), (Closed,SuitOn,Inside,Empty)) do_endday :: IxMonad (Closed,SuitOff,Inside,Empty) (Closed,SuitOff,Inside,Empty) IO () do_endday = lift_io (print "end ") >>>= \() -> IxMonad $ \_ -> return ((), (Closed,SuitOff,Inside,Empty)) myday = do_beginday >>>= \_ -> do_suiton >>>= \_ -> do_open_lock >>>= \_ -> do_leave_base >>>= \_ -> do_close_lock >>>= \_ -> take_sample >>>= \_ -> do_open_lock >>>= \_ -> do_enter_base >>>= \_ -> do_close_lock >>>= \_ -> empty_pocket >>>= \_ -> do_suitoff >>>= \_ -> do_enddayна с++ таке точно можна написати, я навіть щось таке подібне робив колись, з variadic templates, enable_if and partial template specification. блищить як сльоза дитиниДякую за код!
Я навмисно не використовував монади, щоб не потрапити в ситуацію, коли усі коментарі будуть про те що «монади незрозуміло» або «монади непотрібно» :)
похоже на design by contract
не помню правда, было ли там что-то для компиляций?
если даже нет, то вероятнее всего для плюсов можно славать используя макросы
такий доу мені подобається
В мейнстрімі є юніттести, що фактично завжди є продовженням компіляції, і невід’ємною частиною процесу білда.
Якщо ж хочеться саме на етапі компіляції, то тут все просто — робимо класи з обмеженими інтерфейсами.
Ви не моделюєте на рівні об’єктів, по факту те що ви зробили (звалили всі дії в 1 клас) — це антипатерн (Год Обджект).
open_lock, enter_base, take_sample — це принаймні 2 різних класи — база, астронавт; ще там може бути сутність скафандр.
Якщо є бажання все на рівні саме компіляції, то робимо аналогічно, як це скоріше буде в ФП — скафандр та одягнений скафандр — це різні типи, так само як і база з відкритим та закритим люками. Якщо бізнес логіка, як у вас, одна строка, то купа класів — це оверхед, якщо маємо складну бізнес логіку, то можливо це й доцільно
А до чого тут юніттести? Проблему вони ніяк не вирішать, це просто твердження про те, що при деяких (типових) сценаріях все працює як треба. Але це не гарантія того, що не існують сценарії, коли це валиться.
Яку проблему вирішує
?
Так от юніт тести вирішують рівно ту саму.
Ну, наприклад таку проблему:
Ви пишете client library до 3rd party сервісу, спілкування з яким іде по якомусь складному протоколу, з «додатковими правилами» типу «на цьому етапі можна посилати лише це, а на цьому — робити це і це», і т.п.
У моєму прикладі автор бібліотеки пише (не дуже складні) type annotations, і компілятор примушує виконувати ці правила.
Ви пропонуєте альтернативу: щоб користувачі цієї бібліотеки могли переконатись, що у них все гаразд з дотриманням правил, їм надо писати unit тести, щоб «конвертувати» runtime checks в build-time (або CI/CD time) checks. Але ж для цього їм потрібен або mock серверу, або mock API цієї бібліотеки, який моделює частину логіки серверу. Ці mock-и не можуть бути простими «затичками» — вони повинні мати свою внутрішню логіку, щоб відстежувати, в якому стані ми зараз є, що можна робити, а що — ні.
Ці mock-и користувачі мають або писати самі, або для них їх повинні написати ви. Але якщо готового mock-у нема, то скоріше за все користувачі не будуть писати їх самі.
Це ми зараз про біржі говоримо? :)
Якщо ми пишемо клієнт зі сторони 3rd party сервісу, то так, нам би логічно обмежити, щоб абізяни не лізли з битими даними. Проблема в тому, що абізяни будуть писати на якійсь джаві/ЦПП/пітоні і не дуже зрадіють нашому дизайну клієнта (і там можуть знайтись винахідливі абізяни, що «імплементують протокол самостійно, бо ліба тормозить»). Нода (за рахунок тайпскріпта) тут трохи у виграшній позиції.
Якщо ми пишемо клієнт до сервісу, який ми не контролюємо, то компайлтайм перевірка нам не вирішить описану проблему, бо протокол може змінитись без відома автора бібліотеки.
Чому тоді не сказати просто: це зробити неможливо, але в реальному житті такої проблеми не існує.
Вона покращить ситуацію, тому що ми не зможемо зробити певний клас помилок. Якщо змінюється протокол, то ми отримаємо помилку protocol not supported.
Бо це не правда :)
Це зробити складно (не неможливо), але в реальному житті (точніше в мейнстрімі) це обходять іншими способами, що мають гірші показники по якісному атрибуту «надійність», але кращі по «ціна», «швидкість», «масштабування команди» (це скоріше вже зовсім бізнес метрика).
Юніт-тести не вирішують проблему, вони просто збільшують ймовірність що все добре. Наприклад,
ми хочемо, щоб Send можна було викликати (1) після вдалого виклику Connect; (2) після вдалої перевірки IsConnected. В інших випадках помилка компіляції. Я розумію, що ми можемо розбити ISocket на
таале чим тут допоможуть юніттести? Як вони можуть перевірити, що розробник десь в коді не викликав Connect без перевірки IsConnected?
Ви сформулюйте проблему спочатку :)
Даю підказку: оте, що ви написали про сокети — це приклад, а не формулювання загальної проблеми
Сабж:
Ось, гарно сформульовано. Можливо треба додати «на етапі перевірки коду». Що тут незрозумілого?
Тут все зрозуміло, але сформульовано не гарно, навіть паршиво і зі спробою придумати (це ви правильно сказали) задачу від рішення, а не описати реальну проблему.
Це не я казав, це автор теми сказав.
Я зрозумів, що для вас це не проблема.
Ну а так програмісти роблять баги, інколи це буває досить неприємно для всіх, тому звісно хотілося б якось зменшити їх кількість.
Один зі шляхів зменшити кількість багів це юніттести. Так, це себе гарно зарекомендувало, але гарантій, що в програмі немає багів вони не дають. Взагалі, мій досвід каже про те, що частіше усього баги виникають там де програміст не подумав, що таке взагалі можливо. Тому написати тест про випадок, який не прийшов йому в голову, він не може.
Інший підхід у боротьбі з багами це типи. Причому типи можуть вживатися в двох значеннях, які інтуїтивно зрозумілі, але у мозку плутанина. Тому на співбесіді можна запитати, що таке тип даних (взагалі, а не в конкретній мові програмування), та подивитися на реакцію. Перше визначення типа це інформація, яка доступна лише до запуску кода (скажімо компіляція чи синтаксична перевірка), мета якої зменшення помилок в програмі. Зазвичай типи в Haskell та Сі це чисті приклади таких типів. Друге визначення це частина значення, яке описує те, що зберігається в інших. Наприклад, типи в Python це типовий приклад другого визначення. А з точки зору першого, в мові програмування Python усього один тип, це
PyObject. Життя звісно складніше, і часто типи це суміш першого та другого визначення, наприклад об’єкти в C++ містять поля RTTI, тому в runtime ми можемо зробити певні перевірки. В тому ж Python є аннотації, є Pylint, ...Так от, типи в (першому значенні) це як раз це один зі шляхів зменшити кількість багів. Наприклад, якщо ми звертаємося до неіснуючого поля в класі, то ми отримаємо помилку компіляції в Java або
AttributeErrorпід час виконання програми в Python. Чим більше розвинута система типів, тим більше різних помилок вона може піймати на етапі перевірки коду. Закінчується це все мовами програмування зі залежними типами (CoQ, Idris, Agda) де будь яке істинне математичне твердження стосовно поведінки програми можна довести засобами мови.Якщо брати типи, то ми маємо множину скалярних типів, яку можна розвивати двома способами: OOP та ADT. Якщо брати OOP, то... воно зручне в написанні коду, але має певні проблеми з потужністю системи типів. Але в реальному житті на це просто забивають болт. Комусь вистачає Python, а комусь OOP: накрили юніттестами і ок, з’явився баг — винні тестери. Інколи, можливо, анотації та зовнішні системи. До речі, там є дуже цікава гілочка з верифікацією на базі трійок Хоара (Ada), але... якось це не дуже набуває популярності, тому це типовий випадок вирішення проблеми, коли вона не вважається реальною проблемою. ADT вже дає набагато більше гарантій, саме цим пояснюється популярність Rust.
Ну а далі... Як показує мій досвід, більшість розробників взагалі не уявляють, що таке взагалі можливо: ООП це топ, треба писати код, фіксити баги, це макисмум що можливо. Звісно що тут виникає бажання поділитися просвітити, що на світі також існує магія.
Чудово. Маємо проект на джаві (туди ще хтось втягнув кастомні спрінгові анотації, що підтягують данні як проксі в рантаймі через рефлекцію), продемонструйте використання типів для такого проекту.
Власне яка практична цінність від того, що такий підхід існує, якщо його застововувати не ефективно для «мейнстрім мов».
Далі цікавіше: я бачив проект де багато вирішувалось через правильне моделювання типів; туди була проблема стафити людей, часто навіть в команді були люди, що хотіли зрізати кути (і так само нещодавно бачив на тайпскріпті спробу зрізати кути)
Мені жаль, що у вас такий рівень професіоналізму в вашому оточенні. Враховуючи стан універів (по всьому світу) далі буде тільки веселіше.
Але повертаємось до питання:
Який практичний ауткам ви очікуєте від того, що люди «дізнаються» (проникнуться) системою типів?
Ну... в Java немає розвинутої системи типів, тому як можна продемонструвати те, чого немає? Напевне, це можна зробити в Scala, але я її не знаю.
Взагалі дуже дивне питання. Аналогічно можна взяти проект наприклад, на Rust, та запитати, яка користь буде від Java ООП в ньому? Речення виглядає для мене як маячня.
Ну... якщо мова дозволяє «зрізати кути», то... рано чи пізно хтось це зробить. Можливо навіть не подумає що зрізає кут.
Бо ширпоживчі мови мають вбудовані обмеження.
На який практичний ауткам розраховують вчені, які пишуть науково-популярні книжки? Більше людей опанують, отримають задоволення, буде більш великий кругозір, ...
Ну... навіть на dou можна бачити, що більшість опонентів не розуміє, що таке монади :-)
Так, я з цим теж часто стикаюсь, ось ви незодавно продемонстрували, що не маєте розуміння, що таке монада :)
Бабло від продажів.
Але ваші вислови в цьому коментарі — це демагогії, тому коментувати всі не буду. Якщо в вас є бажання зрозуміти і донести свої ідеї, то перечитайте мій попередній коментар.
Малоймовірно, не кажучи про те, що часто це на волонтерских засадах.
Де саме?
Можливо, але я можу хоча б практично використовувати, наприклад тут. І навіть мого рівня нерозуміння вистачає, щоб зрозуміти, що в інших він набагато меньше.
Демагогія на демагогію... В невідомому проекті, який вже написаний в ООП-стилі, щось треба показати... Щось нагадує ось будинок, треба поглибити фундамент на п’ять метрів.
загалом юніт тести прямо для цього призначені by design
тести мають співпадати з тим, що дійсно треба від коду (подібну проблему мають застарілі коментарі в коді). тести має хтось писати. коду для тестів набагато більше, ніж опис типу (більше коду -> більше помилок, більше коду -> більше писати, більше платити програмісту). найголовніше тести by design не можуть бути повні, можуть бути повні лише для деякий випадків.
Мій приклад подобається мені тим, що навіть якщо користувач бібліотеки не хоче/не пише юніт-тести, то все одне некоректний код скомпілювати не вийде.
Покаже як?
Ну, в мене не ООП, тож є одна бібліотека/один модуль на все API, щоб код був простіший і основна ідея не загубилася за boilerplate. Але звичайно можна розбити мій тип State на чотири окремих типи, це суттєво нічого не змінить.
Є!
Ну, в мене тип один і той же, але з різними параметрами. Мені здається що це може бути суттєвою відмінністю. Як при цьому будуть виглядати типи аргументів для «будь-який стан скафандру», наприклад?
Вважайте, що бізнес-логіка схована за інтерфейсом API, і користувачу не відомо, одна строка там, чи 100500. Бізнес-логіка тут не дуже цікава, фокус на дизайні API.
Я б залюбки подивився, як виглядатиме ваш варіант (побитий на окремі об’єкти, якщо так ідіоматично, і т.п.).
Користувач бібліотеки і не повинен писати юніт тести на бібліотеку. Ваш приклад має сутєву перевагу для такого сценарію — ви повідомляєте користувача __бібліотеки__ про можливі проблеми. Але для цього треба, щоб кодова база була організована певним чином. Якщо все в одному місці, то ассерт (навіть якщо там обмеження типів) буде прибрано, бо воно «заважало компіляції».
Останні 2 реченні. А для того, щоб подивитись на код, то нам треба якось попасти на один і той самий реальний проект, можливо навіть будемо писати софт для бази на місяці.
Скоріше за все вони будуть в батьківському класі «Просто скафандр»
Я не зрозумів про «кодова база організована певним чином» — яким?
Про «буде прибрано» — я пропоную не доводити вже зовсім до абсурду, і прийняти, що користувач бібліотеки не буде стріляти собі в ногу, прибираючи safety checks.
Я інтерпретую ваші коментарі так, що коду треба писати нетрівіальні обсяги (всяко більше моїх 30 строк), і якщо це так — то цілком розумію ваше небажання це робити. Вибачайте, я прочитав в вашому верхньому коментарі «Якщо ж хочеться саме на етапі компіляції, то тут все просто ...» і думав, що буде дійсно просто :)
Окремо винесені в інші репозиторії бібліотеки для початку. Але знову ж оновити версію — це час (якрах кілька тижнів тому довелось з цим погратись).
Для дизайну зовнішнього АПІ ви навели гарний аргумент. В контексті розробки сервісу (бізнес логіки процесу), в «мейнстрімі» для зменшення помилок покладаються на юніт тести (а по факту на ТТД-подібний процес розробки коду), бо це дозволяє дешевшими спеціалістами вирішувати задачу отримання певного низького рівня помилок при внесенні змін у функціональність.
Бачив таке літералі тиждень тому :) Бо доставка фічі важливіша за дизайн АПІ, а «помилки тут немає, передаються коректні дані» (і в тому випадку все було коректно)
1) Так. Я в принципі і не стверджував, що буде менше строк ніж у вас. Груба оцінка — буде десь100-300 строк.
2) Якби навіть треба було лише 5 строк, то відповідь була б та сама
от за це я щиро люблю «класичний ооп» )) (на справді ніт)
... я сам «знайшов» цю «цікавинку» у «класичному ооп» буквально «на котах»
... бо «просто кіт» і «хворий кіт» то є буквально аж на стільки 2 різні стани «кота» що розглядати їх однаково нема жодного сенсу як то буквально просто сам по собі сам стан «хворий кіт» у випадку «просто кіт» просто відсутній як такий
... тож виходить «об’єкт» фактично може змінювати саме свій «тип» просто за свого існування
... чи може то за фактом «хворий кіт» виникає новий об’єкт вже іншого типу? а він наслідує «просто кота» чи ні? бо на вчили що «наслідування» то є «кіт та собака є тварини» але ні як собака може стати котом чи навпаки а потім повернутися знову на зад до «просто кота»
а от х.з. я ще тільки почав думати за це (років з 5 мабуть тому не обрахую по пам’яті) але досі не додумав як правильно треба і як взагалі треба і навіть як взагалі можна (ще)
відкрийте для себе en.wikipedia.org/wiki/Dependent_type
Круто! Нарешті на ДОУ дійсно цікаві технічні статті! ;)
Я чисто для себе переходжу в деяких скриптах автоматизації з Python на Rust і ось це «так, стоп, не всі можливі варіанти оброблені» як помилка при компіляції ніби то і напрягає в Rust, бо в Python на це можна забити, але ж розуміння, що краще прописати повну логіку роботи, ніж падати в рантаймі мені і подобається. Хоча часу на написання виходить більше. Бо треба якісніше продумувати як всі можливі ситуації так і структури даних.
В С++ статически компилируемые стейт машины на основе параметризации шаблонов делали еще лет 20 назад. Правда сообщения об ошибках тогда были в килобайты и невнятные, но сейчас вроде поправили.
Эта задача очень похожа на стейт-машину, но она не про стейт-машины. То, что внутреннее состояние системы тут можно свести к стейт-машине — это просто так совпало.
Если интересно, то en.wikipedia.org/wiki/Modern_C++_Design
Не очень понятно, что именно из (хорошего и эпохального) труда Александреску тут поможет :(
З чого би це, аналіз задачі що ви надали — це NFA тільки в якості API. Тобто API вашого програмного модуля не задає конкретного алгоритму з послідовностями дій, відповідно ви вимушені робити перевірки state вашого NFA безпосередньо в API, що звісно бажано зробити як найраніше особливо під час компіляції, тобто щоби це взагалі не впливало на runtime та при цьому так самоті користувачу API важко би було створити не коректний програмний код по реалізації конкретного алгоритму. Тобто метод відомий як декларативне мета програмування. А там вже різні методи і різних технологіях для цього є, шаблони і constexpr в С++, на цьому збудовано безліч фреймверків в Boost, зокрема практичні Spirit та Expressive.Також є два універсальні мета фреймверках старий MPL під С++ 03 та більш сучасний Hana під С++ 11+. додаткові прероцессори як то Aspect C++, або qmake у QT. Що в С++ із стандартом 20 додали концепти, які якраз вже дають можливість реалізувати описані вами рестрікшени якраз на рівні компіляції.
У Java є Aspect J, механізм Annotation preprocessor, що дозволяє обробляти ситуацію коли компілятор потрапляє на анотацію під час компілювання і т.п. на чому збудовані інструменти типу lombok та mapstruct, jekson і т.д.
Досліди у мета програмування продовжуються, бо це дуже потужний абстракційний інструмент, суттєво підвищуючий виробничі можливості програміста зменшуючи час відладки — найдорожчий по часу і зусиллям процесс, який при цьому безкоштовний на рантаймі.
О, специфіка. Дуже дякую.
А не покажете, як це робиться для цього іграшкового прикладу?
Ну ви звісно в курсі, що це такий самий іграшковий приклад як і Велика Теорема Ферма (інакше би не писали статтю про фішки Haskel та Rust). Ця штука це породження проблеми на мійльярди долларів відомої як невірне використання malloc/free та їх аналогів в різних формах. Тим не мнеше от вже розумний AI написав Call Order Enforcer — тобто це можливо, хоч і таке собі. Є інші методи побудови API, Resource Acquisition Is Initialization, fluent interface і т.д.
От тут розумний AI накидав, приклад.
#include <concepts> #include <iostream> // Type-level representation of states struct NotInitialized {}; struct Initialized {}; // Concept to check if a type is Initialized template <typename T> concept InitializedState = std::is_same_v<T, Initialized>; template <typename State> struct CallOrderEnforcer { template <typename NextState = State> struct EnforcedCall { template <typename Func, typename... Args> requires InitializedState<State> // Constraint using requires clause auto operator()(Func&& func, Args&&... args) -> decltype(std::forward<Func>(func)(std::forward<Args>(args)...)) { return std::forward<Func>(func)(std::forward<Args>(args)...); } }; template <typename NextState> CallOrderEnforcer<NextState> transition() const { return CallOrderEnforcer<NextState>(); } EnforcedCall<> enforce() const{ return EnforcedCall<>(); } }; void initialize() { std::cout << "Initializing...\n"; } void foo() { std::cout << "Doing foo...\n"; } void bar() { std::cout << "Doing bar...\n"; } int main() { CallOrderEnforcer<NotInitialized> enforcer; auto initialized_enforcer = enforcer.transition<Initialized>(); // These will work: initialized_enforcer.enforce()(initialize); initialized_enforcer.enforce()(foo); initialized_enforcer.enforce()(bar); // These will produce compile-time errors (with better error messages): // enforcer.enforce()(foo); // Error: Constraints not satisfied // enforcer.enforce()(bar); // Error: Constraints not satisfied // enforcer.enforce()(initialize); // Error: Constraints not satisfied CallOrderEnforcer<NotInitialized> another_enforcer; auto initialized_another_enforcer = another_enforcer.transition<Initialized>(); initialized_another_enforcer.enforce()(initialize); initialized_another_enforcer.enforce()(foo); initialized_another_enforcer.enforce()(bar); return 0; }Дякую за код!
Як я бачу, тут не можна просто брати і використовувати
fooіbarяк звичані функції, їх треба обов’язково загортати вenforcer. Як ви правильно написали, «таке собі» :)Так і у Haskell будє синтаксичний офрехед, щоби отримати перевірку під час компіляції.
{-# LANGUAGE GADTs #-} data State = NotInitialized | Initialized data CallOrderEnforcer s where NotInit :: CallOrderEnforcer NotInitialized IsInit :: CallOrderEnforcer Initialized initialize :: IO () -> CallOrderEnforcer NotInitialized -> IO (CallOrderEnforcer Initialized) initialize f _ = do f return IsInit class Enforceable s m where enforce :: m a -> CallOrderEnforcer s -> m (CallOrderEnforcer s) instance Monad m => Enforceable Initialized m where enforce action _ = action >> return IsInit initialize_fn :: IO () initialize_fn = putStrLn "Initializing..." foo :: IO () foo = putStrLn "Doing foo..." bar :: IO () bar = putStrLn "Doing bar..." main :: IO () main = do let enforcer = NotInit initialized_enforcer <- initialize initialize_fn enforcer _ <- enforce foo initialized_enforcer _ <- enforce bar initialized_enforcer -- The following will produce a type error: -- enforce foo enforcerІ у OCamlВоно не сентакчисний сахар, тобто це плата за перевірку під час компіляції. Не краще справи у
D
import std.stdio; import std.traits; struct NotInitialized {} struct Initialized {} struct CallOrderEnforcer(State) { alias State thisState; auto initialize(alias F)() if (is(State == NotInitialized)) { F(); return CallOrderEnforcer!(Initialized)(); } void enforce(alias F)() if (is(State == Initialized)) { F(); } } void initialize_fn() { writeln("Initializing..."); } void foo() { writeln("Doing foo..."); } void bar() { writeln("Doing bar..."); } void main() { auto enforcer = CallOrderEnforcer!(NotInitialized)(); auto initialized_enforcer = enforcer.initialize!initialize_fn(); initialized_enforcer.enforce!foo(); initialized_enforcer.enforce!bar(); // The following will not compile: // enforcer.enforce!foo(); // Error: enforce is not a member of CallOrderEnforcer!(NotInitialized) auto enforcer2 = CallOrderEnforcer!(NotInitialized)(); auto initialized_enforcer2 = enforcer2.initialize!initialize_fn(); initialized_enforcer2.enforce!foo(); initialized_enforcer2.enforce!bar(); }Scalasealed trait State object NotInitialized extends State object Initialized extends State case class CallOrderEnforcer[S <: State](private val dummy: Unit = ()) { def initialize[U](f: => U): CallOrderEnforcer[Initialized.type] = { f CallOrderEnforcer[Initialized.type]() } def enforce[U](f: => U)(implicit ev: S =:= Initialized.type): U = { f } } object Main extends App { def initialize_fn(): Unit = println("Initializing...") def foo(): Unit = println("Doing foo...") def bar(): Unit = println("Doing bar...") val enforcer = CallOrderEnforcer[NotInitialized.type]() val initializedEnforcer = enforcer.initialize(initialize_fn()) initializedEnforcer.enforce(foo()) initializedEnforcer.enforce(bar()) // The following will not compile: // enforcer.enforce(foo()) // Error: Cannot prove that NotInitialized.type =:= Initialized.type val enforcer2 = CallOrderEnforcer[NotInitialized.type]() val initializedEnforcer2 = enforcer2.initialize(initialize_fn()) initializedEnforcer2.enforce(foo()) initializedEnforcer2.enforce(bar()) }та Ruststruct NotInitialized; struct Initialized; struct CallOrderEnforcer<State> { _state: std::marker::PhantomData<State>, } impl CallOrderEnforcer<NotInitialized> { fn initialize<F>(f: F) -> CallOrderEnforcer<Initialized> where F: FnOnce(), { f(); CallOrderEnforcer { _state: std::marker::PhantomData, } } } impl CallOrderEnforcer<Initialized> { fn enforce<F>(f: F) where F: FnOnce(), { f(); } } fn initialize_fn() { println!("Initializing..."); } fn foo() { println!("Doing foo..."); } fn bar() { println!("Doing bar..."); } fn main() { let enforcer = CallOrderEnforcer::<NotInitialized> { _state: std::marker::PhantomData, }; let initialized_enforcer = enforcer.initialize(initialize_fn); initialized_enforcer.enforce(foo); initialized_enforcer.enforce(bar); // The following will not compile: // enforcer.enforce(foo); // Error: No method named `enforce` found for struct `CallOrderEnforcer<NotInitialized>` let enforcer2 = CallOrderEnforcer::<NotInitialized> { _state: std::marker::PhantomData, }; let initialized_enforcer2 = enforcer2.initialize(initialize_fn); initialized_enforcer2.enforce(foo); initialized_enforcer2.enforce(bar); }«Таке собі» не тому що додатковий код, а тому що у підходу багато пересторог, наприклад це працюватиме лише в рамках одного модуля тарнсляції, обламається на вкладених функціяї і і т.д. Для таких штук треба більш розвинені інструменти в мовах програмування і в ідеалі інтеграція AI в засоби розробки. Це вже перехід на мови над високого рівня, певним чином.Це як на мене дуже забагато неідіоматичного коду, для Haskell та OCaml. Ось Haskell і OCaml варіанти ваших прикладів, де ніякої обгортки не треба, і всі функції — лише звичайні функції.
Haskell:
{-# LANGUAGE DataKinds #-} data InitState = NotInitialized | Initialized data State (initState::InitState) where State :: State initState not_initialized :: () -> IO (State NotInitialized) not_initialized () = return State initialize_fn :: State NotInitialized -> IO (State Initialized) initialize_fn _ = do putStrLn "Initializing..." return State foo :: State Initialized -> IO (State Initialized) foo _ = do putStrLn "Doing foo..." return State bar :: State Initialized -> IO (State Initialized) bar _ = do putStrLn "Doing bar..." return State main :: IO () main = do not_initialized_state <- not_initialized () initialized <- initialize_fn not_initialized_state state <- foo initialized state <- bar state -- The following will produce a type error: -- state <- foo not_initialized_state return ()І OCaml:
Ну і оскільки це просто звичайні функції, то
це працюватиме не лише в рамках одного модуля тарнсляції, не має ніяких проблем з вкладеними функціями і т.п.
Ви пишете: "Для таких штук треба більш розвинені інструменти в мовах програмуванняч. Мій message в тому, що цей велосипед вже винайдено, можна брати та їхати, нічого додаткового вигадувати не треба :)
За великим рахунком, так. Берем Java та додатковий інструмент AspectJ який глобально розповсюджений. Маємо от такий аспект
@Aspect public class CallOrderAspect { private static boolean isInitialized = false; @Pointcut("execution(* MyClass.initialize(..))") public void initializeCall() {} @Pointcut("execution(* MyClass.foo(..)) || execution(* MyClass.bar(..))") public void restrictedMethods() {} @Before("initializeCall()") public void beforeInitialize(JoinPoint jp) { isInitialized = true; } @Before("restrictedMethods() && !within(CallOrderAspect)") public void checkInitialization(JoinPoint jp) { if (!isInitialized) { throw new IllegalStateException("Must call initialize() before " + jp.getSignature().getName()); } } }От тестовий кодpublic class MyClass { public void initialize() { System.out.println("Initializing..."); } public void foo() { System.out.println("Doing foo..."); } public void bar() { System.out.println("Doing bar..."); } public static void main(String[] args) { MyClass obj = new MyClass(); obj.initialize(); obj.foo(); obj.bar(); // The following would cause an error (at compile time with AspectJ): // MyClass obj2 = new MyClass(); // obj2.foo(); } }Це усе не можна зробити, інтерпритаціних мовах, бо нема фази компіляції — та таж можна викорастати скажімо лінтер типу ESLint.Ага а ще про пітон і компіляцію. Про що ти автор взагалі?
Python — популярна ligua franca для прикладів (як я її і використав), яка не компілюється (про що я і зазначив). З іншого боку, мови, що компілюються, можуть робити певні перевірки на етапі компіляції, особливо якщо у них є розвинена type system (про що і стаття).
Такий TLDR простіше опанувати?
Ну ок є спец ліба у С++ у boost з метапрограмуванням. Але сенс? Якщо можна запрограмувати у вигляді таблиці/бітовими флагами і перевіряти в рантаймі але без бісової магії і купи бібліотечного коду
Бісова магія та купа коду — це в С++ так буде? Бо в моїх прикладах якось обійшлося без купи коду (і без бісової магії)...
Звісно, в рантаймі перевіряти можна (і так і роблять в 9 випадках із 10), але допис мій саме про те, що є способи робити це в compile time, і це може бути просто, і лаконічно, і не тягти за собою «бісову магію і купу коду», і взагалі — за кордонами С++ є життя, і воно непогане :)
С++ звісно. Типізація тут про магію шаблонів ... і кучу коду... хоч і переважно з бібліотеки
А що якщо результат виконання залежить від рантайм умов? Наприклад генерується рандомне число, і в залежності від цього числа викликається функція. У такому випадку на етапі компіляції програма не може знати порядок виклику функцій, бо конкретний порядок виклику буде визначений тільки під час виконання. Тобто компілятору теоретично треба буде розглянути всі варіанти виконання, щоб визначити які варіанти є некоректними. Це чимось схоже, на мою думку, на uk.wikipedia.org/wiki/Проблема_зупинки. Це я веду до того, що мені здається, що це неможливо зробити у загальному випадку
«Скажи мені, що ти не читав мій код, не кажучи, що ти не читав мій код» :) Саме це (використання random) є в одному з прикладів використання:
github.com/...aml/adept/main.ml#L29-L50
Компілятор може дійти висновку, що два шляхи виконання після random приводять до різних типів, і якщо вони повинні бути однакові (скажімо, це два бранча в if, або щось таке), то це буде помилка, як в наведеному мною прикладі вище
Ну... якщо в результаті виконання програми типи гарантують тобі, що шлюз відкритий, ти можеш його закрити. Якщо в результаті виконання програми типи не гарантують тобі цього, то тобі треба додатково вставити перевірку, чи відкритий шлюз, і вже у середині if/case/pattern matching у тебе будуть гарантії того, що шлюз відкритий, і ти зможеш його закрити.
Теоретично все супер, але є така штука як фізика і радіація у будь-який момент може зробити bit flip в пзу і програма може опинитись у невалідному стані, хоча наче і прописаний кінцевий автомат не дозволяє зайти у неправильний стан, тому обробка нештатних станів в космічному пз робиться інакше. Але для звичайного пз підхід доволі непоганий, хоча й доволі вербозний і при великій кількості станів складність коду зростає експоненціально
Тобто 1 строчка на одну Api фунцію то забагато, і треба було менше? :)
Наскільки я розумію — ніхто ж вам не забороняє робити ще й рантайм-перевірки. Але сам факт повідомлень від компілятора дозволить вам краще розуміти, що деякі кейси не покриті і, скоріше за все — які саме. Бо в більш складних випадках це можуть бути геть не зрозумілі на перший погляд речі.
розумний компілятор може зіграти злий жарт — він побачить що гіпотетично код недосяжний і тупо викине обробку нештатних ситуацій тому насправді мені було б цікаво почитати як пишуть програми що можуть протистояти радіаційному впливу на оперативку
По-перше, жодного жарту, а ні злого, а ні доброго компілятор зіграти, принаймні сучасний, без ШІ, не може. Він робить виключно те, що в нього закладено. І в принципі не бачу сенсу додавати можливості по розширеному аналізу та передбаченню на етапі компіляції, щоб прибирати це потім з коду. Принаймні, ніхто не заважає зробити це опцією, щоб програміст міг обрати — чи робити таку оптимізацію чи ні.
По-друге, вже існують радіаційно стійки процесори з дублюванням апаратних операцій.