Як функціональне програмування обробляє ситуацію, коли на один і той же об'єкт посилається декілька місць?


10

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

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

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

Першим прикладом буде моя гра C #, яку я намагаюся зробити у вільний час . Це покрокова веб-гра, в якій обидва гравці мають команди по 4 монстри і можуть відправити монстра зі своєї команди на поле битви, де вона зіткнеться з монстром, надісланим противником. Гравці також можуть відкликати монстрів з поля бою та замінити їх іншим монстром зі своєї команди (подібно Покемону).

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

Тепер розглянемо ситуацію, коли одне чудовисько потрапило і втрачає 20 балів здоров’я. У дужках імперативної парадигми я модифікую healthполе цього монстра, щоб відобразити цю зміну - і це я зараз роблю. Однак це робить Monsterклас мінливим і пов'язані з ним функції (методи) нечистими, що, напевно, вважається поганою практикою на даний момент.

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

У функціональному стилі, наскільки я його розумію, я б замість цього зробив копію цього Monsterоб'єкта, зберігаючи його ідентичний старому, за винятком цього поля; і метод suffer_hitповерне цей новий об'єкт замість зміни старого на місце. Тоді я б також скопіював Battlefieldоб’єкт, зберігаючи всі його поля однакові, крім цього монстра.

Це виникає щонайменше з двома труднощами:

  1. Ієрархія може бути набагато глибшою, ніж цей спрощений приклад просто Battlefield-> Monster. Мені довелося б зробити таке копіювання всіх полів, крім одного, і повернути новий об’єкт аж до цієї ієрархії. Це був би код котла, який мені дратує, тим більше, що функціональне програмування передбачає зменшення котла.
  2. Набагато гострішою проблемою є те, що це призведе до того, що дані не синхронізуються . Польовий активний монстр побачив би його здоров'я; однак, цей самий монстр, на який посилається його контрольний гравець Team, не буде. Якби я замість цього застосував імперативний стиль, кожна модифікація даних була б миттєво помітна з усіх інших місць коду, і в таких випадках, як цей, мені здається, що це дуже зручно - але те, як я отримую речі, це саме те, що кажуть люди неправильно з імперативним стилем!
    • Тепер можна було б опікуватися цим питанням, здійснюючи подорож до наступної Teamатаки. Це зайва робота. Однак, що робити, якщо монстр може раптом пізніше посилатися з ще більшої кількості місць? Що робити, якщо я маю здібності, які, наприклад, дозволяють монстру зосередитись на іншому монстрі, який не обов'язково знаходиться на полі (я насправді розглядаю таку здатність)? Чи зможу чи я , звичайно , що не забудьте взяти також подорож в сфокусовані монстр миттєвого після кожної атаки? Здається, це бомба тимчасового вибуху, яка вибухне, коли код стане складнішим, тому я думаю, що це рішення не підлягає.

Ідея кращого рішення походить з мого другого прикладу, коли я потрапив у ту ж саму проблему. У академіях нам сказали написати перекладача мови власного дизайну в Haskell. (Це також я був змушений почати розуміти, що таке ПП). Проблема виявилася, коли я впроваджував закриття. Знову на той самий діапазон тепер можна посилатися з декількох місць: Через змінну, яка містить цю область і як батьківську область будь-яких вкладених областей! Очевидно, що якщо зміна в цій області здійснюється через будь-яку з посилань, що вказують на неї, ця зміна також має бути видимою через усі інші посилання.

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

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

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

Це ще раз є джерелом кодової панелі. Це робить однолінійні обов'язково 3-х вкладишами: те, що раніше було однорядковим на місці модифікацією одного поля, тепер вимагає (a) Вилучення об'єкта з центрального словника (b) Внесення змін (c) Збереження нового об'єкта до центрального словника. Крім того, зберігання ідентифікаторів об'єктів та центральних словників замість посилань збільшує складність. Оскільки FP рекламується для зменшення складності та кодового коду, це натякає на те, що я роблю це неправильно.

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

Однак я вчасно з’ясував, що це швидше здається вирішеною проблемою. Java передбачає, WeakHashMapщо можна було б вирішити цю проблему. C # надає аналогічну програму - ConditionalWeakTable- хоча згідно з документами вона призначена для використання компіляторами. І в Haskell у нас є System.Mem.Weak .

Чи зберігання таких словників є правильним функціональним рішенням цієї проблеми чи є більш просте, яке я не бачу? Я думаю, що кількість таких словників може легко зростати і погано; тому якщо ці диктатури також повинні бути непорушними, це може означати багато параметрів або, мов, що це підтримують, монадичних обчислень, оскільки словники проводитимуться в монадах (але ще раз я читаю, що це суто функціонально Мови якомога менше коду повинні бути монадичними, тоді як це рішення словника розмістить майже весь код всередині Stateмонади; це ще раз змушує мене сумніватися, чи це правильне рішення.)

Після деякого розгляду, я думаю, я би додав ще одне запитання: що ми отримуємо, будуючи такі словники? Те, що не так з імперативним програмуванням, на думку багатьох експертів, те, що зміни в деяких об'єктах поширюються на інші фрагменти коду. Для вирішення цього питання об'єкти повинні бути незмінними - саме тому, якщо я правильно розумію, що зміни, внесені до них, не повинні бути помітні в іншому місці. Але зараз я стурбований іншими фрагментами коду, що працює над застарілими даними, тому я вигадую центральні словники, щоб ... вкотре зміни деяких фрагментів коду поширюються на інші фрагменти коду! Чи не повертаємось ми до імперативного стилю з усіма його передбачуваними недоліками, але з додатковою складністю?


