State Machine: як швидко і зручно організувати код

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт, я Дмитро Хаджанов, Front-End developer в компанії MOJAM з 10 роками роботи в IT. На моєму досвіді State Machine — один з найпростіших, але найефективніших інструментів організації коду в компонентах. Але попри це я не можу стверджувати, що інструмент популярний в Front-End спільноті.

Я брав участь в розробці щонайменше п’яти великих проєктів, і лише в одному бачив його систематичне використання. Частка розробників, з якими я спілкувався, і котрі мали такий досвід, також залишає бажати кращого. Тож ціллю статті є розповісти більше про State Machine і поширити знання, які будуть корисними для його практичного застосування.

Що ж таке State Machine

Як не дивно, це саме той випадок, коли нам для розуміння концепції можуть знадобитись знання математики. Основою цього інструменту є теорія скінченних автоматів. Вона описує всі можливі стани системи та переходи між ними. При цьому система може бути будь-якої складності: від фізичних явищ до інженерних засобів і програмного забезпечення.

Для зображення такої системи часто малюють діаграму станів. Вершини, вони ж «стани», позначаються овалом або прямокутником. Ребра, вони ж «переходи між станами», позначаються стрілочками. В принципі цього вже достатньо, але також можна використовувати правила UML State Machine Diagram.

Як приклад можна зобразити діаграму станів переходу води в різні агрегатні стани:

Хоча ця діаграма і неточна, вона показує декілька основних правил:

  1. Вода може мати тільки три агрегатні стани.
  2. Перехід між станами відбувається тільки при виконанні певних дій/умов.
  3. Більше жодних переходів між станами не існує, тобто вода не може з льоду відразу стати паром, не перетворившись на воду.

Основне і найцінніше, що можна винести з цієї схеми — одночасно вода може перебувати лише в одному стані. Цей стан, незалежно від того, як він зберігається (в пам’яті системи чи у вигляді фізичного явища) є єдиним джерелом істини, на яке ми можемо посилатись.

Не зайвим буде зазначити, що існує GoF-патерн state, що базується на цій самій ідеї, але інкапсулює в об’єктах станів корисні дані\методи, унікальні для кожного стану. Тобто State Machine перш за все концентрує цю ідею. Прочитати детальніше можна на ресурсі guru refactoring. Пізніше ми розберемо приклад застосування цього патерну в контексті вебзастосунку.

Застосування State Machine на практиці

Тож яким чином ці принципи можуть допомоги в розробці вебзастосунків? Для прикладу можемо розглянути типову сторінку каталогу продуктів. Спершу здається, що там небагато станів. Одразу можна виділити стани «завантаження», «відображення»... можливо ще й «помилки». І найтиповіше рішення, яке імплементує більшість розробників — просто створити декілька флагів.

Розглянемо приклад (на Vue, але це не принципово):

<template>
  <div class="list-container">
    <div class="list" v-if="products.length > 0">
      <Product v-for="product in products" :product="product" />
    </div>
    <Loader v-if="isLoading" />
  </div>
</template>


<script>
  const isLoading = ref(true)
  const products = ref([])


  onMounted(async () => {
    try {
      products.value = await getProducts()
    } catch (e) {
      alert(e.message)
    } finally {
      isLoading = false
    }
  })
</script>

Можемо бачити досить просту логіку відображення компонента і завантаження даних. Проте вже є на що звернути увагу:

  1. Компонент по факту може перебувати в декількох станах одночасно, тому що за це відповідають різні флаги/дані. Для прикладу досить можливо, що в майбутньому, хтось змінить код — і в нас виникне можливість отримати і масив продуктів, і лоадер.
  2. В нас декілька джерел істини, які розробник контролює вручну. При розширенні компонента є велика ймовірність припуститися помилки.
  3. Недопрацьований флоу ініціалізації. По суті, все, що буде бачити користувач, — лоадер на білому фоні.

Попри те, що в існуючому варіанті є багато недоліків, вони ще яскравіше проявляються з часом при розширенні компонента. Давайте для прикладу розширемо компонент, скажімо, додамо кілька дрібниць:

  1. Якщо компонент завантажується вперше (ініціалізується):
    1. Замість лоадера можна показувати текст «дані завантажуються».
    2. Якщо під час завантаження сталась помилка — показуємо її замість тексту «дані завантажуються» з кнопкою «повторити спробу».
  2. Додамо фільтрацію.
    1. Під час фільтрації з’являється лоадер.
    2. Якщо у результаті фільтрації немає продуктів для демонстрації — показуємо текст «немає продуктів за заданим фільтром» і кнопку «скинути фільтри».
    3. Якщо під час фільтрації сталась помилка — показуємо алерт.

Після внесення змін у нас буде щось типу такого:

<template>
  <div class="list-container">
    <div class="list" v-if="products.length > 0">
      <Product v-for="product in products" :product="product" />
    </div>
    <Loader v-if="isLoading && !isIniting" />
    <InitingMessage v-if="isLoading && isIniting" />
    <Filters v-if="!isIniting" :disabled="isLoading" />
    <ErrorMessage v-if="errorMessage.length > 0 && isIniting">{{errorMessage}}</ErrorMessage>
  </div>
</template>


<script>
  const isLoading = ref(false)
  const isIniting = ref(true)

  const errorMessage = ref('')

  const products = ref([])
  const filters = ref({})

  const loadProducts = async (filters) => {
    try {
      isLoading.value = true

      products.value = await getProducts(filters)
    } finnaly {
      isLoading.value = false
    }    
  }

  const filter = async () => {
    try {
      await loadProducts(filters.value)
    } catch (e) {
      alert(e)
    }
  }

  const init = async () => {
    try {
      errorMessage.value = ''

      await loadProducts()
     
      isIniting.value = false
    } catch (e) {
      errorMessage.value = e.message
    }
  }

  onMounted(init)
</script>

Прошу звернути увагу, наскільки в цьому прикладі стало важче контролювати й передбачати стан компонента. Я намагався організувати код якнайкраще, але перевірки в темплейті все одно надзвичайно ускладнилися. До того ж якщо в компоненті будуть продукти та флаг isIniting буде в положенні true, то компонент зовсім зламається і поводитиметься непередбачувано.

Як я писав в теоретичній частині, такі проблеми виникають через велику кількість джерел істини. Ми «обмазуємось» флагами станів, які мусимо контролювати в кожній функції, що виконує якусь дію. Цими флагами стає «обмазаний» код, і чим довше існує компонент, тим складніше стає його контролювати.

Можемо спершу використати одне джерело істини як просте рішення, від якого і буде залежати стан компонента:

<template>
  <div class="list-container">
    <div v-if="state === State.Init">Loading will start in a moment</div>
    <div v-if="state === State.Ready" class="list">
      <Product v-for="product in products" :product="product" />
    </div>
    <Loader v-if="state === State.Loading" />
    <InitingMessage v-if="state === State.Initing" />
    <Filters v-if="[State.Initing, State.Ready, State.Loading].includes(state)" :disabled="state === State.Loading" />
    <ErrorMessage v-if="state === State.InitingError">{{ errorMessage || 'Something went wrong'}}</ErrorMessage>
  </div>
</template>

<script>
  enum State {
    Init,
    Initing,
    InitingError,
    Ready,
    Loading
  }

  const errorMessage = ref('')

  const state = ref(State.Init)

  const products = ref([])
  const filters = ref({})

  const loadProducts = async (filters) => {
    products.value = await getProducts(filters)
  }

  const filter = async () => {
    try {
      state.value = State.Loading

      await loadProducts(filters.value)
    } catch (e) {
      alert(e)
    } finish {
      state.value = State.Ready
    }
  }

  const init = async () => {
    try {
      state.value = State.isIniting 
      errorMessage.value = ''

      await loadProducts()
     
      state.value = State.Ready
    } catch (e) {
      state.value = State.InitingError
      errorMessage.value = e.message
    }
  }

  onMounted(init)
</script>

Як можна побачити, тепер в структурі компонента немає неочевидних умов. Компонент має перелік станів, в яких може перебувати, і кожен стан чітко описаний. Якщо використати старий-добрий патерн «модуль», то можна трішки «причесати» подібне рішення:

<div class="list-container">
  <div v-if="state.is(State.Init)">Loading will start in a moment</div>
</div>

// -------------------

const state = (() => {
  const currentState = ref(State.Init)

  return {
    go(to: State) { currentState.value = to },
    is(check: State) { currentState.value === check }
  }
})()

const filter = async () => {
  try {
    state.go(State.Loading)

    await loadProducts(filters.value)
  } catch (e) {
    alert(e)
  } finish {
    state.go(State.Ready)
  }
}

І це варіант, який я наполегливо рекомендую використовувати в більшості випадків. Дуже простий код, передбачуваний, надзвичайно читабельний, легко дебажити й підтримувати. Але це буде працювати лише для невеликих компонентів. Можливо, для якоїсь таблиці, форми, максимум — невеликої сторінки.

Це зумовлено тим, що стани контролює розробник ззовні, а не машина зсередини. Через це у нас немає механізму, що перешкоджатиме некоректним переходам станів. Тобто, якщо зобразити поточні стани компонента і їх можливі переходи на діаграмі, ми матимемо щось типу:

Дуже нагадує малюнок з моєї попередньої статті «навіщо нам DI», і це не дивно, адже причини ті самі, серед яких — порушення принципу інкапсуляції.

Змінити це можна, якщо передати керування станами напряму стейт-машині. Щоб це зробити, ми маємо повністю описати структуру нашого графа (його вершини й ребра), а також умови для переходу між ними. Для створення прикладу можемо використати одне з багатьох уже розроблених рішень: xstate, js-state-machine, typescript-fsm та інших. Але в нашому випадку ми будемо використовувати інтерфейс стейт-машини, яку я розробив сам (за деталями — прошу в особисті повідомлення). Лише зазначу, що я дуже рекомендую виносити такі штуки з компонента. На прикладі побачите, чому:

// component.vue

<template>
  <div class="list-container">
    <div v-if="stateMachine.is(State.Init)">Loading will start in a moment</div>
    <div v-if="stateMachine.is(State.Ready)" class="list">
      <Product v-for="product in products" :product="product" />
    </div>
    <Loader v-if="stateMachine.is(State.Loading)" />
    <InitingMessage v-if="stateMachine.is(State.Initing)" />
    <Filters v-if="stateMachine.is([State.Initing, State.Ready, State.Loading])" :disabled="stateMachine.is(State.Loading)" />
    <ErrorMessage v-if="stateMachine.is(State.InitingError)">{{ errorMessage || 'Something went wrong'}}</ErrorMessage>
  </div>
</template>

<script>
  const errorMessage = ref('')

  const products = ref([])
  const filters = ref({})

  const loadProducts = async (filters) => {
    products.value = await getProducts(filters)
  }

  const filter = () => {
    stateMachine.go(Transaction.Filter)
  }

  const init = () => {
    stateMachine.go(Transaction.Init)
  }

  const stateMachine = createStateMachine (
    loadProducts, // init
    () => loadProducts(filters.value), // filter
    (message) => errorMessage.value = message // setInitError
  ) 

  onMounted(init)
</script>
// component.state-machine.ts

import StateMachine from "my-state-machine"

export enum State {
  Init = 'Init',
  Initing = 'Initing',
  InitingError = 'InitingError',
  Ready = 'Ready',
  Loading = 'Loading'
}

export enum Transaction {
  Init = 'Init',
  InitFailed = 'InitFailed',
  InitSucceeded = 'InitSucceeded',
  Filter = 'Filter',
  FilterFailed = 'FilterFailed',
  FilterSucceeded = 'FilterSucceeded'
}

function createStateMachine (init, filter, setInitError) {
  const stateMachine = new StateMachine<State, Transaction>(State.Init)

  stateMachine.setTransitions([
    { name: Transaction.Init, from: State.Init, to: State.Initing },
    { name: Transaction.InitFailed, from: State.Initing, to: State.InitingError },
    { name: Transaction.InitSucceeded, from: State.Initing, to: State.Ready },
    { name: Transaction.ReInit, from: State.InitingError, to: State.Initing },

    { name: Transaction.Filter, from: State.Ready , to: State.Loading },
    { name: Transaction.FilterFailed, from: State.Loading, to: State.Ready },
    { name: Transaction.FilterSucceeded, from: State.Loading, to: State.Ready },
  ])

  stateMachine.setActions([
    {
      action: init,
      actionEdge: Transaction.Init,
      onErrorEdge: Transaction.InitFailed,
      onSuccessEdge: Transaction.InitSucceeded,
      onError: (e) => setInitError(e.message)
    },      
    {
      action: init,
      actionEdge: Transaction.ReInit,
      onErrorEdge: Transaction.InitFailed,
      onSuccessEdge: Transaction.InitSucceeded,
      onError: (e) => errorMessage.value = e.message
    },
    {
      action: filter,
      actionEdge: Transaction.Filter,
      onErrorEdge: Transaction.FilterFailed,
      onSuccessEdge: Transaction.FilterSucceeded,
      onError: (e) => alert(e)
    }
  ])

  stateMachine.beforeAll(() => setInitError(''))

  return stateMachine
}

Як можна побачити на прикладі, ми відділили логіку управління станами компонента від бізнес-логіки. Це зробило такі функції як filter, init значно більш читабельними і зрозумілими, а логіку зміни станів значно більш впорядкованою і контрольованою.

Більше не потрібно думати, який флаг ще потрібно проконтролювати при завантаженні продуктів! І, як результат, ми отримали щось на зразок:

Можна помітити, що в порівнянні з попереднім варіантом цей значно кращий з точки зору організації компонента, втім складніший для розуміння і підтримки. Це вибір, який завжди потрібно робити, ґрунтуючись на складності створюваного компоненту/сторінки/стору.

Але у світі Front-End розробки не буває все так просто. Всі готові рішення і моє в тому числі дають можливість маніпулювати стейт-машиною ззовні через низку причин. Одна з яких — гнучкість, що необхідна вебзастосункам. Скажімо, ваша система підвантажує продукти через сокети (так, приклад неординарний, але все ж). Тобто ви посилаєте запит на продукти до API, а вони вам потім надходять через сокети в міру завантаження. Це призводить до того, що ви не можете зсередини стейт-машини контролювати цей процес. І це нормально. Так, подібного краще уникати, втім це менше зло, яке служить для куди більшого добра 🙂

Виникає питання, якщо можна робити переходи ззовні, то що робити, коли з якихось причин перехід відбудеться неправильно? Тобто хтось зі стану Ready вирішить перейти до Initing. Цим питанням довгий час задавався і я сам.

З одного боку, в самописній стейт-машині (і такий функціонал є в багатьох) можливо передбачити стан, в який машина буде переходити при виникненні помилки. Як на мене, гарне рішення, втім це змушуватиме користувача перезавантажувати сторінку або ще якось втрачати свій прогрес.

З іншого боку, можна просто кидати сповіщення про те, що щось пішло не так. Аргументацією на користь такого рішення є те, що такі помилкові переходи часто виникають через описану вище асинхронність. І, так, користувач, можливо, втратить якусь інформацію, але загальний прогрес залишиться під контролем. Тоді вибір перезавантажувати сторінку чи ні буде робити сам користувач, а не ми за нього.

Втім, ніхто вам не заважає більш детально продумати ці сценарії і реалізувати рішення, що будуть по-різному себе поводити, залежно від контексту. Для прикладу, ось функція, яка є в моїй самописній стейт-машині (гадаю, її було б краще перейменувати) і могла б бути реалізована в коді:

stateMachine.onGeneralError((e)=>{
  if(e.type === StateMachineErrorType.TransactionError) { alert('Something went wrong') }
  else { stateMachine.goToErrorState() }
})

В цьому випадку ми переписали стандартну поведінку обробки помилок і зробили її більш адаптивною до контексту, в якому вона відбувається.

І останнє, але дуже важливе: при використанні стейт-машини потрібно уникати деяких речей:

  1. Не намагайтесь перейти між транзакціями зсередини машини самостійно. Є рішення, які це дозволяють, але це може призводити до найбільш неочікуваних результатів. Таким чином зменшується один з найбільших бенефітів стейт-машини.
  2. Не намагайтесь в одну стейт-машину засунути весь застосунок. Сказитесь 🙂 Тут точнісінько як з принципом розділення інтерфейсів: краще багато маленьких, ніж одна велика.

Епілог

Вебзастосунки в наші дні рідко можна назвати стабільними. Все частіше розробники надають перевагу швидкості реалізації, а не стабільності роботи. І це не критика з мого боку, це опис стану ринку і потреб бізнесу. Стейт-машина це досить дешевий спосіб зробити застосунок значно більш передбачуваним і продуманим з точки зору UX.

Як було показано вище, її можна використовувати в різних форматах залежно від потреб. Я дуже сподіваюсь що дана стаття хоча б трішки, але поширить цю практику у світі Front-End розробки. Якщо у вас є додаткові питання по темі загалом, або по конкретному кейсу — прошу в коментарі, або особисті повідомлення тут чи на LinkedIn.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

State machine diagram вже дуже давно використовується при описі вимог. Дозволяє візуалізувати складні текстові умови роботи системи.

100%))

У себе на роботі маю правило: кожну машину де більше 3х станів описувати діаграмою, а також закладати подібні діаграми в технічний дизайн. Настільки спрощує розуміння що я дивуюсь як десь можуть їх (діаграми) ігнорувати)

а есть правило сколько минимум нужно состояний и переходов, чтобы в принципе был смысл использовать стейт машину?
если бы кто-то, например, реально предложил пример из статьи реализовывать стейт машиной, я бы как минимум удивился

Я завжди дивувався: JavaScript це алгоритмічна мова, але чомусь замість АЛГОРИТМУ програмісти намагаються написати АВТОМАТ. Для мене це виглядає як писати на асемблері, коли є мова програмування Python.

А тепер практичне питання: чому усю цю логіку з опціями ви не можете записати ОДНІЄЮ функцією?

Ось ChatGPT це спростив. Може тут є помилки, але ідея зрозуміла.

