Como a programação funcional lida com a situação em que o mesmo objeto é referenciado de vários lugares?

10

Estou lendo e ouvindo que as pessoas (também neste site) elogiam rotineiramente o paradigma de programação funcional, enfatizando como é bom ter tudo imutável. Notavelmente, as pessoas propõem essa abordagem mesmo em linguagens OO tradicionalmente imperativas, como C #, Java ou C ++, não apenas em linguagens puramente funcionais como Haskell, que impõem isso ao programador.

Acho difícil entender, porque acho a mutabilidade e os efeitos colaterais ... convenientes. No entanto, dada a forma como as pessoas atualmente condenam os efeitos colaterais e consideram uma boa prática livrar-se deles sempre que possível, acredito que, se quiser ser um programador competente, tenho que iniciar minha jornada para uma melhor compreensão do paradigma ... Daí meu Q.

Um lugar quando encontro problemas com o paradigma funcional é quando um objeto é naturalmente referenciado de vários lugares. Deixe-me descrevê-lo em dois exemplos.

O primeiro exemplo será o meu jogo de C # que estou tentando criar no meu tempo livre . É um jogo na web baseado em turnos, onde ambos os jogadores têm equipes de 4 monstros e podem enviar um monstro do seu time para o campo de batalha, onde enfrentará o monstro enviado pelo jogador adversário. Os jogadores também podem recuperar monstros do campo de batalha e substituí-los por outro monstro de seu time (da mesma forma que Pokemon).

Nesse cenário, um único monstro pode ser referenciado naturalmente de pelo menos 2 lugares: o time de um jogador e o campo de batalha, que referencia dois monstros "ativos".

Agora vamos considerar a situação em que um monstro é atingido e perde 20 pontos de vida. Entre os parênteses do paradigma imperativo, modifico o healthcampo desse monstro para refletir essa mudança - e é isso que estou fazendo agora. No entanto, isso torna a Monsterclasse mutável e as funções (métodos) relacionadas impuras, o que eu acho que é considerado uma prática ruim a partir de agora.

Mesmo que eu tenha me dado a permissão para ter o código deste jogo em um estado abaixo do ideal para ter alguma esperança de realmente terminá-lo em algum momento do futuro, gostaria de saber e entender como deve ser. escrito corretamente. Portanto: se essa é uma falha de design, como corrigi-la?

No estilo funcional, como eu o entendo, eu faria uma cópia desse Monsterobjeto, mantendo-o idêntico ao antigo, exceto nesse campo; e o método suffer_hitretornaria esse novo objeto em vez de modificar o antigo. Da mesma forma, eu copiava o Battlefieldobjeto, mantendo todos os seus campos iguais, exceto esse monstro.

Isso vem com pelo menos 2 dificuldades:

  1. A hierarquia pode facilmente ser muito mais profunda do que este exemplo simplificado de apenas Battlefield-> Monster. Eu teria que fazer essa cópia de todos os campos, exceto um, e retornar um novo objeto por toda essa hierarquia. Este seria um código padrão que acho irritante, especialmente porque a programação funcional deve reduzir o padrão.
  2. Um problema muito mais grave, no entanto, é que isso levaria os dados a ficarem fora de sincronia . O monstro ativo do campo teria sua saúde reduzida; no entanto, esse mesmo monstro, referenciado pelo seu jogador controlador Team, não o faria. Se eu adotasse o estilo imperativo, todas as modificações de dados seriam instantaneamente visíveis de todos os outros locais de código e, em casos como esse, acho realmente conveniente - mas a maneira como estou obtendo as coisas é exatamente o que as pessoas dizem que é errado com o estilo imperativo!
    • Agora seria possível resolver esse problema fazendo uma jornada até o Teamfinal de cada ataque. Isso é trabalho extra. No entanto, e se um monstro puder ser repentinamente referenciado posteriormente de mais lugares? E se eu tiver uma habilidade que, por exemplo, permita que um monstro se concentre em outro monstro que não esteja necessariamente em campo (na verdade, estou considerando essa habilidade)? Eu certamente me lembrarei de fazer também uma jornada para monstros focados imediatamente após cada ataque? Parece ser uma bomba-relógio que explodirá à medida que o código se tornar mais complexo, então acho que não há solução.

