"Tudo é um mapa", estou fazendo isso certo?

69

Eu assisti a palestra de Stuart Sierra " Thinking In Data " e peguei uma das idéias como um princípio de design neste jogo que estou criando. A diferença é que ele está trabalhando em Clojure e eu estou trabalhando em JavaScript. Vejo algumas diferenças importantes entre nossos idiomas:

  • Clojure é uma programação funcional linguística
  • A maioria dos estados é imutável

Tirei a ideia do slide "Tudo é um mapa" (de 11 minutos, 6 segundos a> 29 minutos). Algumas coisas que ele diz são:

  1. Sempre que você vê uma função que recebe de 2 a 3 argumentos, é possível transformá-la em um mapa e apenas passar um mapa. Há muitas vantagens nisso:
    1. Você não precisa se preocupar com a ordem dos argumentos
    2. Você não precisa se preocupar com informações adicionais. Se houver chaves extras, essa não é nossa preocupação. Eles simplesmente fluem, não interferem.
    3. Você não precisa definir um esquema
  2. Ao contrário de passar um Objeto, não há ocultação de dados. Mas ele argumenta que a ocultação de dados pode causar problemas e é superestimada:
    1. atuação
    2. Facilidade de implementação
    3. Assim que você se comunica pela rede ou pelos processos, é necessário que ambos os lados concordem com a representação dos dados. Isso é trabalho extra que você pode pular se apenas trabalhar com dados.
  3. Mais relevante para a minha pergunta. São 29 minutos em: "Torne suas funções compostáveis". Aqui está o exemplo de código que ele usa para explicar o conceito:

    ;; Bad
    (defn complex-process []
      (let [a (get-component @global-state)
            b (subprocess-one a) 
            c (subprocess-two a b)
            d (subprocess-three a b c)]
        (reset! global-state d)))
    
    ;; Good
    (defn complex-process [state]
      (-> state
        subprocess-one
        subprocess-two
        subprocess-three))
    

    Entendo que a maioria dos programadores não conhece o Clojure, então reescreverei isso no estilo imperativo:

    ;; Good
    def complex-process(State state)
      state = subprocess-one(state)
      state = subprocess-two(state)
      state = subprocess-three(state)
      return state
    

    Aqui estão as vantagens:

    1. Fácil de testar
    2. Fácil de olhar para essas funções isoladamente
    3. É fácil comentar uma linha disso e ver qual é o resultado removendo uma única etapa
    4. Cada subprocesso pode adicionar mais informações ao estado. Se o subprocesso um precisa comunicar algo para o subprocesso três, é tão simples quanto adicionar uma chave / valor.
    5. Nenhum clichê para extrair os dados de que você precisa fora do estado, apenas para que você possa salvá-los novamente. Apenas passe o estado inteiro e deixe o subprocesso atribuir o que precisa.

Agora, voltando à minha situação: peguei esta lição e a apliquei no meu jogo. Ou seja, quase todas as minhas funções de alto nível pegam e retornam um gameStateobjeto. Este objeto contém todos os dados do jogo. EG: Uma lista de badGuys, uma lista de menus, os itens no chão, etc. Aqui está um exemplo da minha função de atualização:

update(gameState)
  ...
  gameState = handleUnitCollision(gameState)
  ...
  gameState = handleLoot(gameState)
  ...

O que estou aqui para perguntar é: criei alguma abominação que perverteu uma idéia que só é prática em uma linguagem de programação funcional? O JavaScript não é funcionalmente idioma (embora possa ser escrito dessa maneira) e é realmente desafiador escrever estruturas de dados imutáveis. Uma coisa que me preocupa é que ele assume que cada um desses subprocessos é puro. Por que essa suposição precisa ser feita? É raro que qualquer uma das minhas funções seja pura (com isso, quero dizer, elas frequentemente modificam o gameState. Não tenho outros efeitos colaterais complicados além disso). Essas idéias desmoronam se você não possui dados imutáveis?

Estou preocupado que um dia eu acorde e perceba que todo esse design é uma farsa e realmente acabei de implementar o anti-padrão Big Ball Of Mud .


Honestamente, eu tenho trabalhado nesse código há meses e tem sido ótimo. Sinto que estou recebendo todas as vantagens que ele reivindicou. Meu código é super fácil para eu raciocinar. Mas eu sou uma equipe de um homem só, por isso tenho a maldição do conhecimento.

Atualizar

Eu tenho codificado mais de 6 meses com esse padrão. Normalmente, a essa altura, esqueço o que fiz e é aí que "escrevi isso de maneira limpa?" entra em jogo. Se não tivesse, realmente lutaria. Até agora, não estou lutando.

Entendo como outro conjunto de olhos seria necessário para validar sua manutenção. Tudo o que posso dizer é que me preocupo com a manutenção em primeiro lugar. Eu sou sempre o evangelista mais alto em código limpo, não importa onde eu trabalho.

Quero responder diretamente àqueles que já têm uma experiência pessoal ruim com essa maneira de codificar. Na época eu não sabia, mas acho que estamos realmente falando de duas maneiras diferentes de escrever código. O jeito que eu fiz isso parece ser mais estruturado do que o que os outros experimentaram. Quando alguém tem uma experiência pessoal ruim com "Tudo é um mapa", eles falam sobre como é difícil manter, porque:

  1. Você nunca sabe a estrutura do mapa que a função requer
  2. Qualquer função pode alterar a entrada da maneira que você nunca esperaria. Você deve procurar por toda a base de código para descobrir como uma chave específica entrou no mapa ou por que ela desapareceu.

Para aqueles com essa experiência, talvez a base de código fosse "Tudo leva 1 de N tipos de mapas". O meu é: "Tudo leva 1 de 1 tipo de mapa". Se você conhece a estrutura desse tipo 1, conhece a estrutura de tudo. Obviamente, essa estrutura geralmente cresce com o tempo. É por isso...