async function run(mode: RunMode, nextFilters?: Record<string, any>) {
  errorMessage.value = ""

  // Move into the correct "busy" phase.
  phase.value = mode === "init" ? "initing" : "loading"

  try {
    if (nextFilters) filters.value = nextFilters
    products.value = await getProducts(filters.value)
    phase.value = "ready"
  } catch (e: unknown) {
    const message = e instanceof Error ? e.message : String(e)
    errorMessage.value = message
    phase.value = "error"
  }
}

Ну... по-перше це функція «run», що вона робить? Ініціалізує компонент? Зберігає данні? Завантажує данні?)) У кожної функції є власна ціль)
Ціль цієї функції, на скільки я бачу, об’єднувати в собі функціонал ініціалізації та підвантаження данних. Тобто у однієї функції дві зони відповідальності, що погано з будь-якої точки зору. Згодом ця функція покриється купою перевірок які будуть генерувати баги при роботі з цим компонентом.

По-друге він записує ті самі стани в

phase.value

. Тобто також оперує станами)
В мене в прикладах такий простий приклад також є) (тільки функції все ж розділені)

Ще треба ж розуміти що таких функцій буде декілька і в кожній потрібно це перевіряти. Також потрібно контролювати що б через помилку розробник не перевів компонент із стану «loading» в стан «initing», що досить можливо враховуючи, для прикладу, особливості рендеру реакт компонентів)
Для всього цього і існують стейт машини))

Тобто також оперує станами

Так, будь який компʼютер, який послідовно вирішує завдання оперує станами. Як тільки в памʼяті зʼявляється один біт даних — там зʼявляється стан. Але оперувати станами — це ще не будувати автомат станів. Станами можуть оперувати і алгоритми.

Коли алгоритм перетворюються на машину станів, то треба переконатися що:

1. Усі стани досяжні, тобто немає такого стану в який неможливо потрапити.
2. Що немає станів з яких неможливо вийти та потрібен reload сторінки, щоб почати знову.

Йдемо далі: дію в кожному стані треба перевірити. Переходи між станами треба також перевірити. Тобто треба тестувати! Автоматна модель не робить заміну тестуванню!

Якщо треба тестувати алгоритмічну модель та автоматну модель, але алгоритмічна — вона вдвічі простіша за кількістю рядків коду — то в чому виграш?

Кожному інструменту — своє місце.

Якщо у Вас завдання — зверстати кнопку з лоадером, то машину станів 100% городити не потрібно. Вистачить і флага.

Якщо завдання — зверстати якийсь компонент відображення списку, то підійде щось простеньке, типу списку станів і якогось js паттерну «модуль» для гарного інтерфейсу їх перемикання. Просто тому що вам наврядче потрібно ускладнювати цей процесс контролем переходів і т.д.

Якщо завдання — проконтролювати розгалуджений флоу по якому може йти користувач, тоді машина станів — саме те що потрібно. І тоді і самі раді будете перевіряти всі перераховані Вами ж умови)

В статті я також це описую. Подаю різні варіанти застосування)

Основна думка: возводити в абсолют якесь твердження типу «в js такому інструменту не місце» це дуже радикальна і неправильна думка, уж вибачте мою грубість. Ідеї на яких базується машина автоматів, так і саму машину автоматів, варто і потрібно використовувати, але лише там де це доцільно)

проконтролювати розгалуджений флоу по якому може йти користувач

А в вас є приклад показати машини станів для складних випадків?

В самому кінці статті. Я показав самописну версію стейт-машини як інструмента, але можна також використати і xstate.js.org або подібну. Це найбільше що мені доводилось використовувати.

Але Андрій в коментарях нижче підсвітив ще декілька нюансів які буде корисно дослідити: Composite states та взаємодію декількох паралельно існуючих, але впливаючих один на одного станів (для прикладу стан сторінки та стан мережі). Як приклад навів кейс з парсингом регекспів.

Це я хочу дослідити і потім написати другу статтю на цю тему, з більшим заглибленням в матеріал)

Але якщо Ви про сам прецидент.. типу чи потрібно було пилити щось де вона була б корисна — то так. На проекті над яким я зараз працюю я десь місяці 2 тому реалізував стейт-машину на 14 унікальних станів і 27 переходів. Не найбільша з тих що мені доводилось робити, але без неї я не уявляю ту кількість глобально доступних флажків якою потрібно було б обмазатись що б воно працювало)

Але показати не можу... попаду під НДА)

реалізував стейт-машину на 14 унікальних станів і 27 переходів
я не уявляю ту кількість глобально доступних флажків

Розрахувати «кількість флажків» мене навчили ще на першому курсі ф-та КІУ ХНУРЕ. Це залежить від обраного кодування станів, якщо one-shot, то це буде 14 бітів, але якщо звичайним кодом, то [log2(14)] = 4 біта. Це якщо б я синтезував цей автомат в залізо без мікропроцесора.

Але дякуючи високорівневій мові програмування JavaScript в мене буде рівно один «флажок» — це змінна state. А якщо брати TypeScript, то там було би ще лише 14 допустимих значень, це щоб невалідні стани заборонити ще до компіляції.

Тому що я ще раз підкреслюю: алгоритми теж можуть оперувати станами.

але впливаючих один на одного станів (для прикладу стан сторінки та стан мережі)
Це я хочу дослідити і потім написати другу статтю на цю тему, з більшим заглибленням в матеріал

Чи розглядали ви можливість реалізації (або може й реалізовували?) цю логіку на чистих функціях?

Що б таке реалізувати просто чисті функції не дуже підійдуть... тут потрібно буде агрегувати функції вищого порядку будуючі розгалуджену систему переходів де функція буде де-факто станом (ну.. так собі це уявляю).

Але виглядати то буде жахливо. Як відомо «код частіше читають ніж пишуть». Тому в моїх прикладах я робив акцент на інтерфейсі і описовій частині станів і переходів. Що б максимально спростити розуміння того що відбувається. Не впевнений що вдастся досягти такого ж рівня зрозумілості просто на функціях. Да і питання: для чого? Ви часто пишете про пам’ять, то це все буде для того що б декілька байтів ОЗУ користувача зберегти?)

то треба переконатися що:

1. Усі стани досяжні, тобто немає такого стану в який неможливо потрапити.
2. Що немає станів з яких неможливо вийти та потрібен reload сторінки, щоб почати знову.

І коли є опис саме як автомату, то існують засоби, які проводять аналіз на такі проблеми, а також на зайві стани (коли автомат можна спростити) — просто по опису автомату. І самому їх легко написати, теорія розвинена вже багато десятиліть.

А якщо замість цього формулювати дії системи процедурно — то таких засобів вже нема, і треба нагортати тести в явному вигляді. Причому: Щоб така функція дійсно могла працювати і не блокувати інших, вона має бути окремим актором. Наприклад, через async/await, чи в окремій нитці з обміном повідомленнями. Але тестувати її стає значно складніше, не можна у такого процеса описати всі сценарії. Тому, аналізується окремо логіка автомату, і окремо — реалізація, на відповідність цій логіці.

Якщо треба тестувати алгоритмічну модель та автоматну модель, але алгоритмічна — вона вдвічі простіша за кількістю рядків коду — то в чому виграш?

В легкости розуміння і тестування.

Я багато років розробляв SIP свитч. Там у протокола чотири рівні логіки автоматів (транспортний, транзакціонний, діалоговий, і сессіонний, і це ще якщо не згадувати специфіку кінцевих UA і всяке допоміжне), і зводити це в «алгоритмічну» модель абсолютно нереально. Реально — простіше за все, клас на обʼєкт автомату, стан як поля обʼєкту, хендлери як методи, або навіть як класи, які реалізують специфіку конкретного стана.

Ну... про одну функцію це трішки... утрировано, мабуть. У компонента є багато різних екшенів в різні часи його існування. Від ініціалізації до підвантаженні данних на скролі. Це все описувати однією функцією це досить дивно.
Але пишуть просто. Без зайвих структурних ускладнень. Але потім виходять інтерфейси які ми бачимо на сайтах: то лоадер завісне, то сторінка просто біла і роби що хочеш, то натиснув кнопку а ефекту 0. Таке трапляється коли розробник пише все на «флагах» і в кожному екшені з десяток перевірок. Це важно контролювати, важко підтримувати.
JS (а тим більше TS) дає інструменти за допомогою яких можна зберігати стани та розбивати великий алгоритм на менші, і використовувати їх в залежності від стану. А якщо це ще й описувати за домопогою зручного інтерфейсу якогось інструменту.... це значно підвищить якість пропрацювання компонентів і покриє всі корнер-кейси )

Ви так говорите про Асемблер, нібито це щось погане. )

P.S> «Томущо» («патамушто»). )))

Ооо, я обожнюю асемблер та досі на ньому пишу (гру під ZX Spectrum). Тут його згадую більше як метафору. :)

А тепер практичне питання: чому усю цю логіку з опціями ви не можете записати ОДНІЄЮ функцією?

Не про випадок Javascript, може (хоча, може, і про нього — залежить від сайту/сторінки). Уявіть собі, що таких автоматів одночасно треба декілька тисяч. Наприклад, на моїй попередній роботі це було по одному автомату на активний дзвінок, і ще на зʼєднання і розʼєднання аж на два рівні кожний. А одночасних дзвінків — багато.