Uma ideia para uma solução melhor vem do meu segundo exemplo, quando acertei o mesmo problema. Na academia, fomos instruídos a escrever em Haskell um intérprete de uma linguagem de nosso próprio projeto. (Foi também assim que fui forçado a começar a entender o que é FP). O problema apareceu quando eu estava implementando fechamentos. Mais uma vez, o mesmo escopo agora pode ser referenciado de vários locais: através da variável que contém esse escopo e como o escopo pai de qualquer escopo aninhado! Obviamente, se uma alteração for feita nesse escopo por meio de qualquer uma das referências que apontam para ele, essa alteração também deverá ser visível em todas as outras referências.

A solução que eu vim foi atribuir um ID a cada escopo e manter um dicionário central de todos os escopos na Statemônada. Agora, as variáveis ​​reteriam apenas o ID do escopo ao qual estavam vinculados, e não o próprio escopo, e os escopos aninhados também manteriam o ID do escopo pai.

Eu acho que a mesma abordagem poderia ser tentada no meu jogo de luta contra monstros ... Campos e equipes não fazem referência a monstros; eles possuem IDs de monstros salvos em um dicionário central de monstros.

No entanto, mais uma vez, vejo um problema com essa abordagem que me impede de aceitá-la sem hesitar como a solução para o problema:

Mais uma vez, é uma fonte de código padrão. Torna uma linha necessariamente três linhas: o que anteriormente era uma modificação local de uma linha de um único campo agora requer (a) Recuperação do objeto do dicionário central (b) Realização da alteração (c) Salvamento do novo objeto para o dicionário central. Além disso, manter identificações de objetos e dicionários centrais em vez de ter referências aumenta a complexidade. Como o FP é anunciado para reduzir a complexidade e o código padrão, isso sugere que estou fazendo errado.

Eu também escreveria sobre um segundo problema que parece muito mais grave: essa abordagem introduz vazamentos de memória . Objetos inacessíveis normalmente serão coletados como lixo. No entanto, objetos mantidos em um dicionário central não podem ser coletados como lixo, mesmo que nenhum objeto acessível faça referência a esse ID específico. E, embora uma programação teoricamente cuidadosa possa evitar vazamentos de memória (poderíamos ter o cuidado de remover manualmente cada objeto do dicionário central quando não for mais necessário), isso é propenso a erros e o FP é anunciado para aumentar a correção dos programas. não ser o caminho correto.

No entanto, descobri a tempo que parece ser um problema resolvido. O Java fornece WeakHashMapque podem ser usados ​​para resolver esse problema. O C # fornece um recurso semelhante - ConditionalWeakTable- embora, de acordo com os documentos, ele deva ser usado pelos compiladores. E em Haskell, temos System.Mem.Weak .

Armazenar esses dicionários é a solução funcional correta para esse problema ou existe uma mais simples que não consigo ver? Imagino que o número de dicionários desse tipo possa crescer facilmente e muito; Portanto, se essas dicção também devem ser imutáveis, isso pode significar muita passagem de parâmetros ou, em linguagens que suportam isso, cálculos monádicos, já que os dicionários seriam mantidos em mônadas (mas mais uma vez eu estou lendo isso em funções puramente funcionais). idiomas, o mínimo de código possível deve ser monádico, enquanto essa solução de dicionário colocaria quase todo o código dentro da Statemônada; o que mais uma vez me faz duvidar se essa é a solução correta.)

