"Todo es un mapa", ¿estoy haciendo esto bien?

69

Vi la charla de Stuart Sierra " Thinking In Data " y tomé una de las ideas como principio de diseño en este juego que estoy haciendo. La diferencia es que él está trabajando en Clojure y yo estoy trabajando en JavaScript. Veo algunas diferencias importantes entre nuestros idiomas en eso:

  • Clojure es una programación idiomáticamente funcional
  • La mayoría del estado es inmutable.

Tomé la idea de la diapositiva "Todo es un mapa" (de 11 minutos, 6 segundos a> 29 minutos). Algunas cosas que dice son:

  1. Siempre que vea una función que tome 2-3 argumentos, puede hacer un caso para convertirla en un mapa y simplemente pasar un mapa. Hay muchas ventajas en eso:
    1. No tiene que preocuparse por el orden de los argumentos.
    2. No tiene que preocuparse por ninguna información adicional. Si hay claves adicionales, esa no es realmente nuestra preocupación. Simplemente fluyen, no interfieren.
    3. No tienes que definir un esquema
  2. A diferencia de pasar un objeto, no hay datos ocultos. Pero argumenta que el ocultamiento de datos puede causar problemas y está sobrevalorado:
    1. Actuación
    2. Facilidad de implementación
    3. Tan pronto como se comunique a través de la red o entre procesos, debe hacer que ambas partes acuerden la representación de datos de todos modos. Ese es un trabajo adicional que puede omitir si solo trabaja en datos.
  3. Lo más relevante para mi pregunta. Esto es 29 minutos en: "Haga que sus funciones sean componibles". Aquí está el ejemplo de código que usa para explicar el concepto:

    ;; 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))
    

    Entiendo que la mayoría de los programadores no están familiarizados con Clojure, por lo que volveré a escribir esto en un estilo imperativo:

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

    Aquí están las ventajas:

    1. Fácil de probar
    2. Fácil de ver esas funciones de forma aislada
    3. Es fácil comentar una línea de esto y ver cuál es el resultado al eliminar un solo paso
    4. Cada subproceso podría agregar más información al estado. Si el subproceso uno necesita comunicar algo al subproceso tres, es tan simple como agregar una clave / valor.
    5. No hay repeticiones para extraer los datos que necesita fuera del estado solo para que pueda guardarlos nuevamente. Simplemente pase todo el estado y deje que el subproceso asigne lo que necesita.

Ahora, volviendo a mi situación: tomé esta lección y la apliqué a mi juego. Es decir, casi todas mis funciones de alto nivel toman y devuelven un gameStateobjeto. Este objeto contiene todos los datos del juego. EG: Una lista de badGuys, una lista de menús, el botín en el suelo, etc. Aquí hay un ejemplo de mi función de actualización:

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

De lo que estoy aquí para preguntar es, ¿he creado alguna abominación que pervierte una idea que solo es práctica en un lenguaje de programación funcional? JavaScript no es idiomáticamente funcional (aunque se puede escribir de esa manera) y es realmente difícil escribir estructuras de datos inmutables. Una cosa que me preocupa es que asume que cada uno de esos subprocesos es puro. ¿Por qué es necesario hacer esa suposición? Es raro que cualquiera de mis funciones sean puras (con eso quiero decir que a menudo modifican gameState. No tengo otros efectos secundarios complicados aparte de eso). ¿Estas ideas se desmoronan si no tienes datos inmutables?

Me preocupa que algún día me despierte y me dé cuenta de que todo este diseño es una farsa y realmente he estado implementando el antipatrón Big Ball Of Mud .


Honestamente, he estado trabajando en este código durante meses y ha sido genial. Siento que estoy obteniendo todas las ventajas que él reclama. Mi código es muy fácil de razonar. Pero soy un equipo de un solo hombre, así que tengo la maldición del conocimiento.

Actualizar

He estado codificando más de 6 meses con este patrón. Por lo general, en este momento me olvido de lo que he hecho y ahí es donde "¿escribí esto de manera limpia?" entra en juego. Si no lo hubiera hecho, realmente lucharía. Hasta ahora, no estoy luchando en absoluto.

Entiendo cómo sería necesario otro par de ojos para validar su mantenibilidad. Todo lo que puedo decir es que me importa la mantenibilidad en primer lugar. Siempre soy el evangelista más ruidoso para el código limpio, sin importar dónde trabajo.

Quiero responder directamente a aquellos que ya tienen una mala experiencia personal con esta forma de codificación. Entonces no lo sabía, pero creo que realmente estamos hablando de dos formas diferentes de escribir código. La forma en que lo hice parece estar más estructurada de lo que otros han experimentado. Cuando alguien tiene una mala experiencia personal con "Todo es un mapa", hablan de lo difícil que es mantenerlo porque:

  1. Nunca se sabe la estructura del mapa que requiere la función
  2. Cualquier función puede mutar la entrada de formas que nunca esperarías. Debe buscar en toda la base del código para descubrir cómo una clave en particular ingresó al mapa o por qué desapareció.

Para aquellos con tal experiencia, tal vez la base del código fue: "Todo toma 1 de N tipos de mapas". El mío es, "Todo toma 1 de 1 tipo de mapa". Si conoce la estructura de ese tipo 1, conoce la estructura de todo. Por supuesto, esa estructura generalmente crece con el tiempo. Es por eso...

Hay un lugar para buscar la implementación de referencia (es decir, el esquema). Esta implementación de referencia es un código que usa el juego para que no se desactualice.

En cuanto al segundo punto, no agrego / elimino claves al mapa fuera de la implementación de referencia, solo muto lo que ya está allí. También tengo un gran conjunto de pruebas automatizadas.

Si esta arquitectura finalmente colapsa bajo su propio peso, agregaré una segunda actualización. De lo contrario, suponga que todo va bien :)