Це можливо, звісно, при підтримці async/await в мові. В Javascript воно вже є, порівняно з віком самої мови, не так давно, і комбінацією таких викликів під промісами можна зробити імперативний вигляд. Але навіть у цьому випадку деякі автомати можуть бути дуже складні і їх запис у процедурному форматі виглядатиме недоречно. Наприклад, може бути відкат на попередній стан для переузгодження параметрів. Що, викликати goto? Ви отримаєте цикл навколо if(!complete), той же самий автомат.

Я не фронт, але стаття гарна, дякую

З просторів
коли зелений був, теж упоровся для статусів однієї штуки в стейт машину. Ходять легенди, що там досі т***я з нею, і при тому випиляти страшно. Не говорячи ще, скільки воно мені крові попило в ті часи.

Ну... певно якщо випити за раз літрів 5 води, то померти можна))

Я це до того що Ви праві, якщо щось реалізувати криво або не до місця, то це буде спричиняти більше проблем ніж давати користі. Але казати що стейт-машина зло за замовчуванням бо був негативний досвід, то просто зворотній кінець тієї самої палки.
Тому я надав декілька прикладів можливого застосування. Від простіших, до більш комплексних. Для того що б застосовувати не просто «інструмент», а «ідею» і робити це так що б вона приносила максимально користі.

В коментарях нижче Андрій поділився тим як можна застосовувати нові підходи в стейт-машині для ще більш складних кейсів. Можливо також буде цікаво\корисно ознайомитись)
Я ж планую вивчити і зробити другу частину статті)

Composite states (суперстани) значно спростили б першу діаграму: замість десятків стрілок до стану помилки можна згрупувати «нормальні» стани в один суперстан і мати один перехід на помилку.

А те, що пропонується в статті... Якщо спробувати застосувати такий підхід для парсингу навіть простого регекспа (який теж є машиною станів), це буде просто неможливо зрозуміти.

Про суперстани почитаю, дякую)

За парсинг регекспа, чи можу попросити розкрити тему? Хоча б тезисно?
Бо запропонований в статті підхід це те як в принципі працюють зі станами в світі фронтенду (навіть популярні бібліотеки), тому можливо приклад з парсингом це якийсь вузький приклад. Або ж є мінуси в запропонованій реалізації і я б був дуже вдячним за їх висвітлення)

Тезісно: є формальне визначення машини станів, є визначення алфавіту, ланцюга символів, мови. Є визначення регулярної мови (яка описується правилами регекспа), і є теорема, яка доводить, що будь-яка регулярна мова розпізнається кінцевим автоматом. Про це написано з доказами ще у Ахо, Ульмана в книжці 70-х років «Теорія синтаксичного аналізу, перекладу та компіляції».

Тобто парсинг регекспів це класичне застосування машин станів, яке вивчається в курсі теорії автоматів. Ну і там у нас можуть бути легко сотні станів, важко уявити, як це буде виглядати в коді за запропонованим підходом.

Щодо мінусів запропонованої реалізації: основна проблема в тому, що вона губить структуру автомата. Для простих випадків це може працювати, але для складних систем (драйвери, протоколи, компілятори) це стає нечитабельним. Прості кінцеві автомати можна реалізовувати як завгодно. Проблема зі складними.

По теоремі дяку, почитаю. Потім напишу ще одну статтю з заглибленням у тему, якщо будуть домки з цього приводу)) (така собі робота над помилками)

В статті є декілька прикладів реалізації і фінальний — це, я вважав, вже досить достатня конструкція. В якій можна прописати повністтю весь автомат. Я вже не хочу нагліти, але буду вдячним за комент по цьому питанню... типу «чому стуктура автомата була загублена»? Тобто в чому саме на вашу думку є проблема з реалізацією? (ну окрім суперстанів)

P.S.
В статті не привів це, але екшн може повернути інший success state для реалізації розгалуджень.

  stateMachine.setTransitions([
    { name: Transaction.Init, from: State.Init, to: State.Initing },
    { name: Transaction.InitFailed, from: State.Initing, to: State.InitingError },
    { name: Transaction.InitSucceeded, from: State.Initing, to: State.Ready },
    { name: Transaction.ReInit, from: State.InitingError, to: State.Initing },

    { name: Transaction.Filter, from: State.Ready , to: State.Loading },
    { name: Transaction.FilterFailed, from: State.Loading, to: State.Ready },
    { name: Transaction.FilterSucceeded, from: State.Loading, to: State.Ready },
  ])

А тепер припустимо у нас 20+ станів, що дає 190+ потенційних переходів. Що там можна буде побачити? Легко знайти пропущений перехід? Легко зрозуміти структуру автомата?

Ну... можна розбити на різні кроки ініціалізації... але зрозумів. Мова за композиційні стани))
Добре, дякую за інформацію, піду обробляти і, думаю, згодом зроблю другу частину)
Буду радий і там зустрітись в коментарях)

Мова за композиційні стани))

Ну... скоріше навіть ніхто не робить акцент саме на станах у складних системах. Проблема в тому, що часто виникає комбінаторний вибух.

У тебе є Working з підстанами (Init, Initing, Ready, Loading) та окремий стан Error. Ніби-то гарно, але десь від замовника прилітає NetworkState (Offline, Reconnecting, Slow, Normal) і починається велике сумування, бо це не чотири окремі стани, а купа нових комбінацій. Комбінаторний вибух, яким неможливо керувати.

Тому зараз роблять більше акцент на подіях, які зазвичай більш постійні. Та маємо умовний об’єкт State з полями: isBad: bool, network: (Offline | Reconnecting | Slow | Normal), working: (Init | Initing | Ready | Loading). А вся логіка переноситься на події.

Наприклад, якщо взяти msquic, то маємо купу подій: CONNECTED, SHUTDOWN_INITIATED_BY_TRANSPORT, SHUTDOWN_INITIATED_BY_PEER, SHUTDOWN_COMPLETE, LOCAL_ADDRESS_CHANGED, PEER_ADDRESS_CHANGED, PEER_STREAM_STARTED, DATAGRAM_STATE_CHANGED, які можна якось хендлити.

А от якщо подивитися на стан
github.com/...​src/core/connection.h#L29
Бачимо досить багато незалежних прапорців, які можуть бути в різних комбінаціях, а ще щось з самого класу з’єднання впливає на обробку подій. Заплутатися раз плюнути, а таблиці переходів буде вибухом мозку. Тому або тулза, яка згенерує таблиці (регулярні вирази), або події наше все.

Дякую дуже за розгорнуту відповідь... чомусь цей приклад відразу мене наштовхнув на щось типу «океей... тобто нам потрібно крутити паттерн State + докручувати щось типу Мосту»)))

Хмм... а це цікаво... типу 2-3-4 паралельні актуальні стани...

Гадаю частину проблеми можна вирішити якщо генерувати сценарії попередньо. Маю на увазі якась подія «save» і під неї підбирається сценарій (стретегія) залежно від стану offline/online etc.

Також думка: що саме описують стани та чи не буде проблемою якщо в одній стейт-машині перевіряти стани іншої... ну що б не пхати все в одну. Бо по-логіці pffline/online це стани мережі, а не умовної сторінки «product».

Крч є над чим подумати, дяка)

P.S.
Стосовно мого прикладу — він певно покриває більше 90% всіх потреб типових проектів на фронтенді )
Це не виправдання що б не копати глибше... просто я думаю також в продовженні варто буде залишити посилання і на простіші реалізації. Бо оверінженірінг такий самий гріх (якщо не більший) за 10 булеан флагів в компоненті )

Стейт машина вважається проблемним патерном — його простіше використати там, де він нашкодить, ніж за призначенням.

Мені за купу років в ембедеді та хай лоаді (де ніби саме місце для стейт машин) вона знадобилася один раз — для обробки імпульсного набору в телефоні.

Йдуть розмикання й замикання контакту. Залежно часу, на котрий контакт розімкнений, це може бути:
— шум чи гармоніка від попереднього імпульса
— один з імпульсів, що кодують набрану цифру
— кнопка R (hook flash)
— юзер поклав слухавку

Себто, в даному випадку розмірність вхідних даних нижча, ніж вихідних — одна подія має купу значень, і яке з цих значень взяти — залежить від історії подій.

Оце і є пряме призначення стейт машини — де вона просто проситься, і без неї нормально не написати. Усі інші варіанти, що бачив — створюють страшний код, котрий можна зрозуміти лише якщо наставити десятки логів в усі методи, і запустити систему, щоб побачити в якій послідовності ті методи реально викликаються. Навіть без комбінаторного вибуху (одномірні стейт машини).

Ще бачив UI, зроблений через nested state machine. Але там кожен стейт-скрін мав десятки івент хендлерів, а саме стейт-машина полягала в тому, що скрін при певних подіях заміняв себе на чи створював над собою інший скрін.

Не чув що б стейт машина була проблемним паттерном. Можливо у Вас в команді він таким вважається, але точно не має такої репутації в цілому. Ба більше, в світі розробки мобільних додатків цей паттерн використовується доволі часто і це вважається нормою. Ба ще більше, є GoF паттерн state який описує зміну поведінки классу (компоненту) в залежності від його стану + зміну цих станів. Не стверджую, звісно, що це одней й теж, але абстрактно концепція виглядає досить схоже, чи не так?

