"Tutto è una mappa", lo sto facendo bene?


69

Ho visto il discorso di Stuart Sierra " Pensare nei dati " e ne ho tratto una delle idee come principio progettuale in questo gioco che sto realizzando. La differenza sta lavorando su Clojure e sto lavorando su JavaScript. Vedo alcune importanti differenze tra le nostre lingue in quanto:

  • Clojure è una programmazione idiomaticamente funzionale
  • La maggior parte dello stato è immutabile

Ho preso l'idea dalla diapositiva "Tutto è una mappa" (da 11 minuti, 6 secondi a> 29 minuti). Alcune cose che dice sono:

  1. Ogni volta che vedi una funzione che accetta 2-3 argomenti, puoi fare un caso per trasformarla in una mappa e passare semplicemente una mappa. Ci sono molti vantaggi:
    1. Non devi preoccuparti dell'ordine degli argomenti
    2. Non devi preoccuparti di ulteriori informazioni. Se ci sono chiavi extra, questa non è davvero la nostra preoccupazione. Scorrono semplicemente, non interferiscono.
    3. Non è necessario definire uno schema
  2. Al contrario del passaggio in un oggetto non ci sono dati nascosti. Ma sostiene che nascondere i dati può causare problemi ed è sopravvalutato:
    1. Prestazione
    2. Facilità di implementazione
    3. Non appena comunichi attraverso la rete o attraverso i processi, devi comunque concordare entrambe le parti sulla rappresentazione dei dati. È un lavoro extra che puoi saltare se lavori solo sui dati.
  3. Più pertinente alla mia domanda. Sono trascorsi 29 minuti in "Rendi compostabili le tue funzioni". Ecco l'esempio di codice che usa per spiegare il concetto:

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

    Capisco che la maggior parte dei programmatori non ha familiarità con Clojure, quindi lo riscriverò in stile imperativo:

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

    Ecco i vantaggi:

    1. Facile da testare
    2. Facile da vedere quelle funzioni in isolamento
    3. È facile commentare una riga di questo e vedere qual è il risultato rimuovendo un singolo passaggio
    4. Ogni sottoprocesso potrebbe aggiungere ulteriori informazioni sullo stato. Se un sottoprocesso è necessario comunicare qualcosa con il sottoprocesso tre, è semplice come aggiungere una chiave / valore.
    5. Nessun boilerplate per estrarre i dati necessari dallo stato solo per poterli salvare nuovamente. Basta passare in tutto lo stato e lasciare che il sottoprocesso assegni ciò di cui ha bisogno.

Ora, tornando alla mia situazione: ho preso questa lezione e l'ho applicata al mio gioco. Cioè, quasi tutte le mie funzioni di alto livello accettano e restituiscono un gameStateoggetto. Questo oggetto contiene tutti i dati del gioco. Ad esempio: un elenco di badGuys, un elenco di menu, il bottino a terra, ecc. Ecco un esempio della mia funzione di aggiornamento:

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

Ciò di cui sono qui per chiedere è: ho creato un abominio che ha pervertito un'idea che è pratica solo in un linguaggio di programmazione funzionale? JavaScript non è idiomaticamente funzionale (anche se può essere scritto in quel modo) ed è davvero difficile scrivere strutture di dati immutabili. Una cosa che mi preoccupa è che presume che ognuno di questi sottoprocessi sia puro. Perché è necessario formulare tale presupposto? È raro che una qualsiasi delle mie funzioni sia pura (con ciò, intendo dire che spesso modificano il gameState. Non ho altri effetti collaterali complicati oltre a quello). Queste idee cadono in pezzi se non si hanno dati immutabili?

Sono preoccupato che un giorno mi sveglierò e realizzerò che tutto questo design è una finzione e ho davvero implementato l' anti-pattern Big Ball Of Mud .


Onestamente, ho lavorato su questo codice per mesi ed è stato fantastico. Mi sembra di ottenere tutti i vantaggi che ha affermato. Il mio codice è semplicissimo per me ragionare. Ma sono una squadra di uomini, quindi ho la maledizione della conoscenza.

Aggiornare

Ho programmato più di 6 mesi con questo schema. Di solito a questo punto dimentico quello che ho fatto ed è lì che "l'ho scritto in modo pulito?" entra in gioco. Se non lo avessi fatto, avrei davvero delle difficoltà. Finora non sto lottando affatto.

Capisco come sarebbe necessario un altro set di occhi per convalidare la sua manutenibilità. Tutto quello che posso dire è che mi preoccupo innanzitutto della manutenibilità. Sono sempre l'evangelista più rumoroso per il codice pulito, non importa dove lavoro.

Voglio rispondere direttamente a coloro che hanno già una brutta esperienza personale con questo modo di scrivere codice. Allora non lo sapevo, ma penso che stiamo davvero parlando di due modi diversi di scrivere codice. Il modo in cui l'ho fatto sembra essere più strutturato di quello che altri hanno vissuto. Quando qualcuno ha una brutta esperienza personale con "Tutto è una mappa", parlano di quanto sia difficile da mantenere perché:

  1. Non si conosce mai la struttura della mappa richiesta dalla funzione
  2. Qualsiasi funzione può mutare l'input in modi che non ti aspetteresti mai. Devi cercare in tutta la base di codice per scoprire come una particolare chiave è entrata nella mappa o perché è scomparsa.

Per quelli con una tale esperienza, forse la base di codice era: "Tutto prende 1 di N tipi di mappe". Il mio è "Tutto richiede 1 di 1 tipo di mappa". Se conosci la struttura di quel tipo 1, conosci la struttura di tutto. Naturalmente, quella struttura di solito cresce nel tempo. Ecco perchè...

C'è un posto dove cercare l'implementazione di riferimento (cioè lo schema). Questa implementazione di riferimento è il codice utilizzato dal gioco in modo da non poter essere aggiornato.

Per quanto riguarda il secondo punto, non aggiungo / rimuovo le chiavi alla mappa al di fuori dell'implementazione di riferimento, muto solo ciò che è già lì. Ho anche una vasta gamma di test automatizzati.