Daniel Kaplan
fuente
2
Buena pregunta (+1)! Me parece un ejercicio muy útil para tratar de implementar modismos funcionales en un lenguaje no funcional (o no muy funcional).
Giorgio
15
Cualquiera que le diga que ocultar información de estilo OO (con propiedades y funciones de acceso) es algo malo debido al impacto en el rendimiento (generalmente insignificante), y luego le dice que convierta todos sus parámetros en un mapa, lo que le brinda la sobrecarga (mucho mayor) de una búsqueda de hash cada vez que intenta recuperar un valor puede ignorarse de forma segura.
Mason Wheeler
44
@MasonWheeler digamos que tienes razón sobre esto. ¿Va a anular cualquier otro punto que haga porque esto está mal?
Daniel Kaplan
9
En Python (y creo que la mayoría de los lenguajes dinámicos, incluido Javascript), el objeto es realmente solo la sintaxis de azúcar para un dict / map de todos modos.
Lie Ryan
66
@EvanPlaice: la notación Big-O puede ser engañosa. El hecho simple es que cualquier cosa es lenta en comparación con el acceso directo con dos o tres instrucciones de código de máquina individuales, y en algo que sucede con tanta frecuencia como una llamada de función, esa sobrecarga se acumulará muy rápidamente.
Mason Wheeler

Respuestas:

42

He admitido una aplicación donde 'todo es un mapa' antes. Es una idea terrible. ¡POR FAVOR no lo hagas!

Cuando especifica los argumentos que se pasan a la función, eso hace que sea muy fácil saber qué valores necesita la función. Evita pasar datos extraños a la función que solo distrae al programador: cada valor pasado implica que es necesario, y eso hace que el programador que admite su código tenga que descubrir por qué se necesitan los datos.

Por otro lado, si pasa todo como un mapa, el programador que admite su aplicación tendrá que comprender completamente la función llamada en todos los sentidos para saber qué valores debe contener el mapa. Peor aún, es muy tentador reutilizar el mapa pasado a la función actual para pasar datos a las siguientes funciones. Esto significa que el programador que admite su aplicación necesita conocer todas las funciones llamadas por la función actual para comprender qué hace la función actual. Eso es exactamente lo opuesto al propósito de escribir funciones: abstraer los problemas para que no tenga que pensar en ellos. Ahora imagine 5 llamadas de profundidad y 5 llamadas de ancho cada una. Es mucho tener en mente y muchos errores para cometer.

"todo es un mapa" también parece llevar a usar el mapa como valor de retorno. Lo he visto. Y, de nuevo, es un dolor. Las funciones llamadas nunca deben sobrescribirse entre sí, a menos que conozca la funcionalidad de todo y sepa que el valor X del mapa de entrada debe reemplazarse para la siguiente llamada a la función. Y la función actual necesita modificar el mapa para devolver su valor, que a veces debe sobrescribir el valor anterior y otras no.

editar - ejemplo

Aquí hay un ejemplo de dónde esto fue problemático. Esta fue una aplicación web. La entrada del usuario se aceptó desde la capa de IU y se colocó en un mapa. Luego se llamaron a las funciones para procesar la solicitud. El primer conjunto de funciones verificaría la entrada errónea. Si hubiera un error, el mensaje de error se colocaría en el mapa. La función de llamada verificaría el mapa para esta entrada y escribiría el valor en la interfaz de usuario si existiera.

El siguiente conjunto de funciones iniciaría la lógica empresarial. Cada función tomaría el mapa, eliminaría algunos datos, modificaría algunos datos, operaría sobre los datos en el mapa y colocaría el resultado en el mapa, etc. Las funciones posteriores esperarían resultados de funciones anteriores en el mapa. Para corregir un error en una función posterior, tuvo que investigar todas las funciones anteriores, así como la persona que llama para determinar en todas partes el valor esperado podría haberse establecido.

Las siguientes funciones extraerían datos de la base de datos. O, más bien, pasarían un mapa a la capa de acceso a datos. El DAL verificará si el mapa contiene ciertos valores para controlar cómo se ejecutó la consulta. Si 'justcount' fuera una clave, entonces la consulta sería 'count select foo from bar'. Cualquiera de las funciones que se llamó anteriormente podría haber sido la que agregó 'justcount' al mapa. Los resultados de la consulta se agregarían al mismo mapa.

Los resultados llegarían a la persona que llama (lógica de negocios) que verificaría el mapa para saber qué hacer. Algo de esto provendría de cosas que fueron agregadas al mapa por la lógica comercial inicial. Algunos vendrían de los datos de la base de datos. La única forma de saber de dónde vino fue encontrar el código que lo agregó. Y la otra ubicación que también puede agregarlo.

El código era efectivamente un desastre monolítico, que tenía que entender en su totalidad para saber de dónde provenía una sola entrada en el mapa.

atk
fuente
2
Tu segundo párrafo tiene sentido para mí y eso realmente suena horrible. Sin embargo, tengo la sensación de su tercer párrafo de que no estamos hablando realmente del mismo diseño. "reutilizar" es el punto. Sería un error evitarlo. Y realmente no puedo relacionarme con tu último párrafo. Tengo todas las funciones tomadas gameStatesin saber nada sobre lo que sucedió antes o después. Simplemente reacciona a los datos que se proporcionan. ¿Cómo llegaste a una situación en la que las funciones pisarían los dedos de los demás? ¿Puede dar un ejemplo?
Daniel Kaplan
2
Agregué un ejemplo para tratar de hacerlo un poco más claro. Espero eso ayude. Además, hay una diferencia entre pasar alrededor de un objeto de estado bien definido frente a pasar alrededor de un blob que establece cambios por muchos lugares por muchas razones, mezclando lógica de interfaz de usuario, lógica de negocios y lógica de acceso a la base de datos
atk
28