Інший аргумент — погано можна спроектувати будь-яку систему... до прикладу pub-sub систему можна так спаклюжити що додаток в рандомні моменти часу буде крашитись через непрерирвну рекурсію, але ж це не означає що такі системи не мають права на існування, чи не так? Тоді б можна було відмовлятись взагалі від асинхронності, багатопоточності... та навіть роботи з датою XD

То ж, як я писав вже в коментарях: возводити в абсолют думку що стейт-машина це виключно зло — це крайність, яка скоріше свідчить про упереджене ставлення ніж про реальний стан справ. Навіть якщо у Вас був негативний досвід використання стейт-машини — це значить для мене лиш одне — хтось зміг її застосувати, наробив помилок і трішки виріс як розробник. Але, нажаль, не було нікого більш досвідченного поряд що б допомогти в налаштуванні цього інструменту.

Тому повторюсь: стейт-машина, як ідея, може бути адаптована під різні задачі (в статті я показав своє бачення такої адаптації), і при цьому возна значно покращує читабильність компонента в сучасних реаліях front-end фреймворків. Приклади я розбирав в статті, гадаю вони наочно це демонструють. Але готовий десь на просторах лінкедину списатись і в якомусь гугл-міт більш предметно подебатувати, якщо є бажання)

Не чув що б стейт машина була проблемним паттерном. Можливо у Вас в команді він таким вважається, але точно не має такої репутації в цілому.

Pattern-Oriented Software Architecture, vol. 5, pp. 6-7, 140-141.
Pattern-Oriented Software Architecture, vol. 4, p. 468.

Не стверджую, звісно, що це одней й теж, але абстрактно концепція виглядає досить схоже, чи не так?

Це одне й те ж.

То ж, як я писав вже в коментарях: возводити в абсолют думку що стейт-машина це виключно зло — це крайність, яка скоріше свідчить про упереджене ставлення ніж про реальний стан справ.

Не виключно зло, але патерн, котрий на практиці потрібен раз на 15 років, а зазвичай його пхають туди, де він шкодить.

Але готовий десь на просторах лінкедину списатись і в якомусь гугл-міт більш предметно подебатувати, якщо є бажання)

Думаю, корисніше була б дискусія в чаті, присвяченому архітектурі t.me/swarchua

За запрошення дякую :)

Та на моєму досвіді цей паттерн навпаки використовують злочинно мало)

Мабуть різний в нас досвід)

А така думка, що складність і є причина, чому його використовують мало, на думку не спадала? State machine існують майже з самого початку розвитку кібернетики: еквівалентність регулярних виразів до скінченних автоматів була доведена на початку 50-х. Тому виникає питання, чому за 70+ років так ніхто і не розгледів міць? При тому, що була нескінчена кількість спроб, це просто виявилося неефективним, за єдиним виключенням, коли сам автомат будується по його опису (регулярні вирази, обчислення сили руки в покері, тощо).

Ну а так, якщо у нас десять станів, то ніякої складності немає, можна використовувати будь-який підхід. Якщо їх більше 50, то ручками підтримувати таблиці той ще гемор. Ну а якщо після оптимізації мільйон станів? Тільки автоматично згенерований автомат.

Ну... якщо кожний із 100 станів замінити на комбінацію з 20-30 булевих флагів по типу «із лоадінг», «із реади» і т.д., то легше система в розумінні не стане, лише з’являться непередбачувані її стани)

Ну і знову ж, чому «ніхто не розгледів міць»? Повторюсь, є паттерн GoF State, стейт-машинап як інструмент є в великій варіативності в якості бібліотек під кожну мову програмування, а в деяких напрямках, таких як мобільна розробка, це взагалі розповсюджена практика. Тому розгледіли ще й як)

Але в світ front-end розробки часто попадають люди «з вулиці», які все вивчають на-ходу. Часто такі люди не знають подібних концепцій бо вони не закладені в інструменти фреймворку. І тому поширюю подібні знання)

Ну... якщо кожний із 100 станів замінити на комбінацію з 20-30 булевих флагів по типу «із лоадінг», «із реади» і т.д., то легше система в розумінні не стане, лише з’являться непередбачувані її стани)

По-перше, як раз стане. Тому що зазвичай як будуються FSM? Ми виписуємо всі можливі стани, а потім вже формальними методами оптимізуємо, наприклад, алгоритм Хопкрофта який працює через об’єднання еквівалентних станів. Це підкреслює що людина як раз починає з декомпозиції, тому що в процесі дуже складно відповісти на питання, чи якась комбінація прапорців це окремий стан, чи його можна поєднати з іншим?