Se questa architettura alla fine crolla sotto il suo stesso peso, aggiungerò un secondo aggiornamento. Altrimenti, supponi che tutto stia andando bene :)


2
Bella domanda (+1)! Trovo che sia un esercizio molto utile cercare di implementare modi di dire funzionali in un linguaggio non funzionale (o non fortemente funzionale).
Giorgio,

15
Chiunque ti dirà che nascondere le informazioni in stile OO (con proprietà e funzioni accessor) è una brutta cosa a causa del colpo di prestazioni (solitamente trascurabile), e quindi ti dice di trasformare tutti i tuoi parametri in una mappa, che ti dà l'overhead (molto maggiore) di una ricerca hash ogni volta che si tenta di recuperare un valore, può essere tranquillamente ignorato.
Mason Wheeler,

4
@MasonWheeler dice che hai ragione su questo. Annullerai ogni altro punto che sottolinea perché questa cosa è sbagliata?
Daniel Kaplan,

9
In Python (e credo che la maggior parte dei linguaggi dinamici, incluso Javascript), l'oggetto sia in realtà solo zucchero di sintassi per un dict / map comunque.
Lie Ryan,

6
@EvanPlaice: la notazione Big-O può essere ingannevole. Il semplice fatto è che tutto è lento rispetto all'accesso diretto con due o tre singole istruzioni del codice macchina, e su qualcosa che accade spesso come una chiamata di funzione, l'overhead si sommerà molto rapidamente.
Mason Wheeler,

Risposte:


42

Ho supportato un'applicazione in cui "tutto è una mappa" in precedenza. È un'idea terribile. PER FAVORE, non farlo!

Quando si specificano gli argomenti che vengono passati alla funzione, ciò rende molto semplice sapere quali valori sono necessari alla funzione. Evita il passaggio di dati estranei alla funzione che distrae solo il programmatore: ogni valore trasmesso implica che è necessario e che rende il programmatore che supporta il codice a capire perché sono necessari i dati.

D'altra parte, se passi tutto come una mappa, il programmatore che supporta la tua app dovrà comprendere appieno la funzione chiamata in ogni modo per sapere quali valori deve contenere la mappa. Ancora peggio, è molto allettante riutilizzare la mappa passata alla funzione corrente per passare i dati alle funzioni successive. Ciò significa che il programmatore che supporta la tua app deve conoscere tutte le funzioni chiamate dalla funzione corrente per capire cosa fa la funzione corrente. Questo è esattamente l'opposto dello scopo di scrivere funzioni: eliminare i problemi in modo da non doverci pensare! Ora immagina 5 chiamate in profondità e 5 in larghezza ciascuna. È un sacco di cose da tenere a mente e un sacco di errori da fare.

"Tutto è una mappa" sembra anche portare a utilizzare la mappa come valore di ritorno. L'ho visto. E, di nuovo, è un dolore. Le funzioni chiamate non devono mai sovrascrivere reciprocamente il valore restituito, a meno che non si conosca la funzionalità di tutto e si sappia che il valore della mappa di input X deve essere sostituito per la chiamata di funzione successiva. E la funzione corrente deve modificare la mappa per restituire il suo valore, che a volte deve sovrascrivere il valore precedente e talvolta no.

modifica - esempio

Ecco un esempio di dove questo era problematico. Questa era un'applicazione web. L'input dell'utente è stato accettato dal livello dell'interfaccia utente e inserito in una mappa. Quindi sono state chiamate funzioni per elaborare la richiesta. Il primo set di funzioni verificherebbe l'input errato. Se si fosse verificato un errore, il messaggio di errore verrebbe inserito nella mappa. La funzione chiamante controlla la mappa per questa voce e scrive il valore nell'interfaccia utente se esiste.

Il prossimo set di funzioni avvierebbe la logica aziendale. Ogni funzione prende la mappa, rimuove alcuni dati, modifica alcuni dati, opera sui dati nella mappa e inserisce il risultato nella mappa, ecc. Le funzioni successive si aspetterebbero risultati dalle funzioni precedenti nella mappa. Al fine di correggere un bug in una funzione successiva, è stato necessario esaminare tutte le funzioni precedenti e un chiamante per determinare ovunque il valore atteso potesse essere stato impostato.

Le funzioni successive estrarrebbero i dati dal database. O meglio, passerebbero una mappa al livello di accesso ai dati. Il DAL controllava se la mappa conteneva determinati valori per controllare il modo in cui la query veniva eseguita. Se "justcount" fosse una chiave, la query sarebbe "count select foo from bar". Qualsiasi funzione precedentemente chiamata potrebbe avere ben quella che ha aggiunto "justcount" alla mappa. I risultati della query verranno aggiunti alla stessa mappa.

I risultati arriverebbero al chiamante (business logic) che controllerebbe la mappa per cosa fare. Parte di ciò verrebbe dalle cose che sono state aggiunte alla mappa dalla logica aziendale iniziale. Alcuni verrebbero dai dati dal database. L'unico modo per sapere da dove veniva era trovare il codice che lo aggiungeva. E l'altra posizione che può anche aggiungerlo.

Il codice era effettivamente un casino monolitico, che dovevi capire nella sua interezza per sapere da dove proveniva una singola voce nella mappa.


2
Il tuo secondo paragrafo ha senso per me e sembra davvero che faccia schifo. Dal terzo paragrafo ho capito che non stiamo parlando dello stesso design. "riutilizzo" è il punto. Sarebbe sbagliato evitarlo. E non posso davvero collegarmi al tuo ultimo paragrafo. Devo prendere tutte le funzioni gameStatesenza sapere nulla di ciò che è accaduto prima o dopo. Reagisce semplicemente ai dati forniti. Come sei arrivato a una situazione in cui le funzioni si sarebbero fatte avanti a vicenda? Puoi fare un esempio?
Daniel Kaplan,

