Usi di strutture di dati persistenti in linguaggi non funzionali


17

I linguaggi puramente funzionali o quasi puri beneficiano di strutture di dati persistenti perché sono immutabili e si adattano bene allo stile apolide della programmazione funzionale.

Ma di tanto in tanto vediamo librerie di strutture di dati persistenti per linguaggi (basati sullo stato, OOP) come Java. Un'affermazione spesso sentita a favore di strutture di dati persistenti è che, poiché sono immutabili, sono thread-safe .

Tuttavia, il motivo per cui le strutture di dati persistenti sono thread-safe è che se un thread dovesse "aggiungere" un elemento a una raccolta persistente, l'operazione restituirà una nuova raccolta come l'originale ma con l'elemento aggiunto. Altre discussioni vedono quindi la collezione originale. Le due collezioni condividono molto stato interno, ovviamente - ecco perché queste strutture persistenti sono efficienti.

Ma poiché thread diversi visualizzano stati di dati diversi, sembrerebbe che le strutture di dati persistenti non siano di per sé sufficienti a gestire scenari in cui un thread apporta una modifica visibile ad altri thread. Per questo, sembra che dobbiamo usare dispositivi come atomi, riferimenti, memoria transazionale del software o persino classici blocchi e meccanismi di sincronizzazione.

Perché allora l'immutabilità dei PDS è propagandata come qualcosa di benefico per la "sicurezza dei thread"? Esistono esempi concreti in cui i PDS aiutano nella sincronizzazione o nella risoluzione di problemi di concorrenza? Oppure i PDS sono semplicemente un modo per fornire un'interfaccia senza stato a un oggetto a supporto di uno stile di programmazione funzionale?


3
Continui a dire "persistente". Intendi davvero "persistente" come in "capace di sopravvivere a un riavvio del programma", o semplicemente "immutabile" come in "non cambia mai dopo la sua creazione"?
Kilian Foth,

17
@KilianFoth Le strutture di dati persistenti hanno una definizione ben definita : "una struttura di dati persistenti è una struttura di dati che conserva sempre la versione precedente di se stessa quando viene modificata". Quindi si tratta di riutilizzare la struttura precedente quando viene creata una nuova struttura basata su di essa piuttosto che la persistenza come "in grado di sopravvivere al riavvio di un programma".
Michał Kosmulski,

3
La tua domanda sembra essere meno sull'uso di strutture di dati persistenti in linguaggi non funzionali e più su quali parti di concorrenza e parallelismo non sono risolte da loro, indipendentemente dal paradigma.

Errore mio. Non sapevo che "struttura dei dati persistente" è un termine tecnico distinto dalla mera persistenza.
Kilian Foth,

@delnan Sì, è corretto.
Ray Toal,

Risposte:


15

Le strutture di dati persistenti / immutabili non risolvono da sole i problemi di concorrenza, ma li rendono molto più facili.

Considera un thread T1 che passa un set S ad un altro thread T2. Se S è mutabile, T1 ha un problema: perde il controllo di ciò che accade con S. Il thread T2 può modificarlo, quindi T1 non può fare affidamento sul contenuto di S. E viceversa - T2 non può essere sicuro che T1 non modifica S mentre T2 vi opera.

Una soluzione è aggiungere una sorta di contratto alla comunicazione di T1 e T2 in modo che solo uno dei thread sia autorizzato a modificare S. Questo è soggetto a errori e grava sia sulla progettazione che sull'implementazione.

Un'altra soluzione è che T1 o T2 clonino la struttura dei dati (o entrambi, se non sono coordinati). Tuttavia, se S non è persistente, si tratta di un'operazione O (n) costosa .

Se hai una struttura di dati persistente, sei libero da questo onere. Puoi passare una struttura a un altro thread e non devi preoccuparti di cosa ci fa. Entrambi i thread hanno accesso alla versione originale e possono fare operazioni arbitrarie su di essa - non influenza ciò che vede l'altro thread.

Vedi anche: struttura dei dati persistente vs immutabile .


2
Ah, quindi "sicurezza dei thread" in questo contesto significa solo che un thread non deve preoccuparsi che altri thread distruggano i dati che vedono, ma non ha nulla a che fare con la sincronizzazione e la gestione dei dati che vogliamo condividere tra i thread. Questo è in linea con quello che pensavo, ma +1 per affermare elegantemente "non risolvere i problemi di valuta da soli".
Ray Toal,