Há um lugar para procurar a implementação de referência (ou seja: o esquema). Essa implementação de referência é o código que o jogo usa para não ficar desatualizado.

Quanto ao segundo ponto, não adiciono / removo chaves do mapa fora da implementação de referência, apenas modifico o que já existe. Eu também tenho um grande conjunto de testes automatizados.

Se essa arquitetura eventualmente entrar em colapso com seu próprio peso, adicionarei uma segunda atualização. Caso contrário, suponha que tudo está indo bem :)

Daniel Kaplan
fonte
2
Pergunta legal (+1)! Acho um exercício muito útil tentar implementar expressões funcionais em uma linguagem não-funcional (ou não muito fortemente funcional).
Giorgio
15
Qualquer pessoa que lhe diga que a ocultação de informações no estilo OO (com propriedades e funções de acessador) é uma coisa ruim por causa do impacto no desempenho (geralmente desprezível) e depois lhe pedirá para transformar todos os seus parâmetros em um mapa, o que lhe dará a sobrecarga (muito maior) de uma pesquisa por hash toda vez que você tenta recuperar um valor pode ser ignorada com segurança.
Mason Wheeler
4
@MasonWheeler permite dizer que você está certo sobre isso. Você vai anular todos os outros argumentos que ele faz por causa dessa coisa errada?
Daniel Kaplan
9
Em Python (e acredito que as linguagens mais dinâmicas, incluindo Javascript), objeto é realmente apenas açúcar de sintaxe para um dict / map de qualquer maneira.
Lie Ryan
6
@EvanPlaice: A notação Big-O pode ser enganosa. O simples fato é que tudo é lento em comparação ao acesso direto com duas ou três instruções de código de máquina individuais e, em algo que acontece tantas vezes como uma chamada de função, essa sobrecarga se acumula muito rapidamente.
Mason Wheeler

Respostas:

42

Já apoiei um aplicativo em que 'tudo é um mapa' antes. É uma péssima ideia. POR FAVOR, não faça isso!

Quando você especifica os argumentos que são passados ​​para a função, fica muito fácil saber quais valores a função precisa. Evita passar dados estranhos para a função que apenas distrai o programador - todo valor passado implica que ele é necessário e faz com que o programador que suporta seu código precise descobrir por que os dados são necessários.

Por outro lado, se você passar tudo como um mapa, o programador que oferece suporte ao seu aplicativo precisará entender completamente a função chamada de todas as maneiras para saber quais valores o mapa precisa conter. Pior ainda, é muito tentador reutilizar o mapa passado para a função atual para passar dados para as próximas funções. Isso significa que o programador que oferece suporte ao seu aplicativo precisa conhecer todas as funções chamadas pela função atual para entender o que a função atual faz. Esse é exatamente o oposto do objetivo de escrever funções - abstraindo os problemas para que você não precise pensar neles! Agora imagine 5 chamadas profundas e 5 chamadas amplas cada. Isso é muita coisa para se manter em mente e muitos erros a serem cometidos.

"tudo é um mapa" também parece levar ao uso do mapa como um valor de retorno. Eu já vi isso. E, novamente, é uma dor. As funções chamadas nunca precisam substituir o valor de retorno uma da outra - a menos que você conheça a funcionalidade de tudo e saiba que o valor do mapa de entrada X precisa ser substituído para a próxima chamada de função. E a função atual precisa modificar o mapa para retornar seu valor, que às vezes deve substituir o valor anterior e às vezes não.

editar - exemplo

Aqui está um exemplo de onde isso era problemático. Esta foi uma aplicação web. A entrada do usuário foi aceita na camada da interface do usuário e colocada em um mapa. Em seguida, as funções foram chamadas para processar a solicitação. O primeiro conjunto de funções procuraria por entradas incorretas. Se houvesse um erro, a mensagem de erro seria colocada no mapa. A função de chamada verificaria o mapa para esta entrada e gravaria o valor na interface do usuário, se ela existisse.

O próximo conjunto de funções iniciaria a lógica de negócios. Cada função pegaria o mapa, removeria alguns dados, modificaria alguns dados, operaria com os dados no mapa e colocaria o resultado no mapa, etc. As funções subseqüentes esperariam resultados de funções anteriores no mapa. Para corrigir um bug em uma função subseqüente, você tinha que investigar todas as funções anteriores e também o responsável pela chamada para determinar em qualquer lugar que o valor esperado pudesse ter sido definido.

As próximas funções puxariam dados do banco de dados. Ou melhor, eles passariam o mapa para a camada de acesso a dados. O DAL verificaria se o mapa continha certos valores para controlar como a consulta foi executada. Se 'justcount' fosse uma chave, a consulta seria 'count select foo from bar'. Qualquer uma das funções que foram chamadas anteriormente pode ter aquela que adicionou 'justcount' ao mapa. Os resultados da consulta seriam adicionados ao mesmo mapa.

Os resultados iriam até o chamador (lógica de negócios), que verificaria o mapa quanto ao que fazer. Parte disso viria de coisas que foram adicionadas ao mapa pela lógica comercial inicial. Alguns viriam dos dados do banco de dados. A única maneira de saber de onde veio foi encontrar o código que o adicionou. E o outro local que também pode adicioná-lo.

O código era efetivamente uma bagunça monolítica, que você precisava entender por inteiro para saber de onde vinha uma única entrada no mapa.

atk
fonte
2
Seu segundo parágrafo faz sentido para mim e isso realmente parece uma merda. No terceiro parágrafo, percebo que não estamos falando do mesmo design. "reutilizar" é o ponto. Seria errado evitá-lo. E eu realmente não posso me relacionar com o seu último parágrafo. Eu tenho todas as funções, gameStatesem saber nada sobre o que aconteceu antes ou depois. Ele simplesmente reage aos dados fornecidos. Como você entrou em uma situação em que as funções pisavam nos dedos uns dos outros? Você pode dar um exemplo?
Daniel Kaplan
2
Adicionei um exemplo para tentar torná-lo um pouco mais claro. Espero que ajude. Além disso, há uma diferença entre passando em torno de um objeto de estado bem definido em relação passando em torno de um blob que define alterado por muitos lugares por muitas razões, misturar lógica ui, lógica de negócios e lógica de acesso a banco de dados
atk
28