2
Ho aggiunto un esempio per provare a renderlo un po 'più chiaro. Spero che sia d'aiuto. Inoltre, c'è una differenza tra il passaggio intorno a un oggetto stato ben definito rispetto al passaggio attorno a un BLOB che imposta cambiato in molti luoghi per molte ragioni, mescolando la logica
dell'interfaccia utente

28

Personalmente, non consiglierei questo modello in nessuno dei due paradigmi. Rende più facile scrivere inizialmente a spese di rendere più difficile ragionare più tardi.

Ad esempio, provare a rispondere alle seguenti domande su ciascuna funzione di sottoprocesso:

  • Di quali campi stateè richiesto?
  • Quali campi modifica?
  • Quali campi sono invariati?
  • Puoi riorganizzare in modo sicuro l'ordine delle funzioni?

Con questo modello, non è possibile rispondere a queste domande senza leggere l'intera funzione.

In un linguaggio orientato agli oggetti, il modello ha ancora meno senso, perché lo stato di tracciamento è ciò che fanno gli oggetti.


2
"i benefici dell'immutabilità diminuiscono tanto più grande diventa il tuo oggetto immutabile" Perché? È un commento su prestazioni o manutenibilità? Per favore, approfondisci quella frase.
Daniel Kaplan,

8
@tieTYT Gli immutabili funzionano bene quando c'è qualcosa di piccolo (un tipo numerico per esempio). Puoi copiarli, crearli, scartarli, volarli con un costo piuttosto basso. Quando inizi a gestire interi stati di gioco costituiti da mappe profonde e grandi, alberi, elenchi e dozzine se non centinaia di variabili, il costo per copiarlo o eliminarlo aumenta (e i pesi volanti diventano poco pratici).

3
Vedo. È un problema di "dati immutabili in lingue imperative" o un problema di "dati immutabili"? IE: Forse questo non è un problema nel codice Clojure. Ma posso vedere com'è in JS. È anche una seccatura scrivere tutto il codice boilerplate per farlo.
Daniel Kaplan,

3
@MichaelT e Karl: per essere onesti, dovresti davvero menzionare l'altro lato della storia dell'immutabilità / efficienza. Sì, l'uso ingenuo può essere orribilmente inefficiente, ecco perché le persone hanno escogitato approcci migliori. Vedi il lavoro di Chris Okasaki per ulteriori informazioni.

3
@MattFenwick Personalmente mi piacciono molto gli immutabili. Quando ho a che fare con il threading so cose sull'immutabile e posso lavorare e copiarle in modo sicuro. L'ho inserito in una chiamata di parametro e l'ho passato a un altro senza preoccuparmi che qualcuno lo modificasse quando torna da me. Se si sta parlando di uno stato di gioco complesso (la domanda lo ha usato come esempio - sarei inorridito a pensare a qualcosa di "semplice" come lo stato del gioco nethack come immutabile), l'immutabilità è probabilmente l'approccio sbagliato.

12

Ciò che sembra fare è, in effetti, una monade statale manuale; quello che vorrei fare è costruire un combinatore di bind (semplificato) e ri-esprimere le connessioni tra i tuoi passi logici usando quello:

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

Puoi persino usare stateBindper costruire i vari sottoprocessi dai sottoprocessi e continuare lungo un albero di combinatori di associazione per strutturare il tuo calcolo in modo appropriato.

Per una spiegazione della monade di stato completa e non semplificata e un'eccellente introduzione alle monadi in generale in JavaScript, vedere questo post del blog .


1
OK, lo esaminerò (e commenterò più avanti). Ma cosa ne pensi dell'idea di utilizzare il modello?
Daniel Kaplan,

1
@tieTYT Penso che lo schema stesso sia un'ottima idea; la monade di Stato in generale è un utile strumento di strutturazione del codice per algoritmi pseudo-mutabili (algoritmi immutabili ma che emulano mutabilità).
Ptharien's Flame,

2
+1 per notare che questo modello è essenzialmente una Monade. Tuttavia, non sono d'accordo sul fatto che sia una buona idea in una lingua che ha effettivamente mutabilità. La monade è un modo per fornire la capacità di stati globali / mutabili in una lingua che non consente la mutazione. IMO, in un linguaggio che non impone l'immutabilità, il modello Monad è solo la masturbazione mentale.
Sdraiati Ryan il

6
Le monadi @LieRyan in generale in realtà non hanno nulla a che fare con la mutabilità o i globuli; solo la monade di Stato fa specificamente (perché è quello che in particolare è progettato per fare). Non sono inoltre d'accordo sul fatto che la monade di Stato non sia utile in una lingua con mutabilità, sebbene un'implementazione basata sulla mutabilità sottostante potrebbe essere più efficiente di quella immutabile che ho dato (anche se non ne sono affatto sicuro). L'interfaccia monadica può fornire capacità di alto livello che non sarebbero altrimenti facilmente accessibili, il stateBindcombinatore che ho fornito ne è un esempio molto semplice.
Ptharien's Flame,

1
@LieRyan Secondo commento di Ptharien: la maggior parte delle monadi non riguarda lo stato o la mutabilità, e anche quella che lo è, non riguarda specificamente lo stato globale . Le monadi in realtà funzionano abbastanza bene nelle lingue OO / imperative / mutabili.

11

Quindi, sembra che ci sia molta discussione tra l'efficacia di questo approccio in Clojure. Penso che potrebbe essere utile esaminare la filosofia di Rich Hickey sul perché abbia creato Clojure per supportare le astrazioni dei dati in questo modo :

Fogus: Quindi, una volta ridotte le complessità accidentali, in che modo Clojure può aiutare a risolvere il problema? Ad esempio, il paradigma idealizzato orientato agli oggetti ha lo scopo di favorire il riutilizzo, ma Clojure non è orientato classicamente agli oggetti: come possiamo strutturare il nostro codice per il riutilizzo?

Hickey: Discuterei di OO e riutilizzerei, ma certamente, essere in grado di riutilizzare le cose rende il problema a portata di mano più semplice, poiché non si reinventano le ruote invece di costruire automobili. E Clojure, essendo sulla JVM, mette a disposizione molte ruote, librerie. Cosa rende riutilizzabile una biblioteca? Dovrebbe fare una o poche cose bene, essere relativamente autosufficiente e fare poche richieste sul codice client. Niente di tutto ciò cade da OO, e non tutte le librerie Java soddisfano questo criterio, ma molti lo fanno.