Наприклад, для оцінки сили руки в покері (7 карт, Texas Hold`em) я на початку будую машину з сотнями мільйонів станів, а потім оптимізація зменшує їх до півмільйона.
По-друге, 20-30 = мільйон можливих комбінацій. Так, можливо валідних буде сотня, або кілька сотен. Але очима дуже легко пропустити стан, який потрібен. Не кажучи що підтримувати таблицю 100 (станів) × 10 (подій) для людини нереально — заплутаєшься.

По-третє, якщо невалідний стан все ж з’явився, то в event-based коді його простіше знайти та виправити локально, ніж розбиратися в роботі всього FSM. У флагах баг проявляється локально: «тут спрацював неправильний handler», і фікс теж локальний. У FSM треба розуміти весь граф переходів між станами, щоб зрозуміти чому система потрапила в цей стан, а потім зрозуміти, що для фіксу треба перебудувати більшість автомату.

Я дійсно по-іншому дивлюсь на FSM. Для мене кожен стан це не «комбінація флагів», а саме що унікальний стан в якому може перебувати компонент, з своєю унікальною поведінкою. Емулювати флагами це можливо, але тоді в кожному методі повинна бути величезна кількість перевірок, як і в відображенні (виводі данних) так і багато де ще. Нагадує код розробника якому заборонили користуватись поліморфізмом.

Ви постійно апелюєте до абстрактного надскладного прикладу і приходите к неправильному, як на мене, висновку: людині важко тримати в голові FSM на 100500 станів і переходів, а значить FSM це зло. Тобто не підходить до якогось конкретного утрированого прикладу — не підходить (майже) ніде.

По-перше в настільки складному кейсі з 100500 станів буде 100500 флагів і їх перевірок. Копирсатись в цьому я б ворогу не побажав. Особливо якщо це ще й event-driven система. Ситуація «тут пофіксив, там полізло» буде повторюватись регулярно. І не дай боже якщо доведеться в це все інтегрувати ще якусь додаткову логіку.

По-друге Ви самі кажите що є методи оптимізації FSM, як, наприклад, группування в Composite state. Що для мене свідчить про протилежне до вашого висновку — для складних систем FSM досить підходящий інструмент.
(btw, як Ви самі казали — для 10 станів, можна використовувати що завгодно. Отже досить універсальна штука виходить. Не в кожній дірці затичка, але спектр застосувань досить великий)

По-трете якщо важко візуалізовувати і відсклідковувати всі зв’язки в FSM, тоді чому не зробити документацію, якусь візуалізацію типу цієї: stately.ai/viz, або ж просто намалювати UML діаграму? (btw що простіше візуалізувати: машину станів, чи алгоритм з усіма тими перевірками?)

P.S.
Зараз перечитав свій комент, потім весь тред, і зрозумів абсурдність ситуації. Я намагаюсь довести що FSM це інструмент який існує і може бути корисним залежно від ситуації. Це, наче як, і так має бути зрозумілим. Я був затролений по-айтішному?)))

P.S.S.
Мені здається ми працюємо на різних мовах і над різними задачами. Ви не стикались з моїми, я з Вашими. Можливо тому я і не розумію до чого тут було докопуватись, а ви не розумієте чому я так волію «ускладнювати» систему.

Ви постійно апелюєте до абстрактного надскладного прикладу і приходите к неправильному, як на мене, висновку:

Для мене це не абстрактний, а конкретний приклад з мого проєкту ofsm. Задача проста: треба побудувати FSM для обчислення сили руки по 7 картам (Texas Hold’em). Очевидно, що стейтів буде стільки ж, скільки різних комбінацій карт. Кожна подія це нова карта. Очевидно, що для 6 карт в нас буде C(52, 6) ~ 20 млн. стейтів. Після чого нова карта (52 події) переведе нас в один з ~7000 стейтів, по одному на кожну силу руки. Це не абстрактна задача, це конкретна, на відміну від Initialiaing, Ready, ... що без конкретного домену досить абстрактно.

Так от, для людини набагато простіше оперувати зі структурою

struct poker_state {
   int card_count;
   int cards[6]
};
яка описує всі ці ~20 млн. стейтів, ніж з

int jums[20000000][52];

де кожному індексу відповідає якась комбінація карт.

По-перше в настільки складному кейсі з 100500 станів буде 100500 флагів і їх перевірок.

Ні, якщо прапорець це один біт, то N прапорців описують до 2^N станів. Для 100500 станів потрібно лише log_2 100500 ~ 17 прапорців. Я приводив код msquic, це було 30 прапорців, тобто з цим як раз дуже просто працювати.

На практиці кожна зовнішня подія впливає лише на певну кількість прапорців, щось лишається незмінним.

Що для мене свідчить про протилежне до вашого висновку — для складних систем FSM досить підходящий інструмент.

Звісно, можна взяти регулярні вирази, парсери От тільки таблиця переходів будується не людиною. Тобто їх готують по різному: є фаза компіляції, є фаза виконання.

По-трете якщо важко візуалізовувати і відсклідковувати всі зв’язки в FSM, тоді чому не зробити документацію, якусь візуалізацію типу цієї: stately.ai/viz, або ж просто намалювати UML діаграму? (btw що простіше візуалізувати: машину станів, чи алгоритм з усіма тими перевірками?)

Звісно алгоритм простіше, це лінійний код з розгалуженнями, який читається зверху вниз. Які там перевірки?

void on_data_received(packet) {
    if (has_error) return;
    if (!is_connected) return;
    
    process_data(packet);
    if (packet.is_last) {
        has_all_data = true;
    }
}

Прийшов пакет, викликався метод, відлагодили його, все локально і зрозуміло. У разі FSM просто буде виконаний перехід зі стану N у стан M, які треба буде декодувати: що означає стан N, чому перейшли в стан M, які ще переходи можливі, ..?

Та да, те що конкретний приклад не видуманий, звісно, все змінює))

Зрозумів, у Вас є травматичний досвід)
Цей ПТСР, який вселяє в Вас ірраціональну нелюбов до FSM я в коментарях бороти не зможу))

Можу лише спробувати відповісти Вам Вашим же методом:
А ось у мене на проекті була задача зробити флоу користувача в якому потрібно було показати послідовно 15 кроків з розгалудженнями в логіці, де кожен наступний крок залежав від попереднього. Хендлити це флагами — значить створювати якісь костилі із масивів зі станів якими пройшов користувач що б відображати йому потрібний.
Тому я вважаю що що-завгодно (окрім деяких випадків по типу лоадера на кнопці) треба обробляти виключно FSM. В коді взагалі немає бути перевірок, а boolean тип можна вважати антипатерном! В кожному проекті, в будь-якому контексті (за вийнятком рідких виключень), будь-яка дія користувача має бути описана виключно викликом переходу у FSM )

Все навпаки, я вивчав математичну теорію, пов’ящану з FSM, вирішував вправи, пропрацьовував докази теорем. І від цього я отримав багато задоволення. Я просто не вважаю, що автомати з п’ятьма станами мають якесь віднощення до FSM, як вміння додавати числах в межах десяти не має ніякого відношення до матеатики.

FSM це гарний інструмент оптимізації. Саме тому регулярні вирази працюють швидко та потребують O(1) пам’яті. Також можливо FSM діаграми мають ілюстративну цінність. FSM дозволяє верифікувати певні речі.

Але кожного разу коли FSM будується ручками, це як повертатися з паскалю на асемблер: більше уваги, більше дрібниць треба контролювати, важко рефакторити. Коли код простий, що ще якось можна підтримувати, але ніяких гарантій що це не вибухне і треба буде переписувати.

Про паттерни мені взагалі не цікаво розморвляти, я тут не спеціаліст, і для мене це просто маркетинг для консультантів, які намаються отримати більше клієнтів за більші гроші: дивиться що я знаю, покразити ваш проєкт як два байти переслати! Тому туди запихують усе що тільки можна.

більше уваги, більше дрібниць треба контролювати, важко рефакторити. Коли код

Частково поза топіком — а як відноситесь до декларативного програмування? (чи як сприймається)

Для мене це мартетінговий термін без чіткого визначення, тому я просто ігнорую це слово де його бачу :-)

В такому випадку можна взяти що-небудь що часто характеризують таким чином — щоб трохи ближче до чогось більш-менш масового з фреймворків — нп програмування з flutter і на противагу gtk (без його gtkbuilders), або оберіть на свій розсуд щось інше що назвали би декларативний підхід чи недекларативний.

Ок, SQL декларативний, бо в принципі один SQL запит, а виконувати його можна багатьма способами. Ок, тоді сучасний C++ -O3 дуже декларативний. Але ще кажуть Prolog декларативний. Але тут виконання програми чітко визначене, мова йде про то, що під капотом реалізований backtracking, який ми можемо використовувати. Тому декларативність скоріше що ми просто даємо правила для алгоритму backtracking. А ще кажуть Haskell декларативний, бо його синтаксис плюс лямбди (do-нотація) дозволяють позбутися багато боілерплейт кода. Плюс, кажуть, вітсутність змінних. Це все різні речі, які об’єднуються словом «декларативність».

Ну а flutter я не знаю, там, напевно, декларативність це ще щось інше.

Трохи інше цікавило — чи відбувається умовно mode switching сприйняття і як продумати/закодувати логіку задачі з тим чи іншим фреймворком який відповідним чином характеризують (в плані декларативності, імперативності, і т.д.) і значно менше нп від конкретного синтаксису. Для мене нп такий switching в процесі явно відчувається поки повністю не втягнувся в якусь задачу з фреймворком в основному з іншим підходом. І з такого ракурсу — з яким підходом комфортніше в цілому програмувати.

для мене це просто маркетинг для консультантів

Скоріше, інструменти у валізі сантехника або хірурга — в кожного своє вузьке призначення.

Або це як алгоритми чи структури даних — але для написання бізнес логіки.

От ви коли щось обраховуєте — то обираєте список, чи хеш мапу, чи щось більш екзотичне — залежно уявлення про те, які операції частіше відбуватимуться, скільки наявно пам’яті, чи впорядковане додавання елементів... І якщо правильно підібрати — то код буде швидкий та красивий.

Так само, коли пишеться бізнес-логіка — правильний патерн зробить код красивішим, ніж намагатися вирішити «в лоба». Люди вже стикалися з такою проблемою, як у вас, думали над нею, і знайшли гідне рішення. Чому його не перевикористати?

Я вважав State антипатерном, що призводить до страшного коду, доки не трапилася задача обробки сигналу з телефонної лінії. Там залежно довжини імпульсу може бути ехо від минулого перемикання, імпульсний набір номера, флеш, або просто юзер зняв чи поклав слухавку. І ехо може накладатися на інші події. Через State воно вирішується в 200 рядків С з 10 явними рукописними станами. А інакше я не придумав, як цю задачу робити. Просто умовами в одній функції воно було б ще заплутаніше.

Я не проти FSM, коли таблиця переходів будується автоматично та перевіряється. Я скоріше проти, коли її будують ручками.

Ну тут було простіше збудувати ручками, ніж придумувати якийсь інший метод інтерпретувати послідовність з замикань/розмикань контакту та тіків таймера.

Наприклад, для оцінки сили руки в покері (7 карт, Texas Hold`em) я на початку будую машину з сотнями мільйонів станів, а потім оптимізація зменшує їх до півмільйона.
По-друге, 20-30 = мільйон можливих комбінацій. Так, можливо валідних буде сотня, або кілька сотен. Але очима дуже легко пропустити стан, який потрібен. Не кажучи що підтримувати таблицю 100 (станів) × 10 (подій) для людини нереально — заплутаєшься.

но так нельзя делать. можно было взять тогда уже и на каждое состояние развернуть отдельный кластер с кучей приложений, как-то все это зароутить, а потом страдать и рассказывать как плох кубер, микросервисы

Так реально працює pcre під капотом. Бо для людини простіше написати регулярний вираз, ніж еквівалентну машину станів.

Для покера у мене реальний код. eval_texas_rank7_via_fsm7 це самий швидкий спосіб порахувати силу руки. Але знову, щоб створити цю texas_fsm_7 треба попотіти.

Як тоді треба робити? П’ять станів? Для такої елементарної задачі навіть goto підійде, чесно.

Так реально працює pcre під капотом

потому что это один из 3х возможных способов сделать движок для регулярок, но при чем тут твой покер?

Бо для людини простіше написати регулярний вираз, ніж еквівалентну машину станів.

ты же вроде недавно в другую сторону воевал, не? почему простота стала основным критерием для выбора? почему ты считаешь что человеку так проще, а не наоборот?

Для покера у мене реальний код. eval_texas_rank7_via_fsm7 це самий швидкий спосіб порахувати силу руки. Але знову, щоб створити цю texas_fsm_7 треба попотіти.

perfect hash будет быстрее =\ это дефолтное решение для таких вещей

Як тоді треба робити? П’ять станів? Для такої елементарної задачі навіть goto підійде, чесно.

сколько будет состояний не имеет значения, но будет странно увидеть конечный автомат, если очевидно что состояний буквально 2-3. критерии применения известны, мог бы и не спрашивать, но раз спрашиваешь, то: 1. когда есть четкие состояния и переходы 2. когда нужна предсказуемость 3. когда нужна высокая производительность 4 когда нужна безопасность 5. когда нужна расширяемость. Если какой-то из критериев не подходит, то вероятно целесообразность использования конечного автомата под вопросом

потому что это один из 3х возможных способов сделать движок для регулярок, но при чем тут твой покер?

Усі вони так чи інакше Finite Automata.

perfect hash будет быстрее =\ это дефолтное решение для таких вещей

Не думаю, по-перше це більша lookup таблиця C(52, 7) ~ 130M, два байти сила руки, вже 260M, майже в два рази більше. По-друге більше операцій, якщо брати комбінаторну систему счислення, то це буде