Pessoalmente, eu não recomendaria esse padrão em nenhum dos paradigmas. Torna mais fácil escrever inicialmente às custas de dificultar o raciocínio mais tarde.

Por exemplo, tente responder às seguintes perguntas sobre cada função de subprocesso:

  • De quais campos stateele requer?
  • Quais campos ele modifica?
  • Quais campos não são alterados?
  • Você pode reorganizar com segurança a ordem das funções?

Com esse padrão, você não pode responder a essas perguntas sem ler a função inteira.

Em uma linguagem orientada a objetos, o padrão faz ainda menos sentido, porque o estado de rastreamento é o que os objetos fazem.

Karl Bielefeldt
fonte
2
"os benefícios da imutabilidade diminuem conforme seus objetos imutáveis ​​ficam maiores" Por quê? Isso é um comentário sobre desempenho ou manutenção? Por favor, elabore essa frase.
Daniel Kaplan
8
Os @tieTYT Immutables funcionam bem quando há algo pequeno (um tipo numérico, por exemplo). Você pode copiá-los, criá-los, descartá-los, pesá-los com um custo bastante baixo. Quando você começa a lidar com estados inteiros do jogo, compostos por mapas, árvores, listas e dezenas de grandes e profundos, senão centenas, variáveis, o custo para copiá-lo ou excluí-lo aumenta (e os pesos de voo se tornam impraticáveis).
3
Eu vejo. Isso é um problema de "dados imutáveis ​​em linguagens imperativas" ou "dados imutáveis"? IE: Talvez este não seja um problema no código Clojure. Mas eu posso ver como é em JS. Também é difícil escrever todo o código padrão para fazê-lo.
Daniel Kaplan
3
@ MichaelT e Karl: para ser justo, você realmente deve mencionar o outro lado da história da imutabilidade / eficiência. Sim, o uso ingênuo pode ser terrivelmente ineficiente, é por isso que as pessoas apresentam melhores abordagens. Veja o trabalho de Chris Okasaki para mais informações.
3
@ MattFenwick Eu, pessoalmente, gosto bastante de imutáveis. Ao lidar com o encadeamento, sei coisas sobre o imutável e posso trabalhar e copiá-las com segurança. Eu o coloco em uma chamada de parâmetro e passo para outra sem me preocupar que alguém a modifique quando voltar para mim. Se alguém está falando sobre um estado complexo do jogo (a pergunta usou isso como exemplo - eu ficaria horrorizado ao pensar em algo tão 'simples' como o estado do jogo nethack como um imutável), a imutabilidade é provavelmente a abordagem errada.
12

O que você parece estar fazendo é, efetivamente, uma mônada manual do Estado; o que eu faria é criar um combinador de ligação (simplificado) e reexprimir as conexões entre suas etapas lógicas usando o seguinte:

function stateBind() {
    var computation = function (state) { return state; };
    for ( var i = 0 ; i < arguments.length ; i++ ) {
        var oldComp = computation;
        var newComp = arguments[i];
        computation = function (state) { return newComp(oldComp(state)); };
    }
    return computation;
}

...

stateBind(
  subprocessOne,
  subprocessTwo,
  subprocessThree,
);

Você pode até usar stateBindpara construir os vários subprocessos a partir de subprocessos e continuar descendo uma árvore de combinadores de ligação para estruturar seu cálculo adequadamente.

Para obter uma explicação da mônada completa do Estado, sem simplificação, e uma excelente introdução às mônadas em geral no JavaScript, consulte esta postagem do blog .

Chama de Ptharien
fonte
11
OK, analisarei isso (e comento mais tarde). Mas o que você acha da idéia de usar o padrão?
Daniel Kaplan
11
@tieTYT Acho que o padrão em si é uma ideia muito boa; a mônada do estado em geral é uma ferramenta útil de estruturação de código para algoritmos pseudo-mutáveis ​​(algoritmos imutáveis, mas que imitam a mutabilidade).
Chama de Ptharien
2
+1 por observar que esse padrão é essencialmente uma mônada. No entanto, discordo que é uma boa ideia em uma linguagem que realmente tenha mutabilidade. Mônada é uma maneira de fornecer a capacidade do estado global / mutável em um idioma que não permite a mutação. Na IMO, em um idioma que não impõe imutabilidade, o padrão da Mônada é apenas masturbação mental.
Lie Ryan
6
@LieRyan Monads em geral, na verdade, não têm nada a ver com mutabilidade ou globais; somente a mônada estadual faz especificamente (porque é para isso que ela foi projetada). Também discordo que a mônada do Estado não é útil em uma linguagem com mutabilidade, embora uma implementação baseada na mutabilidade por baixo possa ser mais eficiente do que a imutável que eu dei (embora eu não tenha certeza disso). A interface monádica pode fornecer recursos de alto nível que não seriam facilmente acessíveis, o stateBindcombinador que dei é um exemplo muito simples disso.
Chama de Ptharien
11
@LieRyan I segundo comentário de Ptharien - a maioria das mônadas não é sobre estado ou mutabilidade, e mesmo a que é, especificamente, não é sobre estado global . As mônadas realmente funcionam muito bem em linguagens OO / imperativas / mutáveis.
11

Portanto, parece haver muita discussão entre a eficácia dessa abordagem no Clojure. Eu acho que pode ser útil olhar para a filosofia de Rich Hickey sobre por que ele criou o Clojure para suportar abstrações de dados desta maneira :