Quando scendiamo al livello dell'algoritmo, penso che OO possa seriamente contrastare il riutilizzo. In particolare, l'uso di oggetti per rappresentare semplici dati informativi è quasi criminale nella sua generazione di micro-linguaggi per pezzo di informazione, cioè i metodi di classe, rispetto a metodi molto più potenti, dichiarativi e generici come l'algebra relazionale. Inventare una classe con una propria interfaccia per contenere un'informazione è come inventare una nuova lingua per scrivere ogni racconto. Questo è anti-riutilizzo e, penso, si traduce in un'esplosione di codice nelle tipiche applicazioni OO. Clojure lo evita e sostiene invece un semplice modello associativo di informazioni. Con esso, si possono scrivere algoritmi che possono essere riutilizzati tra tipi di informazioni.

Questo modello associativo è solo una delle diverse astrazioni fornite con Clojure, e queste sono le vere basi del suo approccio al riutilizzo: funzioni sulle astrazioni. Avere un insieme aperto e ampio di funzioni che operano su un insieme aperto e piccolo di astrazioni estensibili è la chiave per il riutilizzo algoritmico e l'interoperabilità delle biblioteche. La stragrande maggioranza delle funzioni di Clojure sono definite in termini di queste astrazioni e gli autori delle biblioteche progettano i loro formati di input e output anche in termini di esse, realizzando un'enorme interoperabilità tra librerie sviluppate indipendentemente. Questo è in netto contrasto con i DOM e altre cose che vedi in OO. Ovviamente, puoi fare un'astrazione simile in OO con le interfacce, ad esempio le raccolte java.util, ma non puoi farlo altrettanto facilmente, come in java.io.

Fogus ribadisce questi punti nel suo libro Javascript funzionale :

Nel corso di questo libro, seguirò l'approccio dell'uso di tipi di dati minimi per rappresentare le astrazioni, dai set agli alberi alle tabelle. In JavaScript, tuttavia, sebbene i suoi tipi di oggetto siano estremamente potenti, gli strumenti forniti per lavorare con essi non sono del tutto funzionali. Invece, il modello di utilizzo più ampio associato agli oggetti JavaScript è quello di collegare metodi ai fini dell'invio polimorfico. Per fortuna, puoi anche visualizzare un oggetto JavaScript senza nome (non creato tramite una funzione di costruzione) come un semplice archivio dati associativo.

Se le uniche operazioni che possiamo eseguire su un oggetto Book o un'istanza di un tipo Employee sono setTitle o getSSN, allora abbiamo bloccato i nostri dati in micro-linguaggi per informazione (Hickey 2011). Un approccio più flessibile alla modellazione dei dati è una tecnica associativa dei dati. Gli oggetti JavaScript, anche meno il macchinario prototipo, sono veicoli ideali per la modellazione di dati associativi, in cui i valori denominati possono essere strutturati per formare modelli di dati di livello superiore, accessibili in modo uniforme.

Sebbene gli strumenti per manipolare e accedere agli oggetti JavaScript come mappe di dati siano sparsi all'interno di JavaScript stesso, per fortuna Underscore fornisce una serie di operazioni utili. Tra le funzioni più semplici da comprendere ci sono _.keys, _.values ​​e _.pluck. Sia _.keys che _.values ​​sono nominati in base alla loro funzionalità, ovvero prendere un oggetto e restituire una matrice delle sue chiavi o valori ...


2
Ho già letto questa intervista con Fogus / Hickey, ma non ero in grado di capire di cosa stesse parlando fino ad ora. Grazie per la tua risposta. Non sono ancora sicuro se Hickey / Fogus darebbe la mia benedizione al mio design. Sono preoccupato di aver portato lo spirito del loro consiglio all'estremo.
Daniel Kaplan,

9

L'avvocato del diavolo

Penso che questa domanda meriti un avvocato del diavolo (ma ovviamente sono di parte). Penso che @KarlBielefeldt stia facendo ottimi punti e vorrei affrontarli. Per prima cosa voglio dire che i suoi punti sono fantastici.

Dato che ha menzionato che questo non è un buon modello anche nella programmazione funzionale, nelle mie risposte prenderò in considerazione JavaScript e / o Clojure. Una somiglianza estremamente importante tra queste due lingue è la tipizzazione dinamica. Sarei più piacevole con i suoi punti se lo implementassi in un linguaggio tipicamente statico come Java o Haskell. Ma considererò l'alternativa al modello "Tutto è una mappa" come un design OOP tradizionale in JavaScript e non in un linguaggio tipicamente statico (spero di non impostare un argomento di paglia facendo questo, Per favore mi faccia sapere).

Ad esempio, provare a rispondere alle seguenti domande su ciascuna funzione di sottoprocesso:

  • Quali campi di stato sono richiesti?

  • Quali campi modifica?

  • Quali campi sono invariati?

In una lingua tipizzata in modo dinamico, come risponderesti normalmente a queste domande? Il primo parametro di una funzione può essere chiamato foo, ma che cos'è? Un array? Un oggetto? Un oggetto di matrici di oggetti? Come lo scopri? L'unico modo che conosco è

  1. leggi la documentazione
  2. guarda il corpo della funzione
  3. guarda i test
  4. indovina ed esegui il programma per vedere se funziona.

Non credo che il modello "Tutto sia una mappa" faccia la differenza qui. Questi sono ancora gli unici modi in cui conosco per rispondere a queste domande.

Inoltre, tieni presente che in JavaScript e nella maggior parte dei linguaggi di programmazione imperativi, chiunque functionpuò richiedere, modificare e ignorare qualsiasi stato a cui può accedere e la firma non fa alcuna differenza: la funzione / metodo potrebbe fare qualcosa con lo stato globale o con un singleton. Le firme spesso mentono.