Personalmente, no recomendaría ese patrón en ninguno de los paradigmas. Hace que sea más fácil escribir inicialmente a expensas de que sea más difícil razonar más adelante.

Por ejemplo, intente responder las siguientes preguntas sobre cada función de subproceso:

  • ¿Qué campos de staterequiere?
  • ¿Qué campos modifica?
  • ¿Qué campos no han cambiado?
  • ¿Se puede reorganizar con seguridad el orden de las funciones?

Con este patrón, no puede responder esas preguntas sin leer toda la función.

En un lenguaje orientado a objetos, el patrón tiene aún menos sentido, porque el estado de seguimiento es lo que hacen los objetos.

Karl Bielefeldt
fuente
2
"los beneficios de la inmutabilidad disminuyen cuanto más grandes se vuelven tus objetos inmutables" ¿Por qué? ¿Es eso un comentario sobre rendimiento o mantenibilidad? Por favor, explique esa oración.
Daniel Kaplan
8
@tieTYT Los inmutables funcionan bien cuando hay algo pequeño (un tipo numérico, por ejemplo). Puede copiarlos, crearlos, descartarlos, sobrecargarlos con un costo bastante bajo. Cuando comienzas a lidiar con estados de juego completos formados por mapas profundos y grandes, árboles, listas y docenas, si no cientos de variables, el costo de copiarlo o eliminarlo aumenta (y los pesos mosca se vuelven poco prácticos).
3
Veo. ¿Es un problema de "datos inmutables en idiomas imperitivos" o un problema de "datos inmutables"? IE: Tal vez esto no sea un problema en el código Clojure. Pero puedo ver cómo es en JS. También es difícil escribir todo el código repetitivo para hacerlo.
Daniel Kaplan
3
@MichaelT y Karl: para ser justos, realmente debería mencionar el otro lado de la historia de inmutabilidad / eficiencia. Sí, el uso ingenuo puede ser terriblemente ineficiente, por eso las personas han ideado mejores enfoques. Vea el trabajo de Chris Okasaki para más información.
3
@MattFenwick Personalmente me gustan los inmutables. Cuando se trata de enhebrar, sé cosas sobre lo inmutable y puedo trabajar y copiarlas de forma segura. Lo puse en una llamada de parámetro y se lo paso a otro sin preocuparme de que alguien lo modifique cuando vuelva a mí. Si se habla de un estado de juego complejo (la pregunta lo usó como un ejemplo; me horrorizaría pensar que algo es "simple" como el estado de juego de nethack como inmutable), la inmutabilidad es probablemente el enfoque equivocado.
12

Lo que parece estar haciendo es, efectivamente, una mónada de Estado manual; lo que haría es construir un combinador de enlaces (simplificado) y volver a expresar las conexiones entre sus pasos lógicos usando eso:

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,
);

Incluso puede usar stateBindpara construir los diversos subprocesos a partir de subprocesos y continuar por un árbol de combinadores de enlace para estructurar su cálculo de manera adecuada.

Para obtener una explicación de la mónada estatal completa y no simplificada, y una excelente introducción a las mónadas en general en JavaScript, consulte esta publicación de blog .

Llama de Ptharien
fuente
1
OK, lo investigaré (y comentaré más adelante). Pero, ¿qué te parece la idea de usar el patrón?
Daniel Kaplan
1
@tieTYT Creo que el patrón en sí es una muy buena idea; La mónada estatal en general es una herramienta útil de estructuración de código para algoritmos pseudomutables (algoritmos que son inmutables pero que emulan la mutabilidad).
Ptharien's Flame
2
+1 por notar que este patrón es esencialmente una mónada. Sin embargo, no estoy de acuerdo con que sea una buena idea en un lenguaje que realmente tenga mutabilidad. Monad es una forma de proporcionar la capacidad de estado global / mutable en un lenguaje que no permite la mutación. En mi opinión, en un lenguaje que no impone la inmutabilidad, el patrón Monad es solo la masturbación mental.
Lie Ryan
66
@LieRyan Las mónadas en general en realidad no tienen nada que ver con la mutabilidad o las variables globales; solo la mónada del Estado lo hace específicamente (porque para eso está diseñado en particular). También estoy en desacuerdo con que la mónada estatal no es útil en un lenguaje con mutabilidad, aunque una implementación que dependa de la mutabilidad subyacente podría ser más eficiente que la inmutable que di (aunque no estoy del todo seguro de eso). La interfaz monádica puede proporcionar capacidades de alto nivel que de otra manera no serían fácilmente accesibles, el stateBindcombinador que le di es un ejemplo muy simple de eso.
Llama de Ptharien
1
@LieRyan Respaldo el comentario de Ptharien: la mayoría de las mónadas no están relacionadas con el estado o la mutabilidad, e incluso la que sí lo está, específicamente no se trata del estado global . Las mónadas en realidad funcionan bastante bien en lenguajes OO / imperativo / mutable.
11

Entonces, parece haber mucha discusión entre la efectividad de este enfoque en Clojure. Creo que podría ser útil analizar la filosofía de Rich Hickey de por qué creó Clojure para admitir abstracciones de datos de esta manera :

Fogus: Entonces, una vez que se han reducido las complejidades incidentales, ¿cómo puede ayudar Clojure a resolver el problema en cuestión? Por ejemplo, el paradigma idealizado orientado a objetos está destinado a fomentar la reutilización, pero Clojure no está orientado a objetos clásicos: ¿cómo podemos estructurar nuestro código para su reutilización?