Fogus: Assim que as complexidades incidentais foram reduzidas, como Clojure pode ajudar a resolver o problema em questão? Por exemplo, o paradigma idealizado de orientação a objetos visa promover a reutilização, mas Clojure não é classicamente orientado a objetos - como podemos estruturar nosso código para reutilização?

Hickey: Eu discutia sobre OO e reutilizava, mas certamente, poder reutilizar as coisas torna o problema em questão mais simples, pois você não está reinventando as rodas em vez de construir carros. O fato de Clojure estar na JVM disponibiliza várias rodas - bibliotecas - disponíveis. O que torna uma biblioteca reutilizável? Ele deve fazer bem uma ou algumas coisas, ser relativamente auto-suficiente e exigir poucas exigências no código do cliente. Nada disso cai no OO, e nem todas as bibliotecas Java atendem a esse critério, mas muitas o fazem.

Quando caímos no nível do algoritmo, acho que o OO pode impedir seriamente a reutilização. Em particular, o uso de objetos para representar dados informacionais simples é quase criminoso na geração de micro-linguagens por peça de informação, ou seja, os métodos de classe, versus métodos muito mais poderosos, declarativos e genéricos, como a álgebra relacional. Inventar uma classe com sua própria interface para armazenar uma informação é como inventar uma nova linguagem para escrever todos os contos. Isso é anti-reutilização e, acredito, resulta em uma explosão de código em aplicativos OO típicos. Clojure evita isso e defende um modelo associativo simples para obter informações. Com ele, pode-se escrever algoritmos que podem ser reutilizados nos tipos de informação.

Esse modelo associativo é apenas uma das várias abstrações fornecidas com Clojure, e essas são as verdadeiras bases de sua abordagem para reutilizar: funções em abstrações. Ter um conjunto aberto e grande de funções opera sobre um conjunto aberto e pequeno de abstrações extensíveis é a chave para a reutilização algorítmica e a interoperabilidade da biblioteca. A grande maioria das funções de Clojure é definida em termos dessas abstrações, e os autores das bibliotecas também projetam seus formatos de entrada e saída em termos deles, realizando uma enorme interoperabilidade entre bibliotecas desenvolvidas independentemente. Isso contrasta fortemente com os DOMs e outras coisas que você vê no OO. Obviamente, você pode fazer abstração semelhante no OO com interfaces, por exemplo, as coleções java.util, mas também não é tão fácil quanto no java.io.

Fogus reitera esses pontos em seu livro Javascript funcional :

Ao longo deste livro, adotarei a abordagem de usar tipos mínimos de dados para representar abstrações, de conjuntos a árvores e tabelas. No JavaScript, no entanto, embora seus tipos de objetos sejam extremamente poderosos, as ferramentas fornecidas para trabalhar com eles não são totalmente funcionais. Em vez disso, o padrão de uso maior associado aos objetos JavaScript é anexar métodos para fins de envio polimórfico. Felizmente, você também pode visualizar um objeto JavaScript sem nome (não criado por meio de uma função de construtor) como simplesmente um armazenamento de dados associativo.

Se as únicas operações que podemos executar em um objeto Book ou em uma instância de um tipo Employee são setTitle ou getSSN, bloqueamos nossos dados em micro-idiomas por peça de informação (Hickey 2011). Uma abordagem mais flexível para modelar dados é uma técnica associativa de dados. Objetos JavaScript, mesmo menos o mecanismo de protótipo, são veículos ideais para modelagem associativa de dados, onde valores nomeados podem ser estruturados para formar modelos de dados de nível superior, acessados ​​de maneira uniforme.

Embora as ferramentas para manipular e acessar objetos JavaScript como mapas de dados sejam escassas no próprio JavaScript, felizmente o Underscore fornece um bando de operações úteis. Entre as funções mais simples de entender estão _.keys, _.values ​​e _.pluck. _.Keys e _.values ​​são nomeados de acordo com sua funcionalidade, que é pegar um objeto e retornar uma matriz de suas chaves ou valores ...

pooya72
fonte
2
Eu li essa entrevista sobre Fogus / Hickey antes, mas eu não era capaz de entender o que ele estava falando até agora. Obrigado pela sua resposta. Ainda não tenho certeza se Hickey / Fogus daria ao meu projeto sua bênção. Estou preocupado que levei o espírito de seus conselhos ao extremo.
Daniel Kaplan
9

Advogado do diabo

Eu acho que essa pergunta merece um advogado do diabo (mas é claro que sou tendenciosa). Acho que @KarlBielefeldt está fazendo muito bons comentários e gostaria de abordá-los. Primeiro, quero dizer que os pontos dele são ótimos.

Como ele mencionou que esse não é um bom padrão, mesmo em programação funcional, considerarei JavaScript e / ou Clojure em minhas respostas. Uma similaridade extremamente importante entre esses dois idiomas é que eles são digitados dinamicamente. Eu ficaria mais satisfeito com os pontos dele se estivesse implementando isso em uma linguagem de tipo estaticamente como Java ou Haskell. Mas considerarei a alternativa ao padrão "Tudo é um mapa" como um design tradicional de POO em JavaScript e não em uma linguagem de tipo estaticamente (espero não estar configurando um argumento de palhaço fazendo isso, Por favor deixe-me saber).

Por exemplo, tente responder às seguintes perguntas sobre cada função de subprocesso:

  • Quais campos de estado são necessários?

  • Quais campos ele modifica?

  • Quais campos não são alterados?

Em uma linguagem de tipo dinâmico, como você normalmente responderia a essas perguntas? O primeiro parâmetro de uma função pode ser nomeado foo, mas o que é isso? Uma matriz? Um objeto? Um objeto de matrizes de objetos? Como você descobriu isso? A única maneira que eu sei é

  1. leia a documentação
  2. olhe para o corpo da função
  3. olhe para os testes
  4. adivinhe e execute o programa para ver se funciona.

Eu não acho que o padrão "Tudo é um mapa" faça alguma diferença aqui. Ainda são as únicas maneiras que conheço de responder a essas perguntas.