Non sto cercando di creare una falsa dicotomia tra "Tutto è una mappa" e un codice OO mal progettato . Sto solo cercando di sottolineare che avere firme che accettano parametri meno / più fini / a grana grossa non garantisce che tu sappia come isolare, configurare e chiamare una funzione.

Ma, se mi permettessi di usare quella falsa dicotomia: rispetto alla scrittura di JavaScript nel modo tradizionale OOP, "Tutto è una mappa" sembra migliore. Nel modo tradizionale OOP, la funzione può richiedere, modificare o ignorare lo stato in cui si passa o dichiarare che non si passa. Con questo modello "Tutto è una mappa", è necessario solo, modificare o ignorare lo stato che si passa nel.

  • Puoi riorganizzare in modo sicuro l'ordine delle funzioni?

Nel mio codice, sì. Vedi il mio secondo commento alla risposta di @ Evicatos. Forse questo è solo perché sto realizzando un gioco, non posso dire. In un gioco che aggiorna 60 volte al secondo, non importa se dead guys drop lootpoi good guys pick up looto viceversa. Ogni funzione fa esattamente quello che dovrebbe fare indipendentemente dall'ordine in cui viene eseguita. Gli stessi dati vengono semplicemente inseriti in essi a updatechiamate diverse se si scambia l'ordine. Se si dispone good guys pick up lootpoi dead guys drop loot, i bravi ragazzi prenderanno il bottino nella prossima updateed è un grosso problema. Un essere umano non sarà in grado di notare la differenza.

Almeno questa è stata la mia esperienza generale. Mi sento davvero vulnerabile ad ammetterlo pubblicamente. Forse considerare che va bene è una cosa molto , molto brutta da fare. Fammi sapere se ho fatto un terribile errore qui. Ma, se devo, è estremamente facile per riorganizzare le funzioni per cui l'ordine è dead guys drop lootquindi good guys pick up lootdi nuovo. Ci vorrà meno tempo del tempo impiegato per scrivere questo paragrafo: P

Forse pensi che "i ragazzi morti dovrebbero prima abbandonare il bottino. Sarebbe meglio se il tuo codice imponesse quell'ordine". Ma perché i nemici dovrebbero perdere bottino prima di poter raccogliere bottino? Per me questo non ha senso. Forse il bottino è stato lasciato cadere 100 updatesfa. Non è necessario verificare se un cattivo arbitrario deve raccogliere bottino che è già a terra. Ecco perché penso che l'ordine di queste operazioni sia completamente arbitrario.

È naturale scrivere passaggi disaccoppiati con questo modello, ma è difficile notare i passi associati in OOP tradizionale. Se stavo scrivendo OOP tradizionale, il modo naturale e ingenuo di pensare è di rendere il dead guys drop lootritorno un Lootoggetto che devo passare good guys pick up loot. Non sarei in grado di riordinare quelle operazioni poiché il primo restituisce l'input del secondo.

In un linguaggio orientato agli oggetti, il modello ha ancora meno senso, perché lo stato di tracciamento è ciò che fanno gli oggetti.

Gli oggetti hanno stato ed è idiomatico mutare lo stato facendo scomparire la sua storia ... a meno che non si scriva manualmente il codice per tenerne traccia. In che modo il tracking state "cosa fanno"?

Inoltre, i benefici dell'immutabilità si riducono tanto più grande diventa il tuo oggetto immutabile.

Giusto, come ho detto, "È raro che nessuna delle mie funzioni sia pura". Operano sempre solo sui loro parametri, ma mutano i loro parametri. Questo è un compromesso che ho sentito di dover fare quando si applica questo modello a JavaScript.


4
"Il primo parametro di una funzione può essere chiamato pippo, ma che cos'è?" Ecco perché non dai il nome ai tuoi parametri "pippo", ma "ripetizioni", "genitore" e altri nomi che rendono ovvio ciò che ci si aspetta quando combinato con il nome della funzione.
Sebastian Redl,

1
Devo essere d'accordo con te su tutti i punti. L'unico problema che Javascript pone davvero con questo modello, è che stai lavorando su dati mutabili e, come tale, è molto probabile che muti lo stato. C'è comunque una libreria che ti dà accesso alle strutture dati di clojure in javascript semplice, anche se dimentico come si chiama. Passando argomenti come un oggetto non è inaudito, jquery fa questo più posti, ma documenta quali parti dell'oggetto usano. Personalmente, vorrei separare i campi UI e GameLogic, ma qualunque cosa
funzioni

@SebastianRedl Per cosa dovrei passare parent? È un repetitionsarray di numeri o stringhe o non importa? O forse le ripetizioni sono solo un numero per rappresentare il numero di repressioni che desidero? Ci sono un sacco di API là fuori che prendono solo un oggetto opzioni . Il mondo è un posto migliore se si nominano le cose correttamente, ma non garantisce che saprai come usare l'API, senza fare domande.
Daniel Kaplan,

8

Ho scoperto che il mio codice tende a finire strutturato in questo modo:

  • Le funzioni che prendono le mappe tendono ad essere più grandi e hanno effetti collaterali.
  • Le funzioni che accettano argomenti tendono ad essere più piccole e pure.

Non ho deciso di creare questa distinzione, ma spesso è così che finisce nel mio codice. Non penso che usare uno stile neghi necessariamente l'altro.

Le funzioni pure sono facili da testare. Quelle più grandi con mappe entrano maggiormente nell'area di test di "integrazione" poiché tendono a coinvolgere più parti mobili.

In javascript, una cosa che aiuta molto è usare qualcosa come la libreria Match di Meteor per eseguire la validazione dei parametri. Rende molto chiaro cosa si aspetta la funzione e può gestire le mappe in modo abbastanza pulito.

Per esempio,

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
}

Vedi http://docs.meteor.com/#match per ulteriori informazioni.

:: AGGIORNARE ::

Anche la registrazione video di Stuart Sierra di Clojure / West "Clojure in the Large" tocca questo argomento. Come l'OP, controlla gli effetti collaterali come parte della mappa, quindi i test diventano molto più facili. Ha anche un post sul blog che delinea il suo attuale flusso di lavoro Clojure che sembra rilevante.