Hickey: Discutiría sobre OO y reutilizaría, pero ciertamente, poder reutilizar las cosas simplifica el problema, ya que no reinventa las ruedas en lugar de construir automóviles. Y Clojure estando en la JVM hace que muchas ruedas, bibliotecas, estén disponibles. ¿Qué hace que una biblioteca sea reutilizable? Debería hacer bien una o unas pocas cosas, ser relativamente autosuficiente y hacer pocas demandas sobre el código del cliente. Nada de eso queda fuera de OO, y no todas las bibliotecas Java cumplen con este criterio, pero muchas lo hacen.

Cuando bajamos al nivel de algoritmo, creo que OO puede frustrar seriamente la reutilización. En particular, el uso de objetos para representar datos informativos simples es casi criminal en su generación de micro-lenguajes por pieza de información, es decir, los métodos de clase, en comparación con métodos mucho más potentes, declarativos y genéricos como el álgebra relacional. Inventar una clase con su propia interfaz para contener una pieza de información es como inventar un nuevo idioma para escribir cada cuento. Esto es anti-reutilización y, creo, da como resultado una explosión de código en aplicaciones OO típicas. Clojure evita esto y, en cambio, aboga por un modelo asociativo simple de información. Con él, se pueden escribir algoritmos que se pueden reutilizar en todos los tipos de información.

Este modelo asociativo no es más que una de varias abstracciones suministradas con Clojure, y estos son los verdaderos fundamentos de su enfoque para la reutilización: funciones en abstracciones. Tener un conjunto abierto y grande de funciones opera sobre un conjunto abierto y pequeño de abstracciones extensibles es la clave para la reutilización algorítmica y la interoperabilidad de la biblioteca. La gran mayoría de las funciones de Clojure se definen en términos de estas abstracciones, y los autores de bibliotecas también diseñan sus formatos de entrada y salida en términos de ellas, realizando una tremenda interoperabilidad entre bibliotecas desarrolladas independientemente. Esto está en marcado contraste con los DOM y otras cosas que ves en OO. Por supuesto, puede hacer una abstracción similar en OO con interfaces, por ejemplo, las colecciones java.util, pero no puede hacerlo tan fácilmente como en java.io.

Fogus reitera estos puntos en su libro Functional Javascript :

A lo largo de este libro, adoptaré el enfoque de usar tipos de datos mínimos para representar abstracciones, desde conjuntos hasta árboles y tablas. Sin embargo, en JavaScript, aunque sus tipos de objetos son extremadamente potentes, las herramientas proporcionadas para trabajar con ellos no son completamente funcionales. En cambio, el patrón de uso más grande asociado con los objetos de JavaScript es adjuntar métodos para fines de despacho polimórfico. Afortunadamente, también puede ver un objeto JavaScript sin nombre (no construido a través de una función de constructor) simplemente como un almacén de datos asociativo.

Si las únicas operaciones que podemos realizar en un objeto Libro o una instancia de un tipo Empleado son setTitle o getSSN, entonces hemos bloqueado nuestros datos en micro-idiomas por pieza de información (Hickey 2011). Un enfoque más flexible para modelar datos es una técnica de datos asociativos. Los objetos JavaScript, incluso menos la maquinaria prototipo, son vehículos ideales para el modelado de datos asociativos, donde los valores nombrados pueden estructurarse para formar modelos de datos de nivel superior, a los que se accede de manera uniforme.

Aunque las herramientas para manipular y acceder a objetos de JavaScript como mapas de datos son escasas dentro de JavaScript, afortunadamente Underscore proporciona un conjunto de operaciones útiles. Entre las funciones más simples de comprender están _.keys, _.values ​​y _.pluck. Ambos _.keys y _.values ​​se nombran de acuerdo con su funcionalidad, que es tomar un objeto y devolver una matriz de sus claves o valores ...

pooya72
fuente
2
Leí esta entrevista de Fogus / Hickey antes, pero no era capaz de entender de qué estaba hablando hasta ahora. Gracias por tu respuesta. Sin embargo, aún no estoy seguro de si Hickey / Fogus le daría a mi diseño su bendición. Me preocupa haber llevado el espíritu de sus consejos al extremo.
Daniel Kaplan
9

El abogado del diablo

Creo que esta pregunta merece un abogado del diablo (pero, por supuesto, soy parcial). Creo que @KarlBielefeldt está haciendo muy buenos puntos y me gustaría abordarlos. Primero quiero decir que sus puntos son geniales.

Como mencionó que este no es un buen patrón incluso en la programación funcional, consideraré JavaScript y / o Clojure en mis respuestas. Una similitud extremadamente importante entre estos dos idiomas es que están tipificados dinámicamente. Estaría más de acuerdo con sus puntos si implementara esto en un lenguaje de tipo estático como Java o Haskell. Pero voy a considerar que la alternativa al patrón "Todo es un mapa" es un diseño OOP tradicional en JavaScript y no en un lenguaje estáticamente escrito (espero no estar creando un argumento de sorpresa al hacer esto, Por favor hagamelo saber).

Por ejemplo, intente responder las siguientes preguntas sobre cada función de subproceso:

  • ¿Qué campos de estado requiere?

  • ¿Qué campos modifica?

  • ¿Qué campos no han cambiado?

En un lenguaje de tipo dinámico, ¿cómo respondería normalmente estas preguntas? El primer parámetro de una función puede ser nombrado foo, pero ¿qué es eso? ¿Una matriz? ¿Un objeto? ¿Un objeto de matrices de objetos? ¿Cómo te enteraste? La única forma que conozco es

  1. lee la documentación
  2. mira el cuerpo de la función
  3. mira las pruebas
  4. adivina y ejecuta el programa para ver si funciona.

No creo que el patrón "Todo es un mapa" haga alguna diferencia aquí. Estas son todavía las únicas formas que conozco para responder estas preguntas.

