Как функциональное программирование справляется с ситуацией, когда на один и тот же объект ссылаются из разных мест?

10

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

Мне трудно это понять, потому что я нахожу изменчивость и побочные эффекты ... удобными. Тем не менее, учитывая то, как люди в настоящее время осуждают побочные эффекты и считают хорошей практикой избавляться от них везде, где это возможно, я считаю, что если я хочу быть компетентным программистом, я должен начать свой путь к лучшему пониманию парадигмы ... Отсюда мой вопрос.

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

Первым примером будет моя игра на C #, которую я пытаюсь сделать в свободное время . Это пошаговая веб-игра, в которой оба игрока имеют команды по 4 монстра и могут отправить монстра из своей команды на поле битвы, где он столкнется с монстром, посланным противником. Игроки могут также вызывать монстров с поля битвы и заменять их другим монстром из своей команды (аналогично покемонам).

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

Теперь давайте рассмотрим ситуацию, когда один монстр получает удар и теряет 20 очков здоровья. В рамках императивной парадигмы я healthизменяю поле этого монстра, чтобы отразить это изменение - и это то, что я делаю сейчас. Однако это делает Monsterкласс изменчивым и связанные с ним функции (методы) нечистыми, что, я думаю, считается плохой практикой на данный момент.

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

В функциональном стиле, насколько я понимаю, я бы вместо этого сделал копию этого Monsterобъекта, оставив его идентичным старому, за исключением одного этого поля; и метод suffer_hitбудет возвращать этот новый объект вместо того, чтобы модифицировать старый на месте. Затем я также скопировал бы Battlefieldобъект, оставив все его поля одинаковыми, за исключением этого монстра.

Это связано как минимум с 2 трудностями:

  1. Иерархия может быть намного глубже, чем этот упрощенный пример просто Battlefield-> Monster. Мне пришлось бы делать такое копирование всех полей, кроме одного, и возвращать новый объект по всей этой иерархии. Это был бы стандартный код, который я нахожу раздражающим, особенно потому, что функциональное программирование должно уменьшать шаблон.
  2. Однако гораздо более серьезная проблема заключается в том, что это приведет к несинхронизации данных . Активное чудовище поля увидит, что его здоровье уменьшено; однако, этот же монстр, на который ссылается его контролирующий игрок Team, не будет. Если бы я вместо этого принял императивный стиль, каждая модификация данных была бы немедленно видна из всех других мест кода, и в таких случаях, как этот, я нахожу это действительно удобным - но способ, которым я получаю вещи, это именно то, что говорят люди неправильно с императивным стилем!
    • Теперь можно было бы решить эту проблему, отправляясь в путь Teamпосле каждой атаки. Это дополнительная работа. Однако что, если на монстра можно будет внезапно ссылаться из еще большего количества мест? Что если я приду со способностью, которая, например, позволяет монстру сфокусироваться на другом монстре, который не обязательно находится на поле (на самом деле я рассматриваю такую ​​способность)? Не забуду ли я обязательно совершать путешествие к сфокусированным монстрам сразу после каждой атаки? Кажется, это бомба замедленного действия, которая взорвется, когда код станет более сложным, поэтому я думаю, что это не решение проблемы.

Идея лучшего решения возникла из моего второго примера, когда я столкнулся с той же проблемой. В академии нам сказали написать переводчик языка нашего собственного дизайна на Хаскеле. (Это также, как я был вынужден начать понимать, что такое FP). Проблема обнаружилась, когда я реализовывал замыкания. Еще раз одна и та же область теперь может быть использована из нескольких мест: через переменную, которая содержит эту область и как родительскую область для любых вложенных областей! Очевидно, что если в эту область внесено изменение с помощью любой из ссылок, указывающих на нее, это изменение также должно быть видно через все остальные ссылки.

Решение, с которым я пришел, состояло в том, чтобы назначить каждой области идентификатор и держать центральный словарь всех областей в Stateмонаде. Теперь переменные будут содержать только идентификатор области, к которой они были привязаны, а не саму область действия, а вложенные области также будут содержать идентификатор своей родительской области.

Я предполагаю, что такой же подход мог бы быть применен в моей битве с монстрами ... Поля и команды не ссылаются на монстров; вместо этого они содержат идентификаторы монстров, которые сохраняются в центральном словаре монстров.

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

Это еще раз является источником стандартного кода. Это делает однострочники обязательно 3-строчными: то, что раньше было однострочной модификацией отдельного поля на месте, теперь требует (а) извлечения объекта из центрального словаря (б) внесения изменений (в) сохранения нового объекта в центральный словарь. Кроме того, хранение идентификаторов объектов и центральных словарей вместо ссылок увеличивает сложность. Так как FP рекламируется для уменьшения сложности и стандартного кода, это указывает на то, что я делаю это неправильно.

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

Однако со временем я обнаружил, что это, скорее, решенная проблема. Java предоставляет, WeakHashMapчто может быть использовано для решения этой проблемы. C # предоставляет аналогичное средство - ConditionalWeakTableхотя, согласно документам, оно предназначено для использования компиляторами. А в Хаскеле у нас есть System.Mem.Weak .

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

Подумав немного, я бы добавил еще один вопрос: что мы получаем, создавая такие словари? То, что не так с императивным программированием, по мнению многих экспертов, заключается в том, что изменения в некоторых объектах распространяются на другие части кода. Для решения этой проблемы объекты должны быть неизменными - именно по этой причине, если я правильно понимаю, что внесенные в них изменения не должны быть видны в другом месте. Но теперь я обеспокоен другими частями кода, работающими с устаревшими данными, поэтому я придумываю центральные словари, чтобы ... снова изменения в некоторых частях кода распространялись на другие части кода! Разве мы не возвращаемся к императивному стилю со всеми его предполагаемыми недостатками, но с добавленной сложностью?