Lembre-se também de que em JavaScript e nas linguagens de programação mais imperativas, qualquer uma functionpode exigir, modificar e ignorar qualquer estado que possa acessar e a assinatura não faz diferença: A função / método pode fazer algo com o estado global ou com um singleton. As assinaturas frequentemente mentem.

Não estou tentando criar uma falsa dicotomia entre "Tudo é um mapa" e um código OO mal projetado . Estou apenas tentando salientar que ter assinaturas que recebam parâmetros de granulação menos / mais fina / grossa não garante que você saiba isolar, configurar e chamar uma função.

Mas, se você me permitir usar essa falsa dicotomia: Comparado a escrever JavaScript da maneira tradicional de POO, "Tudo é um mapa" parece melhor. Da maneira tradicional de POO, a função pode exigir, modificar ou ignorar o estado em que você passa ou não. no.

  • Você pode reorganizar com segurança a ordem das funções?

No meu código, sim. Veja meu segundo comentário na resposta de @ Evicatos. Talvez seja apenas porque estou fazendo um jogo, não sei dizer. Em um jogo que está atualizando 60x um segundo, isso realmente não importa se dead guys drop loot, em seguida, good guys pick up lootou vice-versa. Cada função ainda faz exatamente o que deve fazer, independentemente da ordem em que é executada. Os mesmos dados são inseridos em updatechamadas diferentes se você trocar o pedido. Se você tem good guys pick up loot, então dead guys drop loot, os mocinhos vão pegar o saque no próximo updatee não é nenhuma grande coisa. Um humano não será capaz de perceber a diferença.

Pelo menos essa tem sido minha experiência geral. Eu me sinto muito vulnerável admitindo isso publicamente. Talvez considerar isso bom seja uma coisa muito , muito ruim a se fazer. Deixe-me saber se eu cometi algum erro terrível aqui. Mas, se eu tiver, é extremamente fácil para reorganizar as funções assim que a ordem é dead guys drop loot, em seguida, good guys pick up lootnovamente. Levará menos tempo do que o tempo necessário para escrever este parágrafo: P

Talvez você pense que "os mortos devam retirar o saque primeiro. Seria melhor se seu código aplicasse essa ordem". Mas, por que os inimigos devem abandonar o tesouro antes que você possa pegá-lo? Para mim isso não faz sentido. Talvez o saque tenha caído 100 anos updatesatrás. Não é necessário verificar se um bandido arbitrário precisa pegar itens que já estão no terreno. É por isso que acho que a ordem dessas operações é completamente arbitrária.

É natural escrever etapas desacopladas com esse padrão, mas é difícil perceber as etapas acopladas no OOP tradicional. Se eu estivesse escrevendo OOP tradicional, a maneira natural e ingênua de pensar é fazer do dead guys drop lootretorno um Lootobjeto que eu tenho que passar para o good guys pick up loot. Eu não seria capaz de reordenar essas operações, já que o primeiro retorna a entrada do segundo.

Em uma linguagem orientada a objetos, o padrão faz ainda menos sentido, porque o estado de rastreamento é o que os objetos fazem.

Os objetos têm estado e é idiomático alterar o estado, fazendo com que seu histórico simplesmente desapareça ... a menos que você escreva manualmente o código para acompanhá-lo. De que maneira o rastreamento do estado "o que eles fazem"?

Além disso, os benefícios da imutabilidade diminuem conforme os objetos imutáveis ​​ficam maiores.

Certo, como eu disse, "é raro que qualquer uma das minhas funções seja pura". Eles sempre operam apenas em seus parâmetros, mas eles os modificam. Esse é um compromisso que senti que tinha que fazer ao aplicar esse padrão ao JavaScript.

Daniel Kaplan
fonte
4
"O primeiro parâmetro de uma função pode ser nomeado foo, mas o que é isso?" É por isso que você não nomeia seus parâmetros "foo", mas "repetições", "pai" e outros nomes que tornam óbvio o que é esperado quando combinado com o nome da função.
Sebastian Redl
11
Eu tenho que concordar com você em todos os pontos. O único problema que o Javascript realmente coloca nesse padrão é que você está trabalhando em dados mutáveis ​​e, como tal, é muito provável que mude de estado. Existe, no entanto, uma biblioteca que dá acesso a estruturas de dados de clojure em javascript simples, embora eu esqueça o que é chamado. Passar argumentos como um objeto também não é inédito, o jquery faz isso em vários lugares, mas documenta quais partes do objeto eles usam. Pessoalmente, porém, eu iria separar UI-campos e GameLogic-campos, mas que funciona para você :)
Robin Heggelund Hansen
@SebastianRedl O que devo aprovar parent? É repetitionse uma matriz de números ou seqüências de caracteres ou isso não importa? Ou talvez repetições seja apenas um número para representar o número de repetições que eu quero? Há muitas APIs por aí que apenas pegam um objeto de opções . O mundo é um lugar melhor se você nomear as coisas corretamente, mas não garante que você saiba como usar a API, sem fazer perguntas.
Daniel Kaplan
8

Eu descobri que meu código tende a acabar estruturado da seguinte maneira:

  • As funções que recebem mapas tendem a ser maiores e têm efeitos colaterais.
  • Funções que recebem argumentos tendem a ser menores e são puras.

Eu não pretendia criar essa distinção, mas é assim que muitas vezes acaba no meu código. Não acho que o uso de um estilo negue necessariamente o outro.

As funções puras são fáceis de testar na unidade. Os maiores com mapas entram mais na área de teste de "integração", pois tendem a envolver mais partes móveis.

Em javascript, uma coisa que ajuda muito é usar algo como a biblioteca Match do Meteor para executar a validação de parâmetros. Isso deixa muito claro o que a função espera e pode lidar com mapas de maneira bastante limpa.

Por exemplo,