También tenga en cuenta que en JavaScript y en la mayoría de los lenguajes de programación imperativos, cualquiera functionpuede requerir, modificar e ignorar cualquier estado al que pueda acceder y la firma no hace ninguna diferencia: la función / método podría hacer algo con un estado global o con un singleton. Las firmas a menudo mienten.

No estoy tratando de establecer una falsa dicotomía entre "Todo es un mapa" y un código OO mal diseñado . Solo estoy tratando de señalar que tener firmas que toman menos / más parámetros de grano fino / grueso no garantiza que sepa cómo aislar, configurar y llamar a una función.

Pero, si me permitiera usar esa falsa dicotomía: en comparación con la escritura de JavaScript en la forma tradicional de OOP, "Todo es un mapa" parece mejor. En la forma tradicional de OOP, la función puede requerir, modificar o ignorar el estado que pasa o el estado que no pasa. Con este patrón "Todo es un mapa", solo requiere, modifica o ignora el estado que pasa en.

  • ¿Se puede reorganizar con seguridad el orden de las funciones?

En mi código sí. Vea mi segundo comentario a la respuesta de @ Evicatos. Quizás esto sea solo porque estoy haciendo un juego, no puedo decirlo. En un juego que se actualiza 60 veces por segundo, realmente no importa si dead guys drop lootentonces good guys pick up looto viceversa. Cada función sigue haciendo exactamente lo que se supone que debe hacer, independientemente del orden en que se ejecuten. Los mismos datos solo se envían a ellos en updatellamadas diferentes si intercambias el pedido. Si es good guys pick up lootasí dead guys drop loot, los buenos recogerán el botín en el próximo updatey no es gran cosa. Un humano no podrá notar la diferencia.

Al menos esta ha sido mi experiencia general. Me siento realmente vulnerable admitiendo esto públicamente. Quizás considerar que esto está bien es algo muy , muy malo. Avísame si he cometido un terrible error aquí. Pero, si tengo, es muy fácil para reorganizar las funciones por lo que el orden es dead guys drop lootentonces good guys pick up lootde nuevo. Tomará menos tiempo que el tiempo que tomó escribir este párrafo: P

Tal vez pienses que "los muertos deberían tirar el botín primero. Sería mejor si tu código aplicara ese orden". Pero, ¿por qué los enemigos tienen que soltar botín antes de que puedas recoger el botín? Para mí eso no tiene sentido. Tal vez el botín se dejó caer updateshace 100 . No es necesario verificar si un malo arbitrario tiene que recoger el botín que ya está en el suelo. Por eso creo que el orden de estas operaciones es completamente arbitrario.

Es natural escribir pasos desacoplados con este patrón, pero es difícil notar sus pasos acoplados en OOP tradicional. Si escribiera OOP tradicional, la forma natural e ingenua de pensar es hacer que el dead guys drop lootretorno sea un Lootobjeto que tengo que pasar good guys pick up loot. No podría reordenar esas operaciones ya que la primera devuelve la entrada de la segunda.

En un lenguaje orientado a objetos, el patrón tiene aún menos sentido, porque el estado de seguimiento es lo que hacen los objetos.

Los objetos tienen estado y es idiomático mutar el estado haciendo que su historia simplemente desaparezca ... a menos que escriba manualmente el código para realizar un seguimiento. ¿De qué manera el estado de seguimiento es "lo que hacen"?

Además, los beneficios de la inmutabilidad disminuyen cuanto más grandes se vuelven tus objetos inmutables.

Bien, como dije, "Es raro que alguna de mis funciones sea pura". Ellos siempre solamente operan en sus parámetros, pero mutan sus parámetros. Este es un compromiso que sentí que tenía que hacer al aplicar este patrón a JavaScript.

Daniel Kaplan
fuente
44
"El primer parámetro de una función puede llamarse foo, pero ¿qué es eso?" Es por eso que no nombra sus parámetros "foo", sino "repeticiones", "parent" y otros nombres que hacen obvio lo que se espera cuando se combina con el nombre de la función.
Sebastian Redl
1
Tengo que estar de acuerdo contigo en todos los puntos. El único problema que Javascript realmente plantea con este patrón es que está trabajando en datos mutables y, como tal, es muy probable que mute el estado. Sin embargo, hay una biblioteca que le da acceso a estructuras de datos de clojure en javascript simple, aunque olvido cómo se llama. Pasando argumentos como un objeto tampoco es desconocido, jquery lo hace en varios lugares, pero documenta qué partes del objeto usan. Sin embargo, personalmente, separaría los campos UI y GameLogic, pero lo que sea que funcione para ti :)
Robin Heggelund Hansen
@SebastianRedl ¿Por qué se supone que debo pasar parent? ¿Es un repetitionsconjunto de números o cadenas o no importa? ¿O tal vez las repeticiones son solo un número para representar la cantidad de repeticiones que quiero? Hay muchas apis por ahí que solo toman un objeto de opciones . El mundo es un lugar mejor si nombra las cosas correctamente, pero no garantiza que sepa cómo usar la API, sin hacer preguntas.
Daniel Kaplan
8

He descubierto que mi código tiende a terminar estructurado así:

  • Las funciones que toman mapas tienden a ser más grandes y tienen efectos secundarios.
  • Las funciones que toman argumentos tienden a ser más pequeñas y son puras.

No me propuse crear esta distinción, pero así es como termina en mi código. No creo que usar un estilo necesariamente niegue el otro.

Las funciones puras son fáciles de probar por unidad. Los más grandes con mapas entran más en el área de prueba de "integración" ya que tienden a involucrar más partes móviles.

En javascript, una cosa que ayuda mucho es usar algo como la biblioteca Meteor's Match para realizar la validación de parámetros. Deja muy claro lo que la función espera y puede manejar los mapas de manera bastante limpia.