1
Penso che i miei commenti a @Evicatos elaboreranno la mia posizione qui. Sì, sto mutando e le funzioni non sono pure. Ma le mie funzioni sono davvero facili da testare, specialmente col senno di poi per difetti di regressione che non avevo intenzione di testare. La metà del merito va a JS: è molto facile costruire una "mappa" / oggetto con solo i dati di cui ho bisogno per il mio test. Quindi è semplice come passarlo e controllare le mutazioni. Gli effetti collaterali sono sempre rappresentati nella mappa, quindi sono facili da testare.
Daniel Kaplan,

1
Credo che l'uso pragmatico di entrambi i metodi sia il modo "corretto" di procedere. Se il test è facile per te e puoi mitigare il meta problema di comunicare i campi richiesti ad altri sviluppatori, allora sembra una vittoria. Grazie per la tua domanda; Mi è piaciuto leggere l'interessante discussione che hai iniziato.
dal

5

L'argomento principale che mi viene in mente contro questa pratica è che è molto difficile dire di quali dati una funzione abbia effettivamente bisogno.

Ciò significa che i futuri programmatori nella base di codice dovranno sapere come funziona la funzione chiamata internamente - e qualsiasi chiamata di funzione nidificata - per poterla chiamare.

Più ci penso, più il tuo oggetto GameState ha l'odore di un globale. Se è così che viene utilizzato, perché passarlo in giro?


1
Sì, dal momento che di solito lo muto, è un globale. Perché preoccuparsi di passarlo in giro? Non lo so, questa è una domanda valida. Ma il mio istinto mi dice che se smettessi di passarlo, il mio programma diventerebbe immediatamente più difficile da ragionare. Ogni funzione potrebbe fare tutto o niente allo stato globale. Così com'è ora, vedi quel potenziale nella firma della funzione. Se non puoi dirlo, non sono sicuro di nulla di tutto questo :)
Daniel Kaplan,

1
BTW: re: l'argomento principale contro di esso: sembra vero se questo fosse in clojure o javascript. Ma è un punto prezioso da sottolineare. Forse i benefici elencati superano di gran lunga il negativo di quello.
Daniel Kaplan,

2
Ora so perché mi preoccupo di passarlo anche se è una variabile globale: mi permette di scrivere funzioni pure. Se cambio il mio gameState = f(gameState)in f(), è molto più difficile da testare f. f()può restituire una cosa diversa ogni volta che la chiamo. Ma è facile f(gameState)restituire la stessa cosa ogni volta che viene dato lo stesso input.
Daniel Kaplan,

3

C'è un nome più appropriato per quello che stai facendo di Big ball of mud . Quello che stai facendo è chiamato il modello a oggetti di Dio . A prima vista non sembra così, ma in Javascript c'è una differenza molto piccola tra

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

e

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

Che sia o meno una buona idea, dipende probabilmente dalle circostanze. Ma è certamente in linea con il modo Clojure. Uno degli obiettivi di Clojure è quello di rimuovere quella che Rich Hickey chiama "complessità incidentale". Più oggetti comunicanti sono certamente più complessi di un singolo oggetto. Se dividi la funzionalità in più oggetti, devi improvvisamente preoccuparti della comunicazione e del coordinamento e della divisione delle responsabilità. Quelle sono complicazioni che sono solo secondarie al tuo obiettivo originale di scrivere un programma. Dovresti vedere il discorso di Rich Hickey Simple reso facile . Penso che sia un'ottima idea.


Domanda correlata e più generale [ programmers.stackexchange.com/questions/260309/… modellizzazione dei dati vs classi tradizionali)
user7610

"Nella programmazione orientata agli oggetti, un oggetto dio è un oggetto che sa troppo o fa troppo. L'oggetto dio è un esempio di anti-modello." Quindi l'oggetto dio non è una buona cosa, eppure il tuo messaggio sembra dire il contrario. Questo è un po 'confuso per me.
Daniel Kaplan,

@tieTYT non stai eseguendo una programmazione orientata agli oggetti, quindi è ok
user7610

Come sei arrivato a questa conclusione (quella "va bene")?
Daniel Kaplan,

Il problema con l'oggetto Dio in OO è che "L'oggetto diventa così consapevole di tutto o tutti gli oggetti diventano così dipendenti dall'oggetto dio che quando c'è un cambiamento o un bug da correggere, diventa un vero incubo da implementare". source Nel tuo codice ci sono altri oggetti accanto all'oggetto God, quindi la seconda parte non è un problema. Per quanto riguarda la prima parte, il tuo oggetto Dio è il programma e il tuo programma dovrebbe essere consapevole di tutto. Quindi va bene lo stesso.
user7610

2

Oggi ho appena affrontato questo argomento mentre giocavo con un nuovo progetto. Sto lavorando a Clojure per fare una partita di poker. Ho rappresentato valori-faccia e semi come parole chiave e ho deciso di rappresentare una carta come una mappa

{ :face :queen :suit :hearts }

Potrei anche averli fatti elenchi o vettori dei due elementi di parole chiave. Non so se fa differenza in termini di memoria / prestazioni, quindi per ora vado con le mappe.

Nel caso in cui dovessi cambiare idea in seguito, ho deciso che la maggior parte delle parti del mio programma dovesse passare attraverso una "interfaccia" per accedere ai pezzi di una carta, in modo che i dettagli dell'implementazione fossero controllati e nascosti. Ho delle funzioni

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

che utilizza il resto del programma. Le carte vengono passate alle funzioni come mappe, ma le funzioni utilizzano un'interfaccia concordata per accedere alle mappe e quindi non dovrebbero essere in grado di sbagliare.

Nel mio programma, una carta sarà probabilmente sempre e solo una mappa a 2 valori. Nella domanda, l'intero stato del gioco viene passato come una mappa. Lo stato del gioco sarà molto più complicato di una singola carta, ma non credo che ci sia colpa da sollevare sull'uso di una mappa. In un linguaggio imperativo agli oggetti, potrei anche avere un unico grande oggetto GameState e chiamarne i metodi, e avere lo stesso 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