function foo (post) {
  check(post, {
    text: String,
    timestamp: Date,
    // Optional, but if present must be an array of strings
    tags: Match.Optional([String])
    });

  // do stuff
}

Consulte http://docs.meteor.com/#match para obter mais informações.

:: ATUALIZAÇÃO ::

A gravação em vídeo de Stuart Sierra de "Clojure in the Large", de Clojure / West, também aborda esse assunto. Como o OP, ele controla os efeitos colaterais como parte do mapa, para que os testes se tornem muito mais fáceis. Ele também tem uma postagem no blog descrevendo seu fluxo de trabalho atual do Clojure que parece relevante.

alanning
fonte
11
Acho que meus comentários a @Evicatos serão elaborados sobre minha posição aqui. Sim, estou mudando e as funções não são puras. Mas, minhas funções são realmente fáceis de testar, especialmente em retrospectiva para defeitos de regressão que eu não planejava testar. Metade do crédito é para JS: é muito fácil construir um "mapa" / objeto com apenas os dados necessários para o meu teste. Então é tão simples quanto passar e verificar as mutações. Os efeitos colaterais são sempre representados no mapa, por isso são fáceis de testar.
Daniel Kaplan
11
Eu acredito que o uso pragmático de ambos os métodos é o caminho "correto" a seguir. Se o teste é fácil para você e você pode atenuar o meta problema de comunicar os campos obrigatórios a outros desenvolvedores, isso soa como uma vitória. Obrigado por sua pergunta; Gostei de ler a interessante discussão que você iniciou.
alanning
5

O principal argumento que posso pensar contra essa prática é que é muito difícil dizer quais dados uma função realmente precisa.

O que isso significa é que futuros programadores na base de código precisarão saber como a função que está sendo chamada funciona internamente - e qualquer função aninhada - para chamá-la.

Quanto mais eu penso sobre isso, mais o seu objeto gameState cheira a um global. Se é assim que está sendo usado, por que distribuí-lo?

Mike Partridge
fonte
11
Sim, já que eu normalmente modifico, é global. Por que se incomodar em passar por aí? Não sei, é uma pergunta válida. Mas meu instinto me diz que, se eu parasse de passar, meu programa se tornaria instantaneamente mais difícil de raciocinar. Toda função pode fazer tudo ou nada ao estado global. Do jeito que está agora, você vê esse potencial na assinatura da função. Se você não pode dizer, eu não estou confiante sobre nada disso :)
Daniel Kaplan
11
BTW: re: o principal argumento contra: isso parece verdadeiro, seja em clojure ou javascript. Mas é um ponto valioso a ser feito. Talvez os benefícios listados superem em muito os negativos.
Daniel Kaplan
2
Agora eu sei por que me incomodo em distribuí-lo, mesmo que seja uma variável global: ela me permite escrever funções puras. Se eu mudar gameState = f(gameState)para f(), é muito mais difícil testar f. f()pode retornar uma coisa diferente cada vez que eu ligar. Mas é fácil fazer f(gameState)o mesmo com a devolução sempre que receber a mesma informação.
Daniel Kaplan
3

Há um nome mais apropriado para o que você está fazendo do que uma grande bola de barro . O que você está fazendo é chamado de padrão de objeto de Deus . Não parece assim à primeira vista, mas em Javascript há muito pouca diferença entre

update(gameState)
  ...
  gameState = handleUnitCollision(gameState)
  ...
  gameState = handleLoot(gameState)
  ...

e

{
  ...
  handleUnitCollision: function() {
    ...
  },
  ...
  handleLoot: function() {
    ...
  },
  ...
  update: function() {
    ...
    this.handleUnitCollision()
    ...
    this.handleLoot()
    ...
  },
  ...
};

Se é uma boa ideia ou não, provavelmente depende das circunstâncias. Mas certamente está de acordo com a maneira Clojure. Um dos objetivos do Clojure é remover o que Rich Hickey chama de "complexidade incidental". Vários objetos de comunicação são certamente mais complexos que um único objeto. Se você dividir a funcionalidade em vários objetos, de repente precisa se preocupar com comunicação, coordenação e divisão de responsabilidades. Essas são complicações apenas incidentais ao seu objetivo original de escrever um programa. Você deve ver a conversa de Rich Hickey sobre Simple facilitada . Eu acho que essa é uma ideia muito boa.

user7610
fonte
Relacionado, questão mais geral [ programmers.stackexchange.com/questions/260309/... modelagem de dados vs aulas tradicionais)
user7610
"Na programação orientada a objetos, um objeto god é um objeto que sabe demais ou sabe demais. O objeto god é um exemplo de um antipadrão". Portanto, o objeto deus não é uma coisa boa, mas sua mensagem parece estar dizendo o contrário. Isso é um pouco confuso para mim.
Daniel Kaplan
@tieTYT você não está fazendo programação orientada a objetos, por isso é ok
user7610
Como você chegou a essa conclusão (a "está tudo bem")?
Daniel Kaplan
O problema com o objeto de Deus no OO é que "o objeto se torna tão consciente de tudo ou todos os objetos se tornam tão dependentes do objeto de Deus que, quando há uma mudança ou um bug a ser corrigido, torna-se um verdadeiro pesadelo a ser implementado". fonte No seu código, há outro objeto ao lado do objeto Deus; portanto, a segunda parte não é um problema. Com relação à primeira parte, seu objeto de Deus é o programa e seu programa deve estar ciente de tudo. Então, tudo bem.
user7610
2

Eu meio que enfrentei esse tópico hoje cedo enquanto brincava com um novo projeto. Estou trabalhando no Clojure para fazer um jogo de pôquer. Eu representei valores de face e naipes como palavras-chave e decidi representar um cartão como um mapa como

{ :face :queen :suit :hearts }

Eu poderia ter feito listas ou vetores dos dois elementos da palavra-chave. Não sei se isso faz diferença na memória / desempenho, por isso só vou usar mapas por enquanto.