Por ejemplo,

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
}

Ver http://docs.meteor.com/#match para más información.

:: ACTUALIZACIÓN ::

La grabación de video de Stuart Sierra de "Clojure in the Large" de Clojure / West también toca este tema. Al igual que el OP, controla los efectos secundarios como parte del mapa, por lo que las pruebas se vuelven mucho más fáciles. También tiene una publicación en el blog que describe su flujo de trabajo actual de Clojure que parece relevante.

alanning
fuente
1
Creo que mis comentarios a @Evicatos detallarán mi posición aquí. Sí, estoy mutando y las funciones no son puras. Pero, mis funciones son realmente fáciles de probar, especialmente en retrospectiva para defectos de regresión que no planeé probar. La mitad del crédito va a JS: es muy fácil construir un "mapa" / objeto con solo los datos que necesito para mi prueba. Entonces es tan simple como pasarlo y verificar las mutaciones. Los efectos secundarios siempre se representan en el mapa, por lo que son fáciles de probar.
Daniel Kaplan
1
Creo que el uso pragmático de ambos métodos es el camino "correcto". Si la prueba es fácil para usted y puede mitigar el metaproblema de comunicar los campos requeridos a otros desarrolladores, entonces eso parece una victoria. Gracias por su pregunta; Me ha encantado leer la interesante discusión que ha comenzado.
Alanning
5

El argumento principal que puedo pensar en contra de esta práctica es que es muy difícil saber qué datos necesita realmente una función.

Lo que eso significa es que los futuros programadores en la base de código tendrán que saber cómo funciona internamente la función que se llama, y ​​cualquier llamada de función anidada, para poder llamarla.

Cuanto más lo pienso, más huele tu objeto gameState como un global. Si así es como se usa, ¿por qué pasarlo?

Mike Partridge
fuente
1
Sí, ya que generalmente lo muto, ES global. ¿Por qué molestarse en pasarlo? No sé, esa es una pregunta válida. Pero mi instinto me dice que si dejara de pasarlo, mi programa sería instantáneamente más difícil de razonar. Cada función podría hacerle todo o nada al estado global. Tal como está ahora, ve ese potencial en la firma de la función. Si no puede decirlo, no estoy seguro de nada de esto :)
Daniel Kaplan
1
Por cierto: re: el argumento principal en su contra: Eso parece cierto si esto estaba en clojure o javascript. Pero es un punto valioso para hacer. Quizás los beneficios enumerados superan con creces lo negativo de eso.
Daniel Kaplan
2
Ahora sé por qué me molesto en pasarlo aunque sea una variable global: me permite escribir funciones puras. Si cambio mi gameState = f(gameState)a f(), es mucho más difícil de probar f. f()puede devolver algo diferente cada vez que lo llamo. Pero es fácil hacer que f(gameState)devuelva lo mismo cada vez que recibe la misma entrada.
Daniel Kaplan
3

Hay un nombre más apropiado para lo que estás haciendo que Big ball of mud . Lo que estás haciendo se llama el patrón de objeto de Dios . A primera vista no parece así, pero en Javascript hay muy poca diferencia entre

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

y

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

Si es o no una buena idea probablemente depende de las circunstancias. Pero ciertamente está en línea con la forma Clojure. Uno de los objetivos de Clojure es eliminar lo que Rich Hickey llama "complejidad incidental". Múltiples objetos comunicantes es ciertamente más complejo que un solo objeto. Si divide la funcionalidad en varios objetos, de repente tiene que preocuparse por la comunicación y la coordinación y la división de las responsabilidades. Esas son complicaciones que solo son incidentales a su objetivo original de escribir un programa. Debería ver la charla de Rich Hickey Simple hecho fácil . Creo que esta es una muy buena idea.

user7610
fuente
Pregunta relacionada y más general [ programmers.stackexchange.com/questions/260309/… modelado de datos versus clases tradicionales)
usuario7610
"En la programación orientada a objetos, un objeto dios es un objeto que sabe demasiado o hace demasiado. El objeto dios es un ejemplo de un antipatrón". Entonces, el objeto de Dios no es algo bueno, sin embargo, su mensaje parece estar diciendo lo contrario. Eso es un poco confuso para mí.
Daniel Kaplan
@tieTYT no está haciendo programación orientada a objetos, por lo que está bien
user7610
¿Cómo llegaste a esa conclusión (la de "está bien")?
Daniel Kaplan
El problema con el objeto de Dios en OO es que "el objeto se vuelve tan consciente de todo o todos los objetos se vuelven tan dependientes del objeto de Dios que cuando hay un cambio o un error que solucionar, se convierte en una verdadera pesadilla de implementar". fuente En su código hay otro objeto además del objeto de Dios, por lo que la segunda parte no es un problema. Con respecto a la primera parte, su objeto de Dios es el programa y su programa debe estar al tanto de todo. Entonces eso también está bien.
user7610
2

Acabo de enfrentar este tema más temprano hoy mientras jugaba con un nuevo proyecto. Estoy trabajando en Clojure para hacer un juego de póker. Representé los valores nominales y los trajes como palabras clave, y decidí representar una tarjeta como un mapa como

{ :face :queen :suit :hearts }

También podría haberlos hecho listas o vectores de los dos elementos de palabras clave. No sé si hace una diferencia de memoria / rendimiento, así que solo voy con mapas por ahora.

Sin embargo, en caso de que cambie de opinión más tarde, decidí que la mayoría de las partes de mi programa deberían pasar por una "interfaz" para acceder a las piezas de una tarjeta, de modo que los detalles de la implementación estén controlados y ocultos. Tengo funciones

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

que usa el resto del programa. Las tarjetas se transmiten a las funciones como mapas, pero las funciones usan una interfaz acordada para acceder a los mapas y, por lo tanto, no deberían poder estropearse.