Ora è orientato agli oggetti. C'è qualcosa di particolarmente sbagliato in questo? Non credo, stai solo delegando il lavoro a funzioni che sanno come gestire un oggetto State. E se stai lavorando con mappe o oggetti, dovresti stare attento a quando dividerlo in pezzi più piccoli. Quindi dico che usare le mappe va benissimo, purché usi la stessa cura che useresti per gli oggetti.


2

Da quello che (poco) ho visto, usare mappe o altre strutture nidificate per creare un singolo oggetto di stato immutabile globale come questo è abbastanza comune nei linguaggi funzionali, almeno quelli puri, specialmente quando si usa la Monade dello Stato come @ Ptharien'sFlame mentioend .

Due blocchi stradali per usarlo in modo efficace di cui ho visto / letto (e altre risposte qui citate) sono:

  • Mutazione di un valore (profondamente) nidificato nello stato (immutabile)
  • Nascondere la maggior parte dello stato da funzioni che non ne hanno bisogno e dare loro solo quel poco di cui hanno bisogno per lavorare / mutare

Esistono un paio di tecniche / modelli comuni che possono aiutare ad alleviare questi problemi:

Il primo è Cerniere : queste permettono di attraversare e mutare lo stato in profondità all'interno di una gerarchia nidificata immutabile.

Un altro è Lenti : ti permettono di concentrarti nella struttura in una posizione specifica e di leggere / mutare il valore lì. Puoi combinare obiettivi diversi per concentrarti su cose diverse, un po 'come una catena di proprietà regolabile in OOP (dove puoi sostituire le variabili con nomi di proprietà reali!)

Prismatic ha recentemente pubblicato un post sul blog sull'utilizzo di questo tipo di tecnica, tra le altre cose, in JavaScript / ClojureScript, che dovresti dare un'occhiata. Usano i cursori (che confrontano con le cerniere) per indicare lo stato della finestra per le funzioni:

Om ripristina l'incapsulamento e la modularità usando i cursori. I cursori forniscono finestre aggiornabili in parti particolari dello stato dell'applicazione (in modo molto simile alle cerniere lampo), consentendo ai componenti di prendere riferimenti solo alle parti pertinenti dello stato globale e aggiornarle in modo privo di contesto.

IIRC, toccano anche l'immutabilità in JavaScript in quel post.


Il discorso OP menzionato discute anche l'uso della funzione di aggiornamento per limitare l'ambito che una funzione può aggiornare a una sottostruttura della mappa di stato. Penso che nessuno lo abbia ancora sollevato.
user7610

@ user7610 Buona cattura, non riesco a credere di aver dimenticato di menzionarlo - Mi piace quella funzione (ed assoc-inaltri). Immagino di avere appena avuto Haskell nel cervello. Mi chiedo se qualcuno ne abbia fatto una porta JavaScript? Probabilmente le persone non l'hanno sollevato perché (come me) non hanno visto il discorso :)
Paolo

@paul in un certo senso lo hanno perché è disponibile in ClojureScript, ma non sono sicuro che questo "conta" nella tua mente. Potrebbe esistere in PureScript e credo che ci sia almeno una libreria che fornisce strutture di dati immutabili in JavaScript. Spero che almeno uno di questi abbia questi, altrimenti sarebbero scomodi da usare.
Daniel Kaplan,

@tieTYT Avevo pensato a un'implementazione nativa di JS quando ho fatto quel commento, ma hai fatto un buon punto su ClojureScript / PureScript. Dovrei esaminare l'immutabile JS e vedere cosa c'è là fuori, non ho mai lavorato prima.
Paolo

1

Se questa è una buona idea o meno dipenderà davvero da cosa stai facendo con lo stato all'interno di quei sottoprocessi. Se capisco correttamente l'esempio di Clojure, i dizionari di stato che vengono restituiti non sono gli stessi dizionari di stato che vengono passati. Sono copie, possibilmente con aggiunte e modifiche, che (presumo) Clojure è in grado di creare in modo efficiente perché il la natura funzionale della lingua dipende da essa. I dizionari di stato originali per ciascuna funzione non vengono modificati in alcun modo.

Se ho la comprensione correttamente, si sta modificando gli oggetti di stato si passa nelle vostre funzioni JavaScript invece di restituire una copia, il che significa che si sta facendo qualcosa di molto, molto, diverso da quello che il codice Clojure sta facendo. Come ha sottolineato Mike Partridge, questo è fondamentalmente solo un globale a cui si passa esplicitamente e si ritorna da funzioni senza motivo reale. A questo punto penso che ti stia semplicemente facendo pensare che stai facendo qualcosa che in realtà non stai facendo.

Se stai effettivamente facendo esplicitamente copie dello stato, modificandolo e quindi restituendo quella copia modificata, continua. Non sono sicuro che sia necessariamente il modo migliore per realizzare ciò che stai cercando di fare in Javascript, ma è probabilmente "vicino" a ciò che sta facendo quell'esempio Clojure.


1
Alla fine è davvero "molto, molto diverso"? Nell'esempio Clojure sovrascrive il suo vecchio stato con il nuovo stato. Sì, non c'è vera mutazione in corso, l'identità sta cambiando. Ma nel suo "buon" esempio, come scritto, non ha modo di ottenere la copia che è stata passata al sottoprocesso-due. L'identificazione di quel valore è stata sovrascritta. Pertanto, penso che la cosa "molto, molto diversa" sia in realtà solo un dettaglio di implementazione del linguaggio. Almeno nel contesto di ciò che fai apparire.
Daniel Kaplan,

2
Ci sono due cose che stanno succedendo nell'esempio Clojure: 1) il primo esempio dipende dalle funzioni chiamate in un certo ordine, e 2) le funzioni sono pure quindi non hanno effetti collaterali. Poiché le funzioni del secondo esempio sono pure e condividono la stessa firma, è possibile riordinarle senza doversi preoccupare di dipendenze nascoste nell'ordine in cui vengono chiamate. Se stai mutando lo stato nelle tue funzioni, non hai la stessa garanzia. La mutazione di stato significa che la tua versione non è così compostabile, che era il motivo originale del dizionario.
Evicatos,