No entanto, caso mude de ideia mais tarde, decidi que a maior parte do meu programa deveria passar por uma "interface" para acessar as partes de um cartão, para que os detalhes da implementação fossem controlados e ocultos. Eu tenho funções

(defn face [card] (card :face))
(defn suit [card] (card :suit))

que o resto do programa usa. Os cartões são passados ​​para funções como mapas, mas as funções usam uma interface combinada para acessar os mapas e, portanto, não devem ser capazes de bagunçar.

No meu programa, um cartão provavelmente será apenas um mapa de dois valores. Na pergunta, todo o estado do jogo é passado como um mapa. O estado do jogo será muito mais complicado do que uma única carta, mas não acho que exista uma falha no uso de um mapa. Em uma linguagem imperativa a objetos, eu poderia também ter um único objeto GameState grande e chamar seus métodos, e ter o mesmo problema:

class State
  def complex-process()
    state = clone(this) ; or just use 'this' below if mutation is fine
    state.subprocess-one()
    state.subprocess-two()
    state.subprocess-three()
    return state

Agora é orientado a objetos. Existe algo particularmente errado com isso? Acho que não, você está apenas delegando trabalho para funções que sabem como lidar com um objeto State. E se você estiver trabalhando com mapas ou objetos, deve ter cuidado com o momento de dividi-lo em pedaços menores. Então, eu digo que usar mapas é perfeitamente adequado, desde que você use o mesmo cuidado que usaria com objetos.

xen
fonte
2

Pelo que (pouco) eu já vi, usar mapas ou outras estruturas aninhadas para criar um único objeto imutável de estado global como esse é bastante comum em linguagens funcionais, pelo menos as puras, especialmente quando se usa a Mônada do Estado como @ Ptharien'sFlame mentioend .

Dois obstáculos para usar isso efetivamente que eu já vi / li (e outras respostas aqui mencionaram) são:

  • Mutando um valor aninhado (profundamente) no estado (imutável)
  • Escondendo a maioria do estado de funções que não precisam dele e dando a eles o pouco que precisam para trabalhar / mudar

Existem algumas técnicas diferentes / padrões comuns que podem ajudar a aliviar esses problemas:

O primeiro é o Zíper : permite atravessar e mudar o estado profundamente dentro de uma hierarquia imutável e aninhada.

Outra é a Lentes : elas permitem que você se concentre na estrutura em um local específico e leia / modifique o valor lá. Você pode combinar diferentes lentes para se concentrar em coisas diferentes, como uma cadeia de propriedades ajustável no OOP (onde você pode substituir variáveis ​​por nomes de propriedades reais!)

A Prismatic publicou recentemente um blog sobre esse tipo de técnica, entre outras coisas, em JavaScript / ClojureScript, que você deve conferir. Eles usam os Cursores (que são comparados aos zíperes) para exibir o estado das funções:

Om restaura o encapsulamento e a modularidade usando cursores. Os cursores fornecem janelas que podem ser atualizadas em partes específicas do estado do aplicativo (como zíperes), permitindo que os componentes tomem referências apenas às partes relevantes do estado global e atualizem-nas de maneira livre de contexto.

IIRC, eles também abordam a imutabilidade no JavaScript nesse post.

Paulo
fonte
O OP de conversa mencionado também discute o uso da função de atualização para limitar o escopo que uma função pode atualizar para uma subárvore do mapa de estado. Eu acho que ninguém trouxe isso ainda.
user7610
@ user7610 Boa captura, não acredito que esqueci de mencionar essa - gosto dessa função (e assoc-inoutras). Acho que eu tinha Haskell no cérebro. Gostaria de saber se alguém fez uma porta JavaScript dele? As pessoas provavelmente não levá-la porque (como eu) que não assistir a palestra :)
paul
@paul em um sentido que eles têm porque está disponível no ClojureScript, mas não tenho certeza se isso "conta" em sua mente. Pode existir no PureScript e acredito que haja pelo menos uma biblioteca que fornece estruturas de dados imutáveis ​​em JavaScript. Eu espero que pelo menos um deles tenha, caso contrário eles seriam inconvenientes de usar.
Daniel Kaplan
@tieTYT Eu estava pensando em uma implementação JS nativa quando fiz esse comentário, mas você faz um bom argumento sobre o ClojureScript / PureScript. Eu deveria olhar para JS imutável e ver o que há por aí, eu não trabalhei com isso antes.
paul
1

Se essa é uma boa ideia ou não, realmente dependerá do que você está fazendo com o estado dentro desses subprocessos. Se eu entendi o exemplo Clojure corretamente, os dicionários de estado que estão sendo retornados não são os mesmos dicionários de estado que estão sendo transmitidos. São cópias, possivelmente com adições e modificações, que (presumo) o Clojure é capaz de criar eficientemente porque o a natureza funcional da linguagem depende disso. Os dicionários de estado originais para cada função não são modificados de forma alguma.

Se estou entendendo corretamente, você está modificando os objetos de estado que passa para as funções javascript em vez de retornar uma cópia, o que significa que você está fazendo algo muito, muito, diferente do que o código Clojure está fazendo. Como Mike Partridge apontou, isso é basicamente apenas um global para o qual você explicitamente repassa e retorna funções sem motivo real. Neste ponto, acho que é simplesmente fazer você pensar que está fazendo algo que realmente não está.

Se você realmente estiver fazendo cópias explícitas do estado, modificando-o e retornando a cópia modificada, continue. Não sei se essa é necessariamente a melhor maneira de realizar o que você está tentando fazer em Javascript, mas provavelmente é "íntima" do que o exemplo do Clojure está fazendo.