Após algumas considerações, acho que acrescentaria mais uma pergunta: o que estamos ganhando com a construção desses dicionários? O que há de errado com a programação imperativa é, de acordo com muitos especialistas, que as mudanças em alguns objetos se propagam para outras partes do código. Para resolver esse problema, os objetos devem ser imutáveis ​​- precisamente por esse motivo, se bem entendi, as alterações feitas a eles não devem ser visíveis em nenhum outro lugar. Mas agora estou preocupado com outros trechos de código operando com dados desatualizados, então inventei dicionários centrais para que ... mais uma vez as alterações em alguns trechos de código se propagem para outros trechos de código! Não voltamos, portanto, ao estilo imperativo, com todas as suas supostas desvantagens, mas com maior complexidade?

gaazkam
fonte
6
Para dar essa perspectiva, os programas imutáveis ​​funcionais destinam-se principalmente a situações de processamento de dados que envolvem simultaneidade. Em outras palavras, programas que processam dados de entrada por meio de um conjunto de equações ou processos que produzem um resultado de saída. A imutabilidade ajuda nesse cenário por vários motivos: é garantido que os valores lidos por vários encadeamentos não sejam alterados durante a vida útil, o que simplifica bastante a capacidade de processar os dados de maneira livre de bloqueios e o motivo pelo qual o algoritmo funciona.
Robert Harvey
8
O pequeno segredo sujo sobre imutabilidade funcional e programação de jogos é que essas duas coisas são meio incompatíveis entre si. Você está basicamente tentando modelar um sistema dinâmico e em constante mudança usando uma estrutura de dados estática e imóvel.
Robert Harvey
2
Não tome mutabilidade vs imutabilidade como um dogma religioso. Há situações em que cada um é melhor que o outro, a imutabilidade nem sempre é melhor, por exemplo, escrever um kit de ferramentas da GUI com tipos de dados imutáveis ​​será um pesadelo absoluto.
Whatsisname
11
Esta questão específica do C # e suas respostas cobrem o problema do clichê, principalmente devido à necessidade de criar clones levemente modificados (atualizados) de um objeto imutável existente.
rwong 31/07/19
2
Um insight importante é que um monstro neste jogo é considerado uma entidade. Além disso, o resultado de cada batalha (que consiste no número de sequência da batalha, os IDs de entidade dos monstros, os estados dos monstros antes e depois da batalha) é considerado um estado em um determinado momento (ou etapa do tempo). Assim, os jogadores ( Team) podem recuperar o resultado da batalha e, portanto, os estados dos monstros por uma tupla (número da batalha, ID da entidade do monstro).
rwong 31/07/19

Respostas:

19

Como a Programação Funcional lida com um objeto referenciado de vários lugares? Ele convida você a revisitar seu modelo!

Para explicar ... vejamos como os jogos em rede às vezes são gravados - com uma cópia "fonte dourada" central do estado do jogo e um conjunto de eventos de cliente que atualizam esse estado e depois são transmitidos de volta para os outros clientes .

Você pode ler sobre a diversão que a equipe do Factorio teve ao fazê-lo se comportar bem em algumas situações; Aqui está uma breve visão geral de seu modelo:

A maneira básica como o nosso multiplayer funciona é que todos os clientes simulem o estado do jogo e apenas recebam e enviem a entrada do jogador (chamada Input Actions). A principal responsabilidade do servidor é fazer proxy de ações de entrada e garantir que todos os clientes executem as mesmas ações no mesmo tick.

Como o servidor precisa arbitrar quando as ações são executadas, uma ação do jogador move algo como isto: Ação do jogador -> Game Client -> Rede -> Servidor -> Rede-> Cliente do jogo. Isso significa que toda ação de jogador só é executada quando ele faz uma ida e volta pela rede. Isso faria o jogo parecer realmente lento, por isso a ocultação de latência foi um mecanismo adicionado ao jogo quase desde a introdução do multiplayer. Ocultar a latência funciona simulando a entrada do jogador, sem considerar as ações de outros jogadores e sem considerar a arbitragem do servidor.