En mi programa, una tarjeta probablemente solo sea un mapa de 2 valores. En la pregunta, todo el estado del juego se pasa como un mapa. El estado del juego será mucho más complicado que una sola carta, pero no creo que sea culpable usar un mapa. En un lenguaje de objeto imperativo, podría tener un solo objeto GameState grande y llamar a sus métodos, y tener el mismo 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

Ahora está orientado a objetos. ¿Hay algo particularmente malo en ello? No lo creo, solo está delegando el trabajo a funciones que saben cómo manejar un objeto State. Y ya sea que trabaje con mapas u objetos, debe tener cuidado con cuándo dividirlo en partes más pequeñas. Así que digo que usar mapas está perfectamente bien, siempre y cuando uses la misma atención que usarías con los objetos.

xen
fuente
2

Por lo que (poco) he visto, usar mapas u otras estructuras anidadas para hacer un solo objeto de estado inmutable global como este es bastante común en lenguajes funcionales, al menos los puros, especialmente cuando se usa la Mónada de Estado como @Ptharien'sFlame mentioend .

Dos obstáculos para usar esto de manera efectiva que he visto / leído (y otras respuestas aquí han mencionado) son:

  • Mutar un valor anidado (profundo) en el estado (inmutable)
  • Ocultando a la mayoría del estado de funciones que no lo necesitan y simplemente dándoles lo poco que necesitan para trabajar / mutar

Hay un par de diferentes técnicas / patrones comunes que pueden ayudar a aliviar estos problemas:

El primero son las cremalleras : estas permiten atravesar y mutar en un estado profundo dentro de una jerarquía anidada inmutable.

Otro es Lentes : estos le permiten enfocarse en la estructura a una ubicación específica y leer / mutar el valor allí. Puede combinar diferentes lentes para enfocarse en diferentes cosas, como una cadena de propiedades ajustable en OOP (¡donde puede sustituir variables por nombres de propiedades reales!)

Prismatic recientemente hizo una publicación de blog sobre el uso de este tipo de técnica, entre otras cosas, en JavaScript / ClojureScript, que debe consultar. Utilizan los cursores (que comparan con las cremalleras) para mostrar el estado de la ventana para las funciones:

Om restaura la encapsulación y la modularidad utilizando cursores. Los cursores proporcionan ventanas que se pueden actualizar en partes particulares del estado de la aplicación (al igual que las cremalleras), lo que permite que los componentes tomen referencias solo a las partes relevantes del estado global y las actualicen de manera libre de contexto.

IIRC, también mencionan la inmutabilidad en JavaScript en esa publicación.

Pablo
fuente
La charla OP mencionada también analiza el uso de la función de actualización para limitar el alcance que una función puede actualizar a un subárbol del mapa de estado. Creo que nadie mencionó esto todavía.
user7610
@ user7610 Buena captura, no puedo creer que olvidé mencionar esa, me gusta esa función (y assoc-inotros). Supongo que acabo de tener a Haskell en el cerebro. Me pregunto si alguien ha hecho un puerto de JavaScript. La gente probablemente no lo mencionó porque (como yo) no vieron la charla :)
Paul
@paul en cierto sentido lo tienen porque está disponible en ClojureScript, pero no estoy seguro de si eso "cuenta" en su mente. Puede existir en PureScript y creo que hay al menos una biblioteca que proporciona estructuras de datos inmutables en JavaScript. Espero que al menos uno de ellos tenga estos, de lo contrario sería inconveniente usarlos.
Daniel Kaplan
@tieTYT Estaba pensando en una implementación nativa de JS cuando hice ese comentario, pero tú haces un buen punto sobre ClojureScript / PureScript. Debería buscar JS inmutable y ver qué hay ahí fuera, no he trabajado antes.
Paul
1

Si es una buena idea o no, dependerá realmente de lo que esté haciendo con el estado dentro de esos subprocesos. Si entiendo el ejemplo de Clojure correctamente, los diccionarios de estado que se devuelven no son los mismos diccionarios de estado que se pasan. Son copias, posiblemente con adiciones y modificaciones, que (supongo) que Clojure puede crear eficientemente porque La naturaleza funcional del lenguaje depende de ello. Los diccionarios de estado originales para cada función no se modifican de ninguna manera.

Si lo entiendo correctamente, está modificando los objetos de estado que pasa en sus funciones de JavaScript en lugar de devolver una copia, lo que significa que está haciendo algo muy, muy diferente de lo que está haciendo ese código Clojure. Como señaló Mike Partridge, esto es básicamente un global al que pasas explícitamente y regresas de las funciones sin ninguna razón real. En este punto, creo que simplemente te hace pensar que estás haciendo algo que en realidad no estás haciendo.

Si realmente está haciendo copias explícitamente del estado, modificándolo y luego devolviendo esa copia modificada, continúe. No estoy seguro de que sea necesariamente la mejor manera de lograr lo que está tratando de hacer en Javascript, pero probablemente sea "similar" a lo que está haciendo ese ejemplo de Clojure.