2
@RayToal Sì, in questo contesto "thread safe" significa esattamente questo. Il modo in cui i dati sono condivisi tra i thread è un problema diverso, che ha molte soluzioni, come hai già detto (personalmente mi piace STM per la sua componibilità). La sicurezza del thread garantisce che non devi preoccuparti di ciò che accade con i dati dopo la condivisione. Questo in realtà è un grosso problema, perché i thread non devono sincronizzare chi lavora su una struttura di dati e quando.
Petr Pudlák,

@RayToal Ciò consente a eleganti modelli di concorrenza come gli attori , che evitano agli sviluppatori di avere a che fare con il blocco esplicito e la gestione dei thread e che si basano sull'immutabilità dei messaggi: non sai quando un messaggio viene recapitato ed elaborato o quale altro attori a cui è stato trasmesso.
Petr Pudlák,

Grazie Petr, darò agli attori un'altra occhiata. Conosco tutti i meccanismi Clojure e ho notato che Rich Hickey ha esplicitamente scelto di non usare il modello dell'attore , almeno come esemplificato in Erlang. Tuttavia, più sai, meglio è.
Ray Toal,

@RayToal Un link interessante, grazie. Ho usato solo attori come esempio, non che sto dicendo che sarebbe la soluzione migliore. Non ho usato Clojure, ma sembra che la sua soluzione preferita sia STM, che preferirei sicuramente agli attori. STM si basa anche sulla persistenza / immutabilità: non sarebbe possibile riavviare una transazione se modifica irrevocabilmente una struttura di dati.
Petr Pudlák,

5

Perché allora l'immutabilità dei PDS è propagandata come qualcosa di benefico per la "sicurezza dei thread"? Esistono esempi concreti in cui i PDS aiutano nella sincronizzazione o nella risoluzione di problemi di concorrenza?

Il vantaggio principale di un PDS in quel caso è che puoi modificare una porzione di dati senza rendere tutto unico (senza copiare in profondità tutto, per così dire). Ciò ha molti potenziali vantaggi oltre a consentire di scrivere funzioni economiche prive di effetti collaterali: istanza di copia e incolla di dati, sistemi di annullamento banali, banali funzioni di riproduzione nei giochi, editing banale non distruttivo, sicurezza di banale eccezione, ecc. Ecc. Ecc.


2

Si può immaginare una struttura di dati che sarebbe persistente ma mutevole. Ad esempio, è possibile prendere un elenco collegato, rappresentato da un puntatore al primo nodo, e un'operazione di prepend che restituisce un nuovo elenco, costituito da un nuovo nodo head più l'elenco precedente. Dato che hai ancora il riferimento all'intestazione precedente, puoi accedere e modificare questo elenco, che nel frattempo è stato incorporato anche nel nuovo elenco. Sebbene possibile, un tale paradigma non offre i vantaggi di strutture di dati persistenti e immutabili, ad esempio non è certamente sicuro per impostazione predefinita. Tuttavia, può avere i suoi usi purché lo sviluppatore sappia cosa stanno facendo, ad esempio per l'efficienza dello spazio. Si noti inoltre che mentre la struttura può essere mutabile a livello di lingua in quanto nulla impedisce al codice di modificarlo,

Per farla breve, senza immutabilità (imposta dalla lingua o per convenzione), la persistenza di strutture di dati perde alcuni dei suoi vantaggi (sicurezza del thread) ma non altri (efficienza dello spazio per alcuni scenari).