Evicatos
fonte
11
No final, é realmente "muito, muito diferente"? No exemplo de Clojure, ele substitui seu antigo estado pelo novo. Sim, não há mutação real acontecendo, a identidade está apenas mudando. Mas, em seu exemplo "bom", como está escrito, ele não tem como obter a cópia que foi passada para o subprocesso dois. A identificação desse valor foi substituída. Portanto, acho que a coisa "muito, muito diferente" é realmente apenas um detalhe de implementação de linguagem. Pelo menos no contexto do que você menciona.
Daniel Kaplan
2
Há duas coisas acontecendo com o exemplo Clojure: 1) o primeiro exemplo depende das funções serem chamadas em uma determinada ordem e 2) as funções são puras para que não tenham efeitos colaterais. Como as funções no segundo exemplo são puras e compartilham a mesma assinatura, é possível reordená-las sem precisar se preocupar com dependências ocultas na ordem em que são chamadas. Se você estiver modificando o estado em suas funções, não terá a mesma garantia. Mutação de estado significa que sua versão não é tão comporável, que foi o motivo original do dicionário.
Evicatos
11
Você terá que me mostrar um exemplo, porque na minha experiência com isso, posso mudar as coisas à vontade e isso tem muito pouco efeito. Só para provar isso, movi duas chamadas aleatórias de subprocesso no meio da minha update()função. Mudei um para o topo e outro para o fundo. Todos os meus testes ainda passaram e, quando joguei, não notei efeitos negativos. Eu sinto minhas funções são apenas como combináveis como o exemplo Clojure. Nós dois estamos jogando fora nossos dados antigos após cada etapa.
Daniel Kaplan
11
Seus testes são aprovados e não percebem efeitos negativos significa que você não está atualmente alterando nenhum estado que tenha efeitos colaterais inesperados em outros lugares. Como suas funções não são puras, você não tem garantia de que sempre será esse o caso. Eu acho que devo estar entendendo fundamentalmente algo sobre sua implementação se você disser que suas funções não são puras, mas você está jogando fora seus dados antigos após cada etapa.
Evicatos
11
@ Evicatos - Ser puro e ter a mesma assinatura não significa que a ordem das funções não importa. Imagine calcular um preço com descontos fixos e percentuais aplicados. (-> 10 (- 5) (/ 2))retorna 2,5. (-> 10 (/ 2) (- 5))retorna 0.
Zak
1

Se você possui um objeto de estado global, às vezes chamado de "objeto divino", que é passado para cada processo, você acaba confundindo vários fatores, todos aumentando o acoplamento e diminuindo a coesão. Todos esses fatores afetam negativamente a manutenção de longo prazo.

Tramp Coupling Isso decorre da passagem de dados por vários métodos que não precisam de quase todos os dados, a fim de levá-los ao local que realmente pode lidar com eles. Esse tipo de acoplamento é semelhante ao uso de dados globais, mas pode ser mais contido. O acoplamento tramp é o oposto da "necessidade de saber", que é usada para localizar efeitos e para conter os danos que um pedaço de código incorreto pode causar em todo o sistema.

Navegação de dados Todo subprocesso no seu exemplo precisa saber exatamente como chegar aos dados de que precisa, e precisa ser capaz de processá-los e talvez construir um novo objeto de estado global. Essa é a consequência lógica do acoplamento tramp; todo o contexto de um dado é necessário para operar no dado. Novamente, o conhecimento não local é uma coisa ruim.

Se você estava passando um "zíper", "lente" ou "cursor", conforme descrito na postagem de @paul, isso é uma coisa. Você deve conter o acesso e permitir que o zíper, etc., controle a leitura e gravação dos dados.

Violação de responsabilidade única Reivindicar cada um dos "subprocessos um", "subprocessos dois" e "subprocessos três" possui apenas uma única responsabilidade, a saber, produzir um novo objeto de estado global com os valores certos, é um reducionismo flagrante. No final, são todos os bits, não é?

O que quero dizer aqui é que ter todos os principais componentes do seu jogo tem as mesmas responsabilidades que o seu jogo derrota o propósito da delegação e fatoração.

Impacto nos Sistemas

O principal impacto do seu projeto é a baixa manutenção. O fato de você poder manter o jogo inteiro em sua mente diz que você provavelmente é um excelente programador. Há muitas coisas que eu projetei que eu poderia manter na minha cabeça durante todo o projeto. Esse não é o objetivo da engenharia de sistemas. O objetivo é criar um sistema que funcione para algo maior que uma pessoa possa manter em sua mente ao mesmo tempo .

A adição de outro programador, ou dois ou oito, fará com que o seu sistema se desfaça quase imediatamente.

  1. A curva de aprendizado dos objetos divinos é plana (isto é, leva muito tempo para se tornar competente neles). Cada programador adicional precisará aprender tudo o que você sabe e mantê-lo na cabeça. Você só poderá contratar programadores melhores do que você, supondo que possa pagá-los o suficiente para sofrer com a manutenção de um objeto divino maciço.
  2. O aprovisionamento de teste, na sua descrição, é apenas caixa branca. Você precisa conhecer todos os detalhes do objeto god, bem como o módulo em teste, para configurar um teste, executá-lo e determinar que a) fez a coisa certa eb) não fez nada de 10.000 coisas erradas. As probabilidades estão muito contra você.
  3. A adição de um novo recurso requer que você a) realize todos os subprocessos e determine se o recurso afeta algum código nele e vice-versa, b) passe pelo seu estado global e projete as adições ec) faça todos os testes de unidade e modifique-o para verificar se nenhuma unidade em teste afetou adversamente o novo recurso .

Finalmente

  1. Objetos divinos mutáveis ​​têm sido a maldição da minha existência em programação, alguns dos meus próprios trabalhos e outros nos quais eu fiquei preso.
  2. A mônada do estado não é escalável. O Estado cresce exponencialmente, com todas as implicações para testes e operações que isso implica. A maneira como controlamos o estado nos sistemas modernos é por delegação (particionamento de responsabilidades) e escopo (restringindo o acesso a apenas um subconjunto do estado). A abordagem "tudo é um mapa" é exatamente o oposto do estado de controle.
BobDalgleish
fonte