In che modo la programmazione funzionale gestisce la situazione in cui si fa riferimento allo stesso oggetto da più punti?


10

Sto leggendo e sentendo che le persone (anche su questo sito) lodano regolarmente il paradigma della programmazione funzionale, sottolineando quanto sia bello avere tutto immutabile. In particolare, le persone propongono questo approccio anche in linguaggi OO tradizionalmente imperativi, come C #, Java o C ++, non solo in linguaggi puramente funzionali come Haskell che impongono questo al programmatore.

Trovo difficile da capire, perché trovo la mutabilità e gli effetti collaterali ... convenienti. Tuttavia, dato come le persone attualmente condannano gli effetti collaterali e lo considerano una buona pratica per sbarazzarsene ove possibile, credo che se voglio essere un programmatore competente devo iniziare il mio percorso verso una migliore comprensione del paradigma ... Da qui il mio Q.

Un posto in cui trovo problemi con il paradigma funzionale è quando un oggetto viene naturalmente referenziato da più punti. Lascia che lo descriva su due esempi.

Il primo esempio sarà il mio gioco C # che sto cercando di realizzare nel mio tempo libero . È un gioco a turni in cui entrambi i giocatori hanno squadre di 4 mostri e possono inviare un mostro della loro squadra sul campo di battaglia, dove affronterà il mostro inviato dal giocatore avversario. I giocatori possono anche richiamare mostri dal campo di battaglia e sostituirli con un altro mostro della loro squadra (in modo simile a Pokemon).

In questa impostazione, un singolo mostro può essere referenziato naturalmente da almeno 2 posti: la squadra di un giocatore e il campo di battaglia, che fa riferimento a due mostri "attivi".

Ora consideriamo la situazione in cui un mostro viene colpito e perde 20 punti salute. Tra le parentesi del paradigma imperativo healthmodifico il campo di questo mostro per riflettere questo cambiamento - e questo è quello che sto facendo ora. Tuttavia, ciò rende la Monsterclasse mutabile e le relative funzioni (metodi) impure, che a mio avviso sono considerate una cattiva pratica fin d'ora.

Anche se mi sono concesso il permesso di avere il codice di questo gioco in uno stato inferiore all'ideale al fine di sperare di poterlo effettivamente finire in un certo punto del futuro, vorrei sapere e capire come dovrebbe essere scritto correttamente. Pertanto: se si tratta di un difetto di progettazione, come risolverlo?

Nello stile funzionale, a quanto ho capito, farei invece una copia di questo Monsteroggetto, mantenendolo identico a quello vecchio ad eccezione di questo campo; e il metodo suffer_hitrestituirebbe questo nuovo oggetto invece di modificare quello vecchio in atto. Quindi copierei anche l' Battlefieldoggetto, mantenendo tutti i suoi campi uguali tranne questo mostro.

Questo comporta almeno 2 difficoltà:

  1. La gerarchia può facilmente essere molto più profonda di questo esempio semplificato di solo Battlefield-> Monster. Dovrei fare una tale copia di tutti i campi tranne uno e restituire un nuovo oggetto su questa gerarchia. Questo sarebbe il codice del boilerplate che trovo fastidioso soprattutto perché la programmazione funzionale dovrebbe ridurre il plateplate.
  2. Un problema molto più grave, tuttavia, è che ciò porterebbe alla sincronizzazione dei dati . Il mostro attivo del campo vedrebbe ridotta la sua salute; tuttavia, questo stesso mostro, a cui fa riferimento il suo giocatore di controllo Team, non lo farebbe. Se invece abbracciassi lo stile imperativo, ogni modifica dei dati sarebbe immediatamente visibile da tutti gli altri luoghi di codice e in casi come questo lo trovo davvero conveniente - ma il modo in cui ottengo le cose è esattamente quello che la gente dice sbagliato con lo stile imperativo!
    • Ora sarebbe possibile occuparsi di questo problema facendo un viaggio verso il Teamdopo ogni attacco. Questo è un lavoro extra. Tuttavia, cosa succede se un mostro può improvvisamente essere referenziato in seguito da ancora più luoghi? E se venissi con un'abilità che, ad esempio, consente a un mostro di concentrarsi su un altro mostro che non è necessariamente sul campo (sto davvero prendendo in considerazione tale abilità)? Avrò sicuramente ricordare di prendere anche un viaggio ai mostri focalizzate immediatelly dopo ogni attacco? Questa sembra essere una bomba a tempo che esploderà man mano che il codice diventa più complesso, quindi penso che non sia una soluzione.

Un'idea per una soluzione migliore viene dal mio secondo esempio, quando ho riscontrato lo stesso problema. Nell'accademia ci è stato detto di scrivere un interprete di una lingua di nostra progettazione in Haskell. (Questo è anche il modo in cui sono stato costretto a iniziare a capire cos'è FP). Il problema si presentava durante l'implementazione delle chiusure. Ancora una volta è possibile fare riferimento allo stesso ambito da più posizioni: tramite la variabile che contiene questo ambito e come ambito padre di tutti gli ambiti nidificati! Ovviamente, se viene apportata una modifica a questo ambito tramite uno dei riferimenti che lo puntano, questa modifica deve essere visibile anche attraverso tutti gli altri riferimenti.

La soluzione che ho fornito è stata assegnare un ID a ciascun ambito e contenere un dizionario centrale di tutti gli ambiti nella Statemonade. Ora le variabili conserverebbero solo l'ID dell'ambito a cui erano vincolate, anziché l'ambito stesso, e gli ambiti nidificati conterrebbero anche l'ID del loro ambito padre.

Immagino che lo stesso approccio potrebbe essere tentato nel mio gioco di battaglie tra mostri ... Campi e squadre non fanno riferimento ai mostri; contengono invece ID di mostri che vengono salvati in un dizionario di mostri centrale.

Tuttavia, posso ancora una volta vedere un problema con questo approccio che mi impedisce di accettarlo senza esitazione come soluzione al problema:

Ancora una volta è una fonte di codice boilerplate. Rende necessariamente una linea singola a 3 linee: ciò che prima era una modifica sul posto di una riga di un singolo campo ora richiede (a) Recuperare l'oggetto dal dizionario centrale (b) Apportare la modifica (c) Salvare il nuovo oggetto al dizionario centrale. Inoltre, contenere ID di oggetti e dizionari centrali invece di avere riferimenti aumenta la complessità. Dal momento che FP è pubblicizzato per ridurre la complessità e il codice boilerplate, questo suggerisce che sto sbagliando.

Stavo anche scrivendo del secondo problema che sembra molto più grave: questo approccio introduce perdite di memoria . Gli oggetti che non sono raggiungibili verranno normalmente raccolti. Tuttavia, gli oggetti contenuti in un dizionario centrale non possono essere garbage collection, anche se nessun oggetto raggiungibile fa riferimento a questo particolare ID. E mentre una programmazione teoricamente accurata può evitare perdite di memoria (potremmo avere cura di rimuovere manualmente ogni oggetto dal dizionario centrale quando non è più necessario), questo è soggetto a errori e FP è pubblicizzato per aumentare la correttezza dei programmi, quindi ancora una volta non essere il modo corretto.

Tuttavia, ho scoperto in tempo che sembra piuttosto essere un problema risolto. Java fornisce WeakHashMapche potrebbe essere utilizzato per risolvere questo problema. C # offre una funzione simile - ConditionalWeakTable- sebbene secondo i documenti sia destinata ai compilatori. E in Haskell abbiamo System.Mem.Weak .

Memorizzare tali dizionari è la soluzione funzionale corretta a questo problema o ce n'è uno più semplice che non riesco a vedere? Immagino che il numero di tali dizionari possa crescere facilmente e male; quindi se si suppone che anche questi dizionari siano immutabili, ciò può significare molto passaggio di parametri o, in lingue che lo supportano, calcoli monadici, dal momento che i dizionari sarebbero tenuti in monadi (ma ancora una volta lo sto leggendo in modo puramente funzionale le lingue con il minor codice possibile dovrebbero essere monadiche, mentre questa soluzione del dizionario collocherebbe quasi tutto il codice all'interno della Statemonade; il che ancora una volta mi fa dubitare che questa sia la soluzione corretta.)

Dopo alcune considerazioni penso che aggiungerei un'altra domanda: cosa stiamo guadagnando costruendo tali dizionari? Che ciò che non va nella programmazione imperativa è, secondo molti esperti, che i cambiamenti in alcuni oggetti si propagano ad altri pezzi di codice. Per risolvere questo problema, si suppone che gli oggetti siano immutabili, proprio per questo motivo, se ho capito bene, che le modifiche apportate non dovrebbero essere visibili altrove. Ma ora sono preoccupato per altri pezzi di codice che operano su dati non aggiornati, quindi invento dizionari centrali in modo che ... ancora una volta le modifiche in alcuni pezzi di codice si propaghino ad altri pezzi di codice! Non siamo dunque tornati allo stile imperativo con tutti i suoi presunti inconvenienti, ma con maggiore complessità?


6
Per dare questa prospettiva, i programmi funzionali immutabili sono principalmente pensati per le situazioni di elaborazione dei dati che coinvolgono la concorrenza. In altre parole, i programmi che elaborano i dati di input attraverso una serie di equazioni o processi che producono un risultato di output. L'immutabilità aiuta in questo scenario per diversi motivi: i valori letti da più thread sono garantiti per non cambiare durante il loro ciclo di vita, il che semplifica notevolmente la capacità di elaborare i dati in modo libero da blocco e la ragione del funzionamento dell'algoritmo.
Robert Harvey,

8
Il piccolo sporco segreto dell'immutabilità funzionale e della programmazione del gioco è che queste due cose sono in qualche modo incompatibili tra loro. Stai essenzialmente cercando di modellare un sistema dinamico e in continua evoluzione utilizzando una struttura dati statica e immobile.
Robert Harvey,

2
Non prendere la mutabilità contro l'immutabilità come dogma religioso. Ci sono situazioni in cui ognuna è migliore dell'altra, l'immutabilità non è sempre migliore, ad esempio scrivere un toolkit GUI con tipi di dati immutabili sarà un vero incubo.
whatsisname

1
Questa domanda specifica per C # e le sue risposte coprono il problema del boilerplate, principalmente derivante dalla necessità di creare cloni leggermente modificati (aggiornati) di un oggetto immutabile esistente.
rwong

2
Un'intuizione chiave è che un mostro in questo gioco è considerato un'entità. Inoltre, il risultato di ogni battaglia (costituito dal numero di sequenza della battaglia, dagli ID entità dei mostri, dagli stati dei mostri prima e dopo la battaglia) viene considerato uno stato in un determinato momento (o fase temporale). Pertanto, i giocatori ( Team) possono recuperare l'esito della battaglia e quindi gli stati dei mostri mediante una tupla (numero di battaglia, ID entità mostro).
rwong

Risposte:


19

In che modo la Programmazione funzionale gestisce un oggetto referenziato da più punti? Ti invita a rivisitare il tuo modello!

Per spiegare ... diamo un'occhiata a come a volte vengono scritti i giochi in rete, con una copia centrale "golden source" dello stato del gioco e una serie di eventi client in entrata che aggiornano tale stato e quindi vengono trasmessi agli altri client .

Puoi leggere del divertimento che il team Factorio ha avuto nel far sì che questo si comportasse bene in alcune situazioni; ecco una breve panoramica del loro modello:

Il modo base in cui funziona il nostro multiplayer è che tutti i client simulano lo stato del gioco e ricevono e inviano solo l'input del giocatore (chiamato Input Actions). La responsabilità principale del server è il proxy delle azioni di input e la garanzia che tutti i client eseguano le stesse azioni con lo stesso tick.

Poiché il server deve arbitrare quando vengono eseguite le azioni, un'azione del giocatore sposta qualcosa del genere: Azione del giocatore -> Client di gioco -> Rete -> Server -> Rete-> Client di gioco. Ciò significa che ogni azione del giocatore viene eseguita solo dopo aver effettuato un round trip attraverso la rete. Ciò renderebbe il gioco molto lento, ecco perché nascondere la latenza è stato un meccanismo aggiunto nel gioco quasi dall'introduzione del multiplayer. La latenza nascosta funziona simulando l'input del giocatore, senza considerare le azioni di altri giocatori e senza considerare l'arbitraggio del server.

In Factorio abbiamo lo stato del gioco, questo è lo stato completo della mappa, giocatore, entità, tutto. Viene simulato in modo deterministico su tutti i client in base alle azioni ricevute dal server. Questo è sacro e se è mai diverso dal server o da qualsiasi altro client, si verifica una desincronizzazione.

Oltre allo stato di gioco abbiamo lo stato di latenza. Questo contiene un piccolo sottoinsieme dello stato principale. Lo stato di latenza non è sacro e rappresenta solo come pensiamo che lo stato del gioco apparirà in futuro in base alle azioni di input eseguite dal giocatore.

La cosa fondamentale è che lo stato di ogni oggetto è immutabile al tick specifico nella linea del tempo . Tutto nello stato multiplayer globale alla fine deve convergere in una realtà deterministica.

E - quella potrebbe essere la chiave della tua domanda. Lo stato di ogni entità è immutabile per un dato tick e si tiene traccia degli eventi di transizione che producono nuove istanze nel tempo.

Se ci pensate, la coda degli eventi in arrivo dal server deve avere accesso a una directory centrale di entità, solo per poter applicare i suoi eventi.

Alla fine, i tuoi semplici metodi di mutatore a una riga che non vuoi complicare sono semplici solo perché non stai davvero modellando il tempo con precisione. Dopotutto, se la salute può cambiare nel mezzo del ciclo di elaborazione, allora le entità precedenti in questo segno di spunta vedranno un vecchio valore e quelle successive ne vedranno uno modificato. Gestire questo con attenzione significa almeno differenziare gli stati attuali (immutabili) e successivi (in costruzione), che sono in realtà solo due tick nella grande linea del tempo!

Quindi, come ampia guida, considera di spezzare lo stato di un mostro in un numero di piccoli oggetti che si riferiscono a, diciamo, posizione / velocità / fisica, salute / danno, beni. Costruisci un evento per descrivere ogni mutazione che potrebbe accadere ed esegui il tuo ciclo principale come:

  1. elaborare input e generare eventi corrispondenti
  2. generare eventi interni (ad es. a causa di collisioni di oggetti, ecc.)
  3. applica eventi agli attuali mostri immutabili, per generare nuovi mostri per il prossimo tick - per lo più copiando il vecchio stato invariato ove possibile, ma creando nuovi oggetti di stato dove necessario.
  4. eseguire il rendering e ripetere per il prossimo segno di spunta.

O qualcosa di simile. Trovo pensando "come lo farei distribuire?" è piuttosto un buon esercizio mentale, in generale, per affinare la mia comprensione quando sono confuso su dove le cose vivono e su come dovrebbero evolversi.

Grazie a una nota di @ AaronM.Eshbach, che evidenzia che si tratta di un dominio problema simile a Event Sourcing e al modello CQRS , in cui si stanno modellando le modifiche allo stato in un sistema distribuito come una serie di eventi immutabili nel tempo . In questo caso, molto probabilmente stiamo cercando di ripulire un'app di database complessa, separando (come suggerisce il nome!) La gestione dei comandi del mutatore dal sistema di query / visualizzazione. Più complesso ovviamente, ma più flessibile.


2
Per ulteriori riferimenti, consultare Event Sourcing e CQRS . Questo è un dominio problematico simile: la modellazione cambia in uno stato di sistema distribuito come una serie di eventi immutabili nel tempo.
Aaron M. Eshbach,

@ AaronM.Eshbach è quello! Ti dispiace se includo il tuo commento / citazioni nella risposta? Lo rende più autorevole. Grazie!
SusanW,

Certo che no, per favore.
Aaron M. Eshbach il

3

Sei ancora la metà nel campo imperativo. Invece di pensare a un singolo oggetto alla volta, pensa al tuo gioco in termini di storia di giochi o eventi

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

eccetera

È possibile calcolare lo stato del gioco in un determinato punto concatenando le azioni per produrre un oggetto stato immutabile. Ogni riproduzione è una funzione che accetta un oggetto stato e restituisce un nuovo oggetto 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.