gaazkam
источник
6
Чтобы дать этому некоторое представление, функциональные неизменяемые программы в основном предназначены для ситуаций обработки данных, связанных с параллелизмом. Другими словами, программы, которые обрабатывают входные данные через набор уравнений или процессов, которые производят выходной результат. Неизменяемость помогает в этом сценарии по нескольким причинам: значения, считываемые несколькими потоками, гарантированно не изменятся в течение срока их службы, что значительно упрощает возможность обработки данных без блокировки и объясняет, как работает алгоритм.
Роберт Харви
8
Грязный маленький секрет о функциональной неизменности и программировании игры заключается в том, что эти две вещи вроде несовместимы друг с другом. По сути, вы пытаетесь смоделировать динамическую, постоянно меняющуюся систему, используя статическую, неподвижную структуру данных.
Роберт Харви
2
Не воспринимайте изменчивость против неизменности как религиозную догму. Существуют ситуации, когда каждая из них лучше, чем другая, неизменность не всегда лучше, например, написание GUI-инструментария с неизменяемыми типами данных будет абсолютным кошмаром.
whatsisname
1
Этот специфичный для C # вопрос и ответы на него охватывают проблему стандартного шаблона, главным образом в результате необходимости создавать слегка измененные (обновленные) клоны существующего неизменяемого объекта.
Rwong
2
Ключевое понимание заключается в том, что монстр в этой игре считается сущностью. Кроме того, исход каждого сражения (состоящий из порядкового номера битвы, идентификаторов сущностей монстров, состояний монстров до и после битвы) считается состоянием в определенный момент времени (или временной шаг). Таким образом, игроки ( Team) могут получить исход битвы и, таким образом, состояния монстров с помощью кортежа (номер битвы, идентификатор объекта монстра).
Руон

Ответы:

19

Как функциональное программирование обрабатывает объект, на который ссылаются из нескольких мест? Он приглашает вас вернуться к вашей модели!

Чтобы объяснить ... давайте посмотрим, как иногда пишутся сетевые игры - с центральной «золотой исходной» копией состояния игры и набором входящих клиентских событий, которые обновляют это состояние и затем передают широковещательную рассылку другим клиентам. ,

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

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

Поскольку серверу необходимо осуществлять арбитраж при выполнении действий, действие игрока перемещается примерно так: действие игрока -> игровой клиент -> сеть -> сервер -> сеть -> игровой клиент. Это означает, что каждое действие игрока выполняется только тогда, когда оно совершает круговое путешествие по сети. Это сделало бы игру действительно запаздывающей, поэтому скрытие латентности стало механизмом, добавленным в игру почти с момента появления многопользовательской игры. Скрытие задержки работает путем имитации ввода игрока, без учета действий других игроков и без учета арбитража сервера.

В Factorio у нас есть Game State, это полное состояние карты, игрок, права, все. Он моделируется детерминистически на всех клиентах на основе действий, полученных от сервера. Это священно, и если оно когда-либо отличается от сервера или любого другого клиента, происходит рассинхронизация.

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

Ключевым моментом является то, что состояние каждого объекта является неизменным в определенный момент времени . Все в глобальном многопользовательском состоянии должно в конечном итоге сходиться к детерминированной реальности.

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

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

В конце концов, ваши простые однострочные методы-мутаторы, которые вы не хотите усложнять, просты только потому, что вы не совсем точно моделируете время. В конце концов, если здоровье может измениться в середине цикла обработки, тогда более ранние объекты в этом тике будут видеть старое значение, а более поздние видят измененное значение. Тщательно управлять этим означает по меньшей мере различающие текущее (неизменяемое) и следующее (находящееся в стадии разработки) состояния, которые на самом деле представляют собой всего два тика на большой временной шкале тиков!

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

  1. обрабатывать входные данные и генерировать соответствующие события
  2. генерировать внутренние события (например, из-за столкновений объектов и т. д.)
  3. применять события к текущим неизменным монстрам, чтобы генерировать новых монстров для следующего тика - в основном копирование старого неизменного состояния, где это возможно, но создание новых объектов состояния, где это необходимо.
  4. отрендерить и повторить для следующего тика.

Или что-то типа того. Я нахожу мысль: «Как бы я сделал это распределенным?» Это довольно хорошее умственное упражнение, как правило, для уточнения моего понимания, когда я не понимаю, где живут вещи и как они должны развиваться.

Благодаря заметке @ AaronM.Eshbach, в которой подчеркивается, что эта область проблем аналогична проблемному источнику событий и шаблону CQRS , где вы моделируете изменения состояния в распределенной системе как последовательность неизменяемых событий с течением времени . В этом случае мы, скорее всего, пытаемся очистить сложное приложение базы данных, отделив (как следует из названия!) Обработку команды мутатора от системы запросов / представлений. Более сложный, конечно, но более гибкий.

SusanW
источник
2
Для дополнительной информации см. Event Sourcing и 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

так далее

Вы можете вычислить состояние игры в любой заданной точке, объединяя действия в цепочку, чтобы создать неизменный объект состояния. Каждая игра - это функция, которая принимает объект состояния и возвращает новый объект состояния.

Ewan
источник