No Fatorio, temos o Estado do Jogo, este é o estado completo do mapa, jogador, entidades, tudo. É simulado deterministicamente em todos os clientes com base nas ações recebidas do servidor. Isso é sagrado e, se for diferente do servidor ou de qualquer outro cliente, ocorrerá uma dessincronização.

No topo do estado do jogo, temos o estado de latência. Isso contém um pequeno subconjunto do estado principal. O estado de latência não é sagrado e apenas representa como pensamos que o estado do jogo será no futuro com base nas ações de entrada que o jogador executou.

O principal é que o estado de cada objeto seja imutável na marca específica na linha do tempo . Tudo no estado multiplayer global deve finalmente convergir para uma realidade determinística.

E - essa pode ser a chave da sua pergunta. O estado de cada entidade é imutável para um determinado tique, e você acompanha os eventos de transição que produzem novas instâncias ao longo do tempo.

Se você pensar bem, a fila de eventos recebidos do servidor deve ter acesso a um diretório central de entidades, apenas para que possa aplicar seus eventos.

No final, seus métodos simples de mutação de uma linha que você não deseja complicar são apenas simples porque você não está realmente modelando o tempo com precisão. Afinal, se a integridade puder mudar no meio do ciclo de processamento, as entidades anteriores desse tick verão um valor antigo e as posteriores, um valor alterado. Gerenciar isso com cuidado significa, no mínimo , os estados atual (imutável) e o próximo (em construção), que são realmente apenas dois ticks na grande linha do tempo dos ticks!

Portanto, como um guia amplo, considere dividir o estado de um monstro em vários objetos pequenos relacionados a, por exemplo, localização / velocidade / física, saúde / dano, ativos. Construa um evento para descrever cada mutação que possa acontecer e execute seu loop principal como:

  1. processar entradas e gerar eventos correspondentes
  2. gerar eventos internos (por exemplo, devido a colisões de objetos, etc.)
  3. aplique eventos aos monstros imutáveis ​​atuais, para gerar novos monstros para o próximo tick - principalmente copiando o antigo estado inalterado sempre que possível, mas criando novos objetos de estado quando necessário.
  4. renderize e repita para o próximo tick.

Ou algo assim. Acho que penso "como eu faria isso distribuído?" é um bom exercício mental, geralmente, para refinar minha compreensão quando estou confuso sobre onde as coisas vivem e como elas devem evoluir.

Graças a uma observação de @ AaronM.Eshbach, destacando que este é um domínio de problema semelhante ao Event Sourcing e ao padrão CQRS , onde você está modelando alterações de estado em um sistema distribuído como uma série de eventos imutáveis ​​ao longo do tempo . Nesse caso, provavelmente estamos tentando limpar um aplicativo de banco de dados complexo, segregando (como o nome sugere!) O tratamento do comando mutator do sistema de consulta / exibição. Mais complexo, é claro, mas mais flexível.

SusanW
fonte
2
Para referência adicional, consulte Event Sourcing e CQRS . Esse é um domínio de problema semelhante: a modelagem muda para o estado em um sistema distribuído como uma série de eventos imutáveis ​​ao longo do tempo.
Aaron M. Eshbach
@ AaronM.Eshbach é esse! Você se importa se eu incluir seus comentários / citações na resposta? Faz parecer mais autoritário. Obrigado!
SusanW
Claro que não, por favor.
Aaron M. Eshbach
3

Você ainda está na metade do campo imperativo. Em vez de pensar em um único objeto de cada vez, pense no seu jogo em termos de um histórico de jogadas ou eventos

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

etc

Você pode calcular o estado do jogo a qualquer momento, encadeando as ações para produzir um objeto de estado imutável. Cada peça é uma função que pega um objeto de estado e retorna um novo objeto de estado

Ewan
fonte