Evicatos
fuente
1
Al final, ¿es realmente "muy, muy diferente"? En el ejemplo de Clojure, sobrescribe su antiguo estado con el nuevo estado. Sí, no está ocurriendo una mutación real, la identidad solo está cambiando. Pero en su "buen" ejemplo, como está escrito, no tiene forma de obtener la copia que se pasó al subproceso dos. La identidad de ese valor se sobrescribió. Por lo tanto, creo que lo que es "muy, muy diferente" es realmente solo un detalle de implementación de lenguaje. Al menos en el contexto de lo que mencionas.
Daniel Kaplan
2
Hay dos cosas que suceden con el ejemplo de Clojure: 1) el primer ejemplo depende de las funciones que se llaman en un cierto orden, y 2) las funciones son puras para que no tengan ningún efecto secundario. Debido a que las funciones en el segundo ejemplo son puras y comparten la misma firma, puede reordenarlas sin tener que preocuparse por las dependencias ocultas en el orden en que se llaman. Si está mutando el estado en sus funciones, entonces no tiene esa misma garantía. La mutación de estado significa que su versión no es tan componible, que fue la razón original del diccionario.
Evicatos
1
Tendrás que mostrarme un ejemplo porque, en mi experiencia con esto, puedo mover las cosas a voluntad y tiene muy poco efecto. Solo para demostrarlo a mí mismo, moví dos llamadas de subproceso al azar en el medio de mi update()función. Moví uno hacia arriba y otro hacia abajo. Todas mis pruebas todavía pasaron y cuando jugué mi juego no noté efectos negativos. Siento que mis funciones son tan componibles como el ejemplo de Clojure. Ambos estamos desechando nuestros datos anteriores después de cada paso.
Daniel Kaplan
1
Sus pruebas pasan y no notan ningún efecto negativo significa que actualmente no está mutando ningún estado que tenga efectos secundarios inesperados en otros lugares. Dado que sus funciones no son puras, no tiene ninguna garantía de que siempre sea así. Creo que debo estar malentendiendo fundamentalmente algo acerca de su implementación si dice que sus funciones no son puras, pero está desechando sus datos anteriores después de cada paso.
Evicatos
1
@Evicatos: ser puro y tener la misma firma no implica que el orden de las funciones no importe. Imagine calcular un precio con descuentos fijos y porcentuales aplicados. (-> 10 (- 5) (/ 2))devuelve 2.5. (-> 10 (/ 2) (- 5))devuelve 0.
Zak
1

Si tiene un objeto de estado global, a veces llamado "objeto de dios", que se pasa a cada proceso, termina confundiendo una serie de factores, todos los cuales aumentan el acoplamiento y disminuyen la cohesión. Todos estos factores tienen un impacto negativo en la mantenibilidad a largo plazo.

Acoplamiento de vagabundo Esto surge del paso de datos a través de varios métodos que no necesitan casi todos los datos, con el fin de llevarlos al lugar que realmente puede manejarlos. Este tipo de acoplamiento es similar al uso de datos globales, pero puede estar más contenido. El acoplamiento de vagabundos es lo opuesto a "necesidad de saber", que se utiliza para localizar efectos y contener el daño que un código erróneo puede tener en todo el sistema.

Navegación de datos Cada subproceso en su ejemplo necesita saber cómo llegar exactamente a los datos que necesita, y debe ser capaz de procesarlos y quizás construir un nuevo objeto de estado global. Esa es la consecuencia lógica del acoplamiento de vagabundos; Se requiere todo el contexto de un dato para poder operar en el dato. Nuevamente, el conocimiento no local es algo malo.

Si estaba pasando una "cremallera", "lente" o "cursor", como se describe en la publicación de @paul, eso es una cosa. Estaría conteniendo el acceso y permitiendo que la cremallera, etc., controle la lectura y escritura de los datos.

Infracción de responsabilidad única Reclamar que cada uno de "subproceso-uno", "subproceso-dos" y "subproceso-tres" tiene una sola responsabilidad, es decir, producir un nuevo objeto de estado global con los valores correctos, es un reduccionismo atroz. Es todo al final, ¿no?

Mi punto aquí es que tener todos los componentes principales de tu juego tienen las mismas responsabilidades ya que tu juego derrota el propósito de la delegación y el factoring.

Impacto de sistemas

El mayor impacto de su diseño es la baja mantenibilidad. El hecho de que puedas mantener todo el juego en tu cabeza dice que es muy probable que seas un excelente programador. Hay muchas cosas que he diseñado que podría tener en mente durante todo el proyecto. Sin embargo, ese no es el objetivo de la ingeniería de sistemas. El punto es hacer un sistema que pueda funcionar para algo más grande que una persona puede tener en su cabeza al mismo tiempo .

Agregar otro programador, o dos u ocho, hará que su sistema se desmorone casi de inmediato.

  1. La curva de aprendizaje para los objetos divinos es plana (es decir, lleva mucho tiempo volverse competente en ellos). Cada programador adicional necesitará aprender todo lo que sabes y mantenerlo en su cabeza. Solo podrá contratar programadores que sean mejores que usted, suponiendo que pueda pagarles lo suficiente como para sufrir al mantener un gran objeto de dios.
  2. El aprovisionamiento de prueba, en su descripción, es solo un cuadro blanco. Debe conocer cada detalle del objeto dios, así como el módulo bajo prueba, para configurar una prueba, ejecutarla y determinar que a) hizo lo correcto yb) no hizo nada de 10,000 cosas equivocadas. Las probabilidades están muy en su contra.
  3. Agregar una nueva función requiere que usted a) revise todos los subprocesos y determine si la función afecta algún código en ella y viceversa, b) revise su estado global y diseñe las adiciones, yc) revise cada prueba unitaria y modifíquela para comprobar que ninguna unidad bajo prueba afectó negativamente a la nueva función .

Finalmente

  1. Los objetos divinos mutables han sido la maldición de mi existencia de programación, algunos de mis propios actos y otros en los que he quedado atrapado.
  2. La mónada estatal no escala. El estado crece exponencialmente, con todas las implicaciones para las pruebas y operaciones que eso implica. La forma en que controlamos el estado en los sistemas modernos es por delegación (partición de responsabilidades) y alcance (restringiendo el acceso a solo un subconjunto del estado). El enfoque de "todo es un mapa" es exactamente lo contrario del estado de control.
BobDalgleish
fuente