1
Dovrai mostrarmi un esempio perché nella mia esperienza con questo, posso spostare le cose a piacimento e ha pochissimo effetto. Solo per dimostrarlo, ho spostato due chiamate di sottoprocesso casuali nel mezzo della mia update()funzione. Ne ho spostato uno in alto e uno in basso. Tutti i miei test sono ancora passati e quando ho giocato il mio gioco non ho notato effetti negativi. Sento le mie funzioni sono proprio come componibili come l'esempio Clojure. Eliminiamo entrambi i nostri vecchi dati dopo ogni passaggio.
Daniel Kaplan,

1
I test che superano e non si notano effetti negativi significa che attualmente non si sta mutando uno stato che ha effetti collaterali imprevisti in altri luoghi. Dal momento che le tue funzioni non sono pure, anche se non hai alcuna garanzia che sarà sempre così. Penso che dovrei fondamentalmente fraintendere qualcosa sulla tua implementazione se dici che le tue funzioni non sono pure ma stai gettando via i tuoi vecchi dati dopo ogni passaggio.
Evicatos,

1
@Evicatos - Essere puri e avere la stessa firma non implica che l'ordine delle funzioni non abbia importanza. Immagina di calcolare un prezzo con sconti fissi e percentuali applicati. (-> 10 (- 5) (/ 2))restituisce 2.5. (-> 10 (/ 2) (- 5))restituisce 0.
Zak,

1

Se si dispone di un oggetto stato globale, a volte chiamato "oggetto dio", che viene passato a ciascun processo, si finisce per confondere una serie di fattori, tutti i quali aumentano l'accoppiamento, riducendo la coesione. Tutti questi fattori incidono negativamente sulla manutenibilità a lungo termine.

Accoppiamento vagabondo Ciò deriva dal passaggio di dati attraverso vari metodi che non hanno bisogno di quasi tutti i dati, al fine di portarli nel luogo che può effettivamente gestirli. Questo tipo di accoppiamento è simile all'utilizzo di dati globali, ma può essere più contenuto. L'accoppiamento del vagabondo è l'opposto del "bisogno di sapere", che viene utilizzato per localizzare gli effetti e contenere il danno che un pezzo di codice errato può avere sull'intero sistema.

Navigazione dei dati Ogni sotto-processo nel tuo esempio deve sapere come ottenere esattamente i dati di cui ha bisogno e deve essere in grado di elaborarli e forse costruire un nuovo oggetto di stato globale. Questa è la logica conseguenza dell'accoppiamento del vagabondo; è necessario l'intero contesto di un dato per operare sul dato. Ancora una volta, la conoscenza non locale è una cosa negativa.

Se stavi passando una "cerniera", un "obiettivo" o un "cursore", come descritto nel post di @paul, questa è una cosa. Dovresti contenere l'accesso e consentire alla cerniera, ecc., Di controllare la lettura e la scrittura dei dati.

Violazione della singola responsabilità Rivendicare ciascuno di "sottoprocesso-uno", "sottoprocesso-due" e "sottoprocesso-tre" ha una sola responsabilità, vale a dire produrre un nuovo oggetto di stato globale con i giusti valori in esso, è un riduzionismo egregio. È tutto alla fine, no?

Il mio punto qui è che avere tutti i componenti principali del tuo gioco ha tutte le stesse responsabilità del gioco che sconfigge lo scopo della delega e del factoring.

Impatto sui sistemi

L'impatto principale del progetto è la scarsa manutenibilità. Il fatto che tu possa tenere tutto il gioco in testa dice che molto probabilmente sei un programmatore eccellente. Ci sono molte cose che ho progettato che potrei tenere in testa per l'intero progetto. Tuttavia, non è questo il punto dell'ingegneria dei sistemi. Il punto è creare un sistema in grado di funzionare per qualcosa di più grande di quanto una persona possa tenere in testa contemporaneamente .

L'aggiunta di un altro programmatore, o due, o otto, farà crollare il sistema quasi immediatamente.

  1. La curva di apprendimento per gli oggetti divini è piatta (vale a dire, ci vuole molto tempo per diventare competenti in essi). Ogni programmatore aggiuntivo dovrà imparare tutto ciò che sai e tenerlo nella loro testa. Sarai sempre in grado di assumere programmatori migliori di te, supponendo che tu possa pagarli abbastanza per soffrire mantenendo un enorme oggetto divino.
  2. Il provisioning di test, nella descrizione, è solo una casella bianca. Devi conoscere ogni dettaglio dell'oggetto god, così come il modulo sotto test, al fine di impostare un test, eseguirlo e determinare che a) ha fatto la cosa giusta e b) non ha fatto nulla di 10.000 cose sbagliate. Le probabilità sono notevolmente accumulate contro di te.
  3. L'aggiunta di una nuova funzionalità richiede che a) si sottoponga a tutti i sottoprocessi e si determini se la funzione influisce su qualsiasi codice in essa contenuto e viceversa, b) si passa attraverso lo stato globale e si progettano le aggiunte, e c) si esegue ogni test unitario e si modifica per verificare che nessuna unità sotto test abbia influito negativamente sulla nuova funzionalità .

Finalmente

  1. Gli oggetti divini mutevoli sono stati la maledizione dell'esistenza della mia programmazione, alcuni dei miei stessi comportamenti e altri in cui sono stato intrappolato.
  2. La monade di stato non si ridimensiona. Lo stato cresce esponenzialmente, con tutte le implicazioni per i test e le operazioni che ciò implica. Il modo in cui controlliamo lo stato nei sistemi moderni è attraverso la delega (suddivisione delle responsabilità) e l'ambito (limitando l'accesso a solo un sottoinsieme dello stato). L'approccio "tutto è una mappa" è l'esatto contrario del controllo dello stato.
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.