6
Щоб надати цьому певну перспективу, функціональні незмінні програми здебільшого призначаються для ситуацій з обробкою даних, пов'язаних з одночасністю. Іншими словами, програми, які обробляють вхідні дані за допомогою набору рівнянь або процесів, які дають вихідний результат. Незмінність допомагає в цьому сценарії з кількох причин: значення, які читаються декількома потоками, гарантовано не змінюються протягом їхнього життя, що значно спрощує можливість обробляти дані без заблокованого режиму та міркувати про те, як працює алгоритм.
Роберт Харві

8
Брудний маленький секрет щодо функціональної незмінності та ігрового програмування полягає в тому, що ці дві речі начебто несумісні між собою. Ви по суті намагаєтесь моделювати динамічну, постійно мінливу систему, використовуючи статичну нерухому структуру даних.
Роберт Харві

2
Не сприймайте незмінність проти незмінність як релігійну догму. Бувають ситуації, коли кожен кращий за інших, незмінність не завжди краща, наприклад, написання інструментарію GUI з незмінними типами даних буде абсолютним кошмаром.
whatsisname

1
Це специфічне питання C # та його відповіді охоплюють проблему котлоагрегату, головним чином випливаючи з необхідності створення трохи модифікованих (оновлених) клонів існуючого незмінного об'єкта.
rwong

2
Ключове розуміння - монстр у цій грі вважається сутністю. Також результат кожного бою (що складається з порядкового номера бою, ідентифікаторів сутності монстрів, станів монстрів до і після бою) вважається станом у певний момент часу (або крок часу). Таким чином, гравці ( Team) можуть отримати результат битви, і, таким чином, стану монстрів за допомогою кортежу (номер бою, ідентифікатор монстра).
rwong

Відповіді:


19

Як функціональне програмування обробляє об'єкт, на який посилається декілька місць? Він пропонує вам переглянути вашу модель!

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

Ви можете прочитати про задоволення, яке отримала команда "Факторио", коли вона спричинила себе добре в деяких ситуаціях; ось короткий огляд їх моделі:

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

Оскільки сервер потребує арбітражу при виконанні дій, дія гравця рухається приблизно так: Дія гравця -> Ігровий клієнт -> Мережа -> Сервер -> Мережа-> Ігровий клієнт. Це означає, що всі дії гравця виконуються лише після того, як він здійснює зворотну поїздку через мережу. Це призвело б до того, що гра буде відчувати себе дуже мляво, тому приховання затримки було механізмом, який додається в грі майже з моменту впровадження багатокористувацької гри. Приховання затримки працює, імітуючи введення гравця, не враховуючи дії інших гравців і не розглядаючи арбітраж сервера.

У Факторио у нас є Ігровий стан, це повний стан карти, гравець, права, все. Він моделюється детерміновано для всіх клієнтів на основі дій, отриманих від сервера. Це священно, і якщо він коли-небудь відрізняється від сервера чи будь-якого іншого клієнта, відбувається десинхронізація.

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

Ключовим є те, що стан кожного об'єкта є незмінним у конкретному галочку в часовому рядку . Все в глобальному мультиплеєрному стані має врешті-решт конвергуватися у детерміновану реальність.

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

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

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

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

  1. обробляти входи та генерувати відповідні події
  2. генерувати внутрішні події (наприклад, через зіткнення об'єкта тощо)
  3. застосувати події до поточних непорушних монстрів, щоб генерувати нові монстри для наступного галочки - в основному копіюючи старий незмінний стан, де це можливо, але створюючи нові об'єкти стану там, де це потрібно.
  4. візьміть і повторіть для наступного галочки.

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

Завдяки примітці від @ AaronM.Eshbach, підкреслюючи, що це схожий доменний проблем із подіями Sourcing та шаблоном CQRS , де ви моделюєте зміни стану в розподіленій системі як серію незмінних подій з часом . У цьому випадку ми, швидше за все, намагаємося очистити складну програму бази даних, розділивши (як випливає з назви!) Обробку команд мутатора з системи запитів / перегляду. Більш складний звичайно, але гнучкіший.


2
Для додаткової довідки див. Пошук подій та CQRS . Це аналогічна проблемна область: моделювання змін стану в розподіленій системі як серія незмінних подій з плином часу.
Аарон М. Ешбах

@ AaronM.Eshbach це один! Ви не заперечуєте, якщо я включу у відповідь ваш коментар / цитати? Це звучить більш авторитетно. Дякую!
SusanW

Звичайно, ні, будь ласка.
Аарон М. Ешбах

3

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

p1 - send m1 to battlefield
p2 - send m2 to battlefield
m1 - attacks m2 (2 dam)
m2 - attacks m1 (10 dam)
p1 - retreats m1

тощо

Ви можете обчислити стан гри в будь-який момент, з'єднавши дії разом для створення об'єкта незмінного стану. Кожна гра - це функція, яка бере об'єкт стану та повертає новий об'єкт стану

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.