uint32_t perfect_hash_texas7(const card_t * const cards) {
    uint64_t mask = 0;
    mask |= (1ULL << cards[0]);
    mask |= (1ULL << cards[1]);
......    
    uint32_t index = 0;
    int pos;
    
    pos = 63 - __builtin_clzll(mask);
    index += binomial[pos][7];
    mask &= mask - 1;
... 
    pos = 63 - __builtin_clzll(mask);
    index += pos;
    
    return perfect_hash[index];
}

Важко сказати що там буде, мені не очевидно

Если какой-то из критериев не подходит, то вероятно целесообразность использования конечного автомата под вопросом

Ну... в нас дискусія, чи має сенс кодувати таблицю переходів руками. Ось код автора:

stateMachine.setTransitions([
    { name: Transaction.Init, from: State.Init, to: State.Initing },
    { name: Transaction.InitFailed, from: State.Initing, to: State.InitingError },
    { name: Transaction.InitSucceeded, from: State.Initing, to: State.Ready },
    { name: Transaction.ReInit, from: State.InitingError, to: State.Initing },

    { name: Transaction.Filter, from: State.Ready , to: State.Loading },
    { name: Transaction.FilterFailed, from: State.Loading, to: State.Ready },
    { name: Transaction.FilterSucceeded, from: State.Loading, to: State.Ready },
  ])

Важко назвати дискуссією. Я показав як можна впорядкувати стани в компонентах front-end фреймворків використовуючи стейт-машину (точніше її варіації в залежності від складності задачі). Вона досить непогано вписується і дійсно полегшує читання компонента. Ви почали сперечатись що стейт-машина не підходить, бо вона не підійшла для вирішення вашої задачі з покером. Я не знаю як на це реагувати, всі свої думки з цього приводу я вже озвучив. То ж, пожимаю плечима і йду далі.

Але дякую за декілька думок, особливо за composite state. Це було корисно, і це я занотував і вивчаю. Як і казав напишу продовження (другу частину) цієї статті. Можу там Вас зазначити, якщо бажаєте)

Тому виникає питання, чому за 70+ років так ніхто і не розгледів міць?

Чому це не розгледіли? Розгледіли. І застосовують, де треба.

Але мислити так складніше. Це вже наслідок того, як люди думають. Навіть при тому, що ми самі подійно-орієнтовні, ми це описуємо, і для себе і для інших, послідовностями.

Ну а якщо після оптимізації мільйон станів? Тільки автоматично згенерований автомат.

Ну так а послідовний код просто не реагує на те, на що не вказано реагувати. У компонента все зламалось, а ми продовжуємо спробу писати в нього дані.

Звісно, після такого спрощення писати легше;/

Це одне й те ж.

Доречі цікава Ваша думка. Мені видається що вони схожі ідеологічно, але все ж трохи різні. Основна відмінність, на мою думку, в наявності «моделі» під яку підлаштовуються стани.

В GoF паттерні є один основний класс. Умовна модель чогось. З якоюсь своєю поведінкою. Частина цієї поведінки делегується об’єкту стану. Об’єкт стану маючи посилання на нашу умовну «модель» має змогу змінювати стани як забажає, але всі класи (!) станів наслідують один інтерфейс. Тобто кожен клас стану має знати про наявні методи і вміти реагувати на їх виклик, навіть якщо йому це не потрібно. Тобто йде залежність інтерфейсу «станів» від потреб «моделі».
В тей самий час машина станів, як окремий інструмент, немає цієї умовної «моделі». Такий інструмент цілковито концентрує свою увагу на описі автомата і єдине на що він працює — актуальний стан.

Тобто і паттерн, і стейт-машина окремо, технічно роблять одне й теж, але в них різна структура і, що головне — залженості. Моя думка, але якщо в Вас є інше бачення — поділіться будь-ласка)

Тобто кожен клас стану має знати про наявні методи і вміти реагувати на їх виклик, навіть якщо йому це не потрібно.

Ні, зазвичай батьківський клас імплементить порожні методи чи асерти / ексепшни для усіх можливих типів подій. А дочірні класи перевизначають лише ті події, котрі вміють обробляти.

В тей самий час машина станів, як окремий інструмент, немає цієї умовної «моделі». Такий інструмент цілковито концентрує свою увагу на описі автомата і єдине на що він працює — актуальний стан.

Коли дійде до коду, то воно або перетвориться в патерн GoF, або в енум. Енум, здається, не схожий на те, що ви описали в статті як стейт машину. Відповідно, в рамках цієї статті стейт машина є синонімом State (GoF) / Objects for States (POSA4).

Взагалі, ось сайт про стейт машини та актори, але його автор не намагається їх описувати в явному виді — мабуть бо знає, на що це перетворюється в коді ithare.com/...​nd-finite-state-machines

А дочірні класи перевизначають лише ті події, котрі вміють обробляти

Ну, це ж не змінює суть мого твердження :)

В GoF паттерні «стани» реалізують поведінку необхідну «моделі». Тоді як в стейт-машині немає чого «реалізовувати». Тобто немає залежності на якусь конкретну специфікацію.

Коли дійде до коду, то воно або перетвориться в патерн GoF, або в енум.

Цікава точка зору... але гадаю тут дається в знаки різниця в мові програмування)
Що б на TS (ака JS) щось переросло в GoF паттерн.... я б радий був, але тут скоріше декілька глобальних функцій сторять, з такими ж глобальними пропами, і покладудь в папочку «helpers» XDD

Енум, здається, не схожий на те, що ви описали в статті як стейт машину.

В мене там в прикладах є буквально єнум з обернутий в геттер\сеттер модуль)) Втім я дав в статті декілька підходів до реалізації в залежності від контексту і завдання... і State як паттерн також згадав... тому частоково згоден)

Дякую за посилання, обов’язково ознайомлюсь)

В GoF паттерні «стани» реалізують поведінку необхідну «моделі». Тоді як в стейт-машині немає чого «реалізовувати». Тобто немає залежності на якусь конкретну специфікацію.

Стейт машина сама є підсистемою (або системою). Кожна (під-)система має визначений інтерфейс (бо інакше як нею користуватися або інтегрувати) та контракт. Останній є моделлю її поведінки.

Мені здається, що ви порівнюєте нутрощі системи (стейт машина в вашому розумінні) з системою в цілому (патерн).

Та наче порівнював нутрощі обох підходів... втім я Вашу думку зрозумів)

Дякую ще раз за корисні посилання, обов’язково ознайомлюсь (з чимось вже ознайомився) і, ймовірно, вони частково підуть в другу частину статті)

Та наче порівнював нутрощі обох підходів

Це не підходи.
Стейт машина — це математична модель. Її нема в коді.
В коді буде якась імплементація, через енуми, або через GoF State, або через інші пов’язані патерни з POSA, або просто купою змінних та умовами на них.

Мені за купу років в ембедеді та хай лоаді (де ніби саме місце для стейт машин) вона знадобилася один раз — для обробки імпульсного набору в телефоні.

Ну а у мене вони були на кожному рівні. Я так памʼятаю, ти SIP сам не писав, звалив на бібліотеку?

Так, поюзав готовий стек. А страшних стейт-машин надивився на першій роботі, і більше не хочу) www.c-jump.com/...​77_0050_state_diagram.htm

10 станів і помірно прості переходи — хіба це страшно?

Так, бо ніфіга не зрозуміло.

Мені здавалось, ця діаграмма досить ясна і проста.

Діаграма проста. Але коли потрапляєш в код з оцими стейт машинами, і в ньому щось глючить — то фіг це віддебажиш, бо нема єдиної точки входу щоб поставити бряку, не розумієш, який там стейт виставлений, на який він має змінитися, і воно ще в ріал-таймі десь вилітає за таймаутом під час демагу.

При дебагу все це робиться на логах. Якщо сценарій хоча б іноді відтворюваний, підняти рівень логування відповідної подсистеми — і всі дані присутні.

А в мене — один метод трансфера, котрий можна дебажити. Він дофіга заплутаний, але один.

Ну ось я не бачу в тому, що він один, жодної переваги.

Або ж якщо це тільки перегрупування ніг при вже успішному запиті на трансфер, там взагалі нема про що думати в плані автомата.

В мене більшість випадків трансфера — це два дзвінки всередині системи. Вони будуть успішними, якщо ці дзвінки можна змержити (наприклад, є спільний кодек).

Навпаки, коли треба відтрансферити два дзвінки з мережею — то там пару рядків коду, бо за нас усе зробить сервер, і відповість, усе ОК (тоді ми вб’ємо обидва дзвінки в себе) чи не ОК (тоді ми скажемо бі-біп, і дзвінки лишаться як були).

А вся логіка переноситься на події.

Я не зрозумів різницю.

Вона в тому, де групується код? По стовпчиках (події) чи рядках (стани)?

Ну давайте візьмемо SIP.

Там в ендпойнта дзвінка ланцюжок INVITE->100->180->200->RELEASE наскільки пам’ятаю, якщо грубо.

А тепер додаємо ресолв STUN. У нас на кожен з 5 меседжів вище є насупні стани: INVITE_STUN_RESOLVED, INVITE_STUN_RESOLVING, INVITE_STUN_FAILED. Ітого вже 15 станів.

А тепер можна ще додати стани TRANSFER — матимемо 200_STUN_RESOLVED_TRANSFER_INACTIVE, 200_STUN_RESOLVED_TRANSFER_INITIATED, 200_STUN_RESOLVED_TRANSFER_SUCCESSFUL,
200_STUN_RESOLVED_TRANSFER_FAILED... і розмножуємо це усе для ..._STUN_RELOVING, ..._STUN_FAILED etc.

Ось це і є комбінаторний вибух.

А в коді воно лежить окремими рівнями і кожен хендлиться в кілька рядків в одному спільному для всіх станів сесії місці:

if(!stunned) {
    wait_for_stun();
    return RETRY;
}
if(stun_failed)
    return ERROR_STUN_FAILED;
if(transfer)
    return ERROR_TRANFER_ONGOING;
Там в ендпойнта дзвінка ланцюжок INVITE->100->180->200->RELEASE наскільки пам’ятаю, якщо грубо.

Занадто грубо, бо ти поєднав процеси різних рівнів.

На рівні сесії це встановлення голосового каналу через діалог і його завершення.

На рівні діалогу це встановлення діалогу, підтримка, і завершення.

І лише на рівні транзакції ми кажемо або про INVITE-100-180-200, або про BYE-200, або про що ще там буде.

А тепер додаємо ресолв STUN.

Це інший логічний рівень, у якого свої автомати (якщо є).

А тепер можна ще додати стани TRANSFER

І це інший рівень. Я не розумію, як і для чого їх поєднувати.

А в коді воно лежить окремими рівнями

Код є у любому випадку. І окремими рівнями, да, лежить в різних автоматах.

if(stun_failed)
return ERROR_STUN_FAILED;

А яке відношення воно взагалі має до трансфера, стану транзакцій і діалогу?

І на подіях неможливо забезпечити реакцію як треба, якщо не розділяти рівні реакції, кожний з яких зроблений саме як автомат.

Так суть що ці рівні є вимірами стану системи. В складніших випадках вони починають взаємодіяти, як от:
— голос може під’єднуватися, бути під’єднаний, роз’єднуватися, роз’єднаний.
Ця фігня з голосом взаємодіє з окремими станами:
— під’єднання та роз’єднання кодеків, котрі самі взаємодіють з списками підтримуваних кодеків, котрі від слухавки можуть приходити на різних етапах, себто, залежать від стану з’єднання дзвінка.
— утримання та відновлення дзвінка нашою стороною (чергові 4 стани) та іншою стороною (ще 2 чи 4 стани).
— трансфер, конференція та перехват дзвінка — по кілька станів на кожен.
— встановлення та розрив дзвінка — ще до 10 станів.

От маємо з десяток взаємозалежних стейт-машин на рівні бізнес-логіки. Як їх описувати?

В нас не буває стану з під’єднаним голосом але не обраним кодеком. Як показати в моделі стейт-машини, що він неможливий?

Можу в телеграм скинути код з метода для call transfer. Не уявляю що б то було зі стейт машинами в явному вигляді, і по скільком методам була б розмазана логіка переносу дзвінка.

От маємо з десяток взаємозалежних стейт-машин на рівні бізнес-логіки. Як їх описувати?

Так і описувати. Як автомати зі звʼязками між їх входами і виходами.

В нас не буває стану з під’єднаним голосом але не обраним кодеком. Як показати в моделі стейт-машини, що він неможливий?

В моделі? Не знаю, не задумувався. Думаю, десь в надрах UML має бути щось на цю тему.

Але це здається дещо іншою темою.

Можу в телеграм скинути код з метода для call transfer. Не уявляю що б то було зі стейт машинами в явному вигляді, і по скільком методам була б розмазана логіка переносу дзвінка.

Я знаю, як у нас реалізований трансфер. Мені цього вистачає.

Код не кидатиму, але логіка в основі проста. Говоримо про blind transfer? Створюється обʼєкт запиту трансфера (один на дзвінок, через його відповідальний call controller), іде на авторизацію, а transferor ставиться на холд. Якщо авторизація пройшла, створюється підлеглий дзвінок у фазі створення на transfer target. Дозвон іде стандартними механізмами, але при успіху дозвону виконується операція перегрупування ніг. При неуспіху transferor знімається з холду.

Все це зараз на колбеках. Не скажу, що логіка проста, але максимум складности не в цьому сценарії по собі, а в організації білінгу і в тому, що наш b2bua намагається якомога більше виключити власне навантаження по transcoding/transrating, вимагаючи у сторін переузгодження кодеків, а це дуже плутаний емпіричний процес.

У вас серверна частина, а в мене — клієнтська. Там про транфері треба змержити два об’єкта дзвінків в один — бо слухавка, котра трансферить, бере участь в двох дзвінках. Обидва можуть бути внутрішніми (слухавка до слухавки), зовнішніми (слухавка до мережі) чи один — такий, інший — не такий.

От задача пошматувати ті об’єкти дзвінків так, щоб з залишків можна було зібрати щось консистентне, і в нього не побилися кодеки, історія, логування, ідентифікатори дзвінка на слухавках, відображення номеру та імені того, з ким абонент розмовляє... І блайнд чи ні — залежить від етапу, на котрому ми отримали запит від юзера. А запит — це просто кнопка * на слухавці. І ця ж кнопка може робити різні речі залежно стану системи.

У вас серверна частина, а в мене — клієнтська. Там про транфері треба змержити два об’єкта дзвінків в один — бо слухавка, котра трансферить, бере участь в двох дзвінках.

Attended transfer у нас теж є.

І всі підняти аспекти оброблюються.

Але якийсь автомат саме в цьому процесі виникає тільки там, де спочатку дзвінки були прийняті різними вузлами кластера.

Вибачте, але ні:
1)

і є теорема, яка доводить, що будь-яка регулярна мова розпізнається кінцевим автоматом.

2)

Тобто парсинг регекспів це класичне застосування машин станів,

Тут некоректний перехід.
З моменту, як в регекспах зʼявились backreferences (наприклад, (a+)b\1) і zero-width lookupahead/lookbehind, вони вже не вкладаються в регулярні мови, це вже не regular, а наступний рівень — context-free. А такі можливости є в майже всіх сучасних рушіях регекспів, навіть базовий POSIX вже вимагає такого. «Приїхали, малята.» І це вже я не кажу про NFA реалізації, де аж ніяк не можна спростити до автомату з одним станом, а таких теж багато.

Коли регекспи починались, вони починались у відповідности до теорії регулярних граматик. Але це скінчилось, в масі, десь в 1990-х.

4-рівнева класифікація Хомського — це не те, що зараз в реальности, це тільки початкова навчальна концепція, щоб від неї відштовхуватись. Це те ж саме як з 7-рівневою моделлю OSI — вона гарна, щоб зрозуміти звʼязки, але одразу ж треба казати, де в реальних протоколах ця схема порушується (майже всюди).

З моменту, як в регекспах зʼявились backreferences

Регулярний вираз це чітке математичне визначення з теорії формальних мов, яке не змінювалося. Але те, що в індустрії називають regexp (Perl, PCRE, Python re) воно плаває.

Backreferences виводять за межі регулярних мов і ламають гарантії ефективності: можливий експоненційний час через комбінаторний вибух варіантів. Через це вони є джерелом ReDoS-атак, і їх десь вклчають, десь не підтримують (Go, Rust) та гарантують O(n).

Але те, що в індустрії називають regexp (Perl, PCRE, Python re) воно плаває.

Ну ось я і кажу, що «регекспи» до регулярного виразу з математики зараз мають віддалене відношення.

Через це вони є джерелом ReDoS-атак, і їх десь вклчають, десь не підтримують (Go, Rust) та гарантують O(n).

Хмм, а іншим чином обмежувати не пробували? ;\
А то тупо відтяти сокирою воно, звісно, просто...

Математичного вирішення немає, і кожне розширення (backreferences, балансовані групи з .NET) треба аналізувати окремо. Так, ставиш regexp з backreference на правилі редиректу HTTP-серверу — отримуєш нескінченний парсінг. Дав запитів більше, ніж воркерів — підвісив сервіс. Можна налаштувати timeout, але гарантія O(n) може бути дуже корисною, а помилку незрозуміло як обробляти.

Знову диспут про термінологію. Є регекспи, є регекспи з розширеннями. Надалі, заради стислості, під терміном «регекспи» розумітимемо регекспи з розширеннями.

Що заважало зробити флаг компіляції regexpʼу? Як зараз присутні, зазвичай, ignore case, multiline. Якщо вже нема можливости надійно проконтролювати джерело (так буває?), то якщо такі небезпечні розширення вимкнені по дефолту, це менш насильно, ніж зовсім не давати.

Про термінологію — ну а якщо 95+% реалізує саме розширений варіант, дивно було б чекати іншої інтерпретації.

а помилку незрозуміло як обробляти.

Незрозуміло у якому сенсі?
У вас є правило, воно не спрацювало. Якщо це

на правилі редиректу HTTP-серверу

значить, 503 клієнту, і аларм кудись у моніторінг.

угу))

В статті є знадка цієї бібліотеки (і не тільки її) з посиланням)

Підписатись на коментарі