Come per gli esempi da linguaggi non funzionali, Java String.substring()usa quella che definirei una struttura di dati persistente. La stringa è rappresentata da un array di caratteri più gli offset iniziale e finale dell'intervallo dell'array che viene effettivamente utilizzato. Quando viene creata una sottostringa, il nuovo oggetto riutilizza la stessa matrice di caratteri, solo con offset di inizio e fine modificati. Poiché Stringè immutabile, è (rispetto substring()all'operazione, non ad altri) una struttura di dati persistente immutabile.

L'immutabilità delle strutture dati è la parte rilevante per la sicurezza del thread. La loro persistenza (riutilizzo di blocchi esistenti quando viene creata una nuova struttura) è rilevante per l'efficienza quando si lavora con tali raccolte. Poiché sono immutabili, un'operazione come l'aggiunta di un elemento non modifica la struttura esistente ma ne restituisce una nuova, con l'aggiunta dell'elemento aggiuntivo. Se ogni volta che l'intera struttura fosse copiata, iniziando con una raccolta vuota e aggiungendo 1000 elementi uno per uno per finire con una raccolta di 1000 elementi, si creerebbero oggetti temporanei con 0 + 1 + 2 + ... + 999 = 500000 elementi in totale che sarebbe un enorme spreco. Con strutture di dati persistenti, questo può essere evitato poiché la raccolta di 1 elemento viene riutilizzata in quella a 2 elementi, che viene riutilizzata in quella a 3 elementi e così via,


A volte è utile avere oggetti quasi immutabili in cui tutti gli aspetti dello stato tranne uno sono immutabili: la capacità di creare un oggetto il cui stato è quasi come un dato oggetto. Ad esempio, un AppendOnlyList<T>supporto di due crescenti array potrebbe produrre istantanee immutabili senza dover copiare dati per ogni istantanea, ma non si potrebbe produrre un elenco che contenesse il contenuto di tale istantanea, più un nuovo elemento, senza ricopiare tutto in un nuovo array.
supercat,

0

Devo ammettere che sono uno che applica tali concetti in C ++ dal linguaggio e dalla sua natura, così come dal mio dominio e persino dal modo in cui usiamo il linguaggio. Ma date queste cose, penso che i disegni immutabili siano l'aspetto meno interessante quando si tratta di raccogliere una grande quantità di benefici associati alla programmazione funzionale, come la sicurezza dei thread, la facilità di ragionamento sul sistema, trovare più riutilizzo delle funzioni (e scoprire che possiamo combinali in qualsiasi ordine senza spiacevoli sorprese), ecc.

Prendi questo semplicistico esempio C ++ (certamente non ottimizzato per semplicità per evitare di mettermi in imbarazzo di fronte a qualsiasi esperto di elaborazione delle immagini):

// Inputs an image and outputs a new one with the specified size.
Image resized_image(const Image& src, int new_w, int new_h)
{
     Image dst(new_w, new_h);
     for (int y=0; y < new_h; ++y)
     {
         for (int x=0; x < new_w; ++x)
              dst[y][x] = src.sample(x / (float)new_w, y / (float)new_h);
     }
     return dst;
}

Mentre l'implementazione di quella funzione muta lo stato locale (e temporaneo) sotto forma di due variabili contatore e un'immagine locale temporanea in uscita, non ha effetti collaterali esterni. Immette un'immagine e ne emette una nuova. Possiamo multithread per il contenuto dei nostri cuori. È facile ragionare, è facile testare a fondo. È eccezionalmente sicuro poiché se viene generato qualcosa, la nuova immagine viene automaticamente scartata e non dobbiamo preoccuparci di ripristinare gli effetti collaterali esterni (non ci sono immagini esterne modificate al di fuori dell'ambito della funzione, per così dire).

Vedo poco da guadagnare, e potenzialmente molto da perdere, rendendo Imageimmutabile nel contesto sopra, in C ++, tranne per rendere potenzialmente la funzione sopra più ingombrante da implementare, e forse un po 'meno efficiente.

Purezza

Quindi le funzioni pure (prive di effetti collaterali esterni ) sono molto interessanti per me e sottolineo l'importanza di favorirle spesso ai membri del team anche in C ++. Ma i disegni immutabili, applicati in un contesto e una sfumatura generalmente assenti, non sono per me così interessanti dal momento che, data la natura imperativa del linguaggio, è spesso utile e pratico essere in grado di mutare alcuni oggetti temporanei locali nel processo di efficienza (entrambi per sviluppatori e hardware) implementando una funzione pura.

Copia economica di strutture pesanti

La seconda proprietà più utile che trovo è la possibilità di copiare a buon mercato le strutture di dati davvero pesanti quando il costo di farlo, come spesso verrebbe sostenuto per rendere pure le funzioni, data la loro natura di input / output, non sarebbe banale. Queste non sarebbero piccole strutture che possono stare in pila. Sarebbero strutture grandi e pesanti, come l'intera Sceneper un videogioco.

In tal caso, il sovraccarico della copia potrebbe impedire opportunità di un parallelismo efficace, poiché potrebbe essere difficile parallelizzare la fisica e renderizzarla efficacemente senza bloccarsi e colli di bottiglia se la fisica sta mutando la scena che il renderizzatore sta simultaneamente cercando di disegnare, mentre contemporaneamente ha una fisica profonda copiare l'intera scena del gioco solo per produrre un fotogramma con la fisica applicata potrebbe essere altrettanto inefficace. Tuttavia, se il sistema fisico fosse "puro", nel senso che ha semplicemente immesso una scena e ne ha emessa una nuova con la fisica applicata, e tale purezza non è venuta a scapito della copia astronomica in testa, potrebbe tranquillamente operare in parallelo con la renderer senza l'uno in attesa dell'altro.

Quindi la possibilità di copiare in modo economico i dati davvero pesanti dello stato della tua applicazione e di produrre nuove versioni modificate con costi minimi di elaborazione e utilizzo della memoria può davvero aprire nuove porte per la purezza e il parallelismo efficace, e lì trovo molte lezioni da imparare da come vengono implementate le strutture di dati persistenti. Ma qualunque cosa creiamo usando tali lezioni non deve essere del tutto persistente o offrire interfacce immutabili (potrebbe usare, ad esempio, un copy-on-write o un "costruttore / transitorio"), per ottenere questa capacità di essere sporchi a buon mercato per copiare e modificare solo sezioni della copia senza raddoppiare l'uso della memoria e l'accesso alla memoria nella nostra ricerca di parallelismo e purezza nelle nostre funzioni / sistemi / pipeline.

Immutabilità

Infine c'è l'immutabilità che ritengo il meno interessante di questi tre, ma può imporre, con un pugno di ferro, quando alcuni progetti di oggetti non sono pensati per essere usati come temporali locali per una funzione pura, e invece in un contesto più ampio, un valore tipo di "purezza a livello di oggetto", come in tutti i metodi non causa più effetti collaterali esterni (non muta più le variabili membro al di fuori dell'ambito locale immediato del metodo).

E mentre lo considero il meno interessante di questi tre in linguaggi come il C ++, può certamente semplificare i test, la sicurezza dei thread e il ragionamento di oggetti non banali. Può essere un carico lavorare con la garanzia che ad un oggetto non può essere data alcuna combinazione di stati univoca al di fuori del suo costruttore, per esempio, e che possiamo passarlo liberamente, anche per riferimento / puntatore senza appoggiarci a costanza e leggere- solo iteratori e handle e simili, pur garantendo (almeno quanto più possibile nella lingua) che i suoi contenuti originali non saranno mutati.

Ma trovo che questa sia la proprietà meno interessante perché la maggior parte degli oggetti che vedo sono utili quanto usati temporaneamente, in forma mutevole, per implementare una funzione pura (o anche un concetto più ampio, come un "sistema puro" che potrebbe essere un oggetto o una serie di funziona con il massimo effetto di inserire semplicemente qualcosa e produrre qualcosa di nuovo senza toccare nient'altro), e penso che l'immutabilità portata alle estremità in un linguaggio ampiamente imperativo sia un obiettivo piuttosto controproducente. Lo applicherei con parsimonia per le parti della base di codice in cui aiuta davvero di più.

Infine:

[...] sembrerebbe che le strutture di dati persistenti non siano di per sé sufficienti per gestire scenari in cui un thread apporta una modifica visibile ad altri thread. Per questo, sembra che dobbiamo usare dispositivi come atomi, riferimenti, memoria transazionale del software o persino classici blocchi e meccanismi di sincronizzazione.

Naturalmente se il tuo progetto richiede che le modifiche (in un senso del design di fine utente) siano visibili a più thread contemporaneamente mentre si verificano, torniamo alla sincronizzazione o almeno al tavolo da disegno per elaborare alcuni modi sofisticati per gestirlo ( Ho visto alcuni esempi molto elaborati utilizzati da esperti che si occupano di questo tipo di problemi nella programmazione funzionale).

Ma ho scoperto, una volta ottenuto quel tipo di copia e capacità di produrre versioni parzialmente modificate di strutture pesanti a buon mercato, come ad esempio con strutture di dati persistenti, si aprono spesso molte porte e opportunità che potresti non ho mai pensato prima di parallelizzare il codice che può essere eseguito in modo completamente indipendente l'uno dall'altro in una sorta di pipeline parallela I / O. Anche se alcune parti dell'algoritmo devono essere di natura seriale, potresti rinviare quell'elaborazione a un singolo thread ma scoprire che appoggiarsi a questi concetti ha aperto le porte per parallelizzare facilmente e senza preoccupazioni il 90% del lavoro pesante, ad es.

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.