Sono arrivato in ritardo a queste domande e risposte con già ottime risposte, ma volevo intromettermi come uno straniero abituato a guardare le cose dal punto di vista di livello inferiore di bit e byte in memoria.
Sono molto entusiasta di progetti immutabili, anche provenienti da una prospettiva C e dalla prospettiva di trovare nuovi modi per programmare efficacemente questo hardware bestiale che abbiamo in questi giorni.
Più lento / veloce
Quanto alla domanda se rende le cose più lente, sarebbe una risposta robotica yes
. A questo tipo di livello concettuale molto tecnico, l'immutabilità non può che rallentare le cose. L'hardware fa meglio quando non sta allocando sporadicamente la memoria e può semplicemente modificare la memoria esistente (perché abbiamo concetti come la località temporale).
Eppure una risposta pratica è maybe
. Le prestazioni sono ancora in gran parte una metrica di produttività in qualsiasi base di codice non banale. In genere non riteniamo che le basi di codice orribili da mantenere che inciampano nelle condizioni di gara siano le più efficienti, anche se ignoriamo i bug. L'efficienza è spesso una funzione di eleganza e semplicità. Il picco delle micro-ottimizzazioni può in qualche modo essere in conflitto, ma quelle sono generalmente riservate alle sezioni di codice più piccole e più critiche.
Trasformazione di bit e byte immutabili
Venendo dal punto di vista di basso livello, se riusciamo a fare radiografie su concetti come objects
e strings
così via, al centro di esso ci sono solo bit e byte in varie forme di memoria con caratteristiche di velocità / dimensione diverse (la velocità e le dimensioni dell'hardware di memoria sono in genere si escludono a vicenda).
Alla gerarchia di memoria del computer piace quando accediamo ripetutamente allo stesso blocco di memoria, come nel diagramma sopra, poiché manterrà quel blocco di memoria a cui si accede frequentemente nella forma di memoria più veloce (cache L1, ad esempio, che è quasi veloce come un registro). Potremmo accedere ripetutamente alla stessa identica memoria (riutilizzandola più volte) o accedere ripetutamente a diverse sezioni del blocco (es: scorrere gli elementi in un blocco contiguo che accede ripetutamente a varie sezioni di quel blocco di memoria).
Finiamo per lanciare una chiave in quel processo se la modifica di questa memoria finisce per voler creare un blocco di memoria completamente nuovo sul lato, in questo modo:
... in questo caso, l'accesso al nuovo blocco di memoria potrebbe richiedere errori di pagina obbligatori e errori di cache per spostarlo indietro nelle forme di memoria più veloci (fino in fondo in un registro). Questo può essere un vero killer delle prestazioni.
Esistono modi per mitigarlo, tuttavia, utilizzando un pool di riserva di memoria preallocata, già toccato.
Grandi aggregati
Un altro problema concettuale che emerge da una visione di livello leggermente superiore è semplicemente fare copie non necessarie di aggregati davvero grandi alla rinfusa.
Per evitare un diagramma troppo complesso, immaginiamo che questo semplice blocco di memoria fosse in qualche modo costoso (forse caratteri UTF-32 su un hardware incredibilmente limitato).
In questo caso, se volessimo sostituire "HELP" con "KILL" e questo blocco di memoria fosse immutabile, dovremmo creare un blocco completamente nuovo nella sua interezza per creare un nuovo oggetto unico, anche se solo alcune parti sono cambiate :
Allungando un po 'la nostra immaginazione, questo tipo di copia profonda di tutto il resto solo per rendere unica una piccola parte potrebbe essere piuttosto costoso (nei casi del mondo reale, questo blocco di memoria sarebbe molto, molto più grande per rappresentare un problema).
Tuttavia, nonostante tale spesa, questo tipo di design tenderà ad essere molto meno soggetto all'errore umano. Chiunque abbia lavorato in un linguaggio funzionale con funzioni pure può probabilmente apprezzarlo, specialmente in casi multithread in cui è possibile multithreading di tale codice senza preoccuparsi del mondo. In generale, i programmatori umani tendono a inciampare sui cambiamenti di stato, in particolare quelli che causano effetti collaterali esterni a stati al di fuori dell'ambito di una funzione corrente. Anche il recupero da un errore esterno (eccezione) in tal caso può essere incredibilmente difficile con cambiamenti di stato esterni mutabili nel mix.
Un modo per mitigare questo lavoro di copia ridondante è trasformare questi blocchi di memoria in una raccolta di puntatori (o riferimenti) a caratteri, in questo modo:
Mi scuso, non sono riuscito a rendermi conto che non abbiamo bisogno di rendere L
unico durante la realizzazione del diagramma.
Il blu indica i dati copiati poco profondi.
... sfortunatamente, sarebbe incredibilmente costoso pagare un puntatore / costo di riferimento per personaggio. Inoltre, potremmo spargere il contenuto dei personaggi in tutto lo spazio degli indirizzi e finire per pagarlo sotto forma di un carico di errori di pagina e mancati riscontri nella cache, rendendo questa soluzione ancora peggiore della copia dell'intera cosa nella sua interezza.
Anche se siamo stati attenti a allocare questi caratteri in modo contiguo, supponiamo che la macchina possa caricare 8 caratteri e 8 puntatori su un carattere in una riga della cache. Finiamo per caricare la memoria in questo modo per attraversare la nuova stringa:
In questo caso, finiamo per caricare 7 diverse righe di cache di memoria contigua da caricare per attraversare questa stringa, quando idealmente ne abbiamo bisogno solo 3.
Raccogliere i dati
Per mitigare il problema sopra riportato, possiamo applicare la stessa strategia di base ma a un livello più grossolano di 8 caratteri, ad es
Il risultato richiede il caricamento di 4 righe di cache di dati (1 per i 3 puntatori e 3 per i caratteri) per attraversare questa stringa che è solo 1 in meno dell'ottimale teorico.
Quindi non è affatto male. C'è un po 'di spreco di memoria, ma la memoria è abbondante e l'utilizzo di più non rallenta le cose se la memoria aggiuntiva sarà solo dati freddi a cui non si accede frequentemente. È solo per i dati caldi e contigui in cui l'uso ridotto di memoria e la velocità spesso vanno di pari passo dove vogliamo adattare una quantità maggiore di memoria in una singola pagina o linea di cache e accedervi tutte prima dello sfratto. Questa rappresentazione è piuttosto adatta alla cache.
Velocità
Quindi l'utilizzo di una rappresentazione come sopra può dare un discreto equilibrio di prestazioni. Probabilmente gli usi più critici in termini di prestazioni di strutture di dati immutabili assumeranno questa natura di modifica di grossi pezzi di dati e di renderli unici nel processo, mentre copiano superficialmente pezzi non modificati. Implica anche un certo sovraccarico di operazioni atomiche per fare riferimento in sicurezza ai pezzi copiati poco profondi in un contesto multithread (probabilmente con un conteggio dei riferimenti atomici in corso).
Tuttavia, fintanto che questi grossi pezzi di dati sono rappresentati a un livello abbastanza approssimativo, molte di queste spese generali diminuiscono e possono anche essere banalizzate, pur continuando a darci la sicurezza e la facilità di codifica e multithreading di più funzioni in una forma pura senza lato esterno effetti.
Conservazione di dati nuovi e vecchi
Quando vedo l'immutabilità come potenzialmente più utile dal punto di vista delle prestazioni (in senso pratico) è quando possiamo essere tentati di fare copie complete di dati di grandi dimensioni per renderlo unico in un contesto mutevole in cui l'obiettivo è produrre qualcosa di nuovo da qualcosa che esiste già in un modo in cui vogliamo mantenere sia vecchi che nuovi, quando potremmo semplicemente renderne piccoli pezzi unici con un design accurato e immutabile.
Esempio: Annulla sistema
Un esempio di ciò è un sistema di annullamento. Potremmo cambiare una piccola parte di una struttura di dati e desiderare mantenere sia il modulo originale che possiamo annullare, sia il nuovo modulo. Con questo tipo di design immutabile che rende uniche solo piccole sezioni modificate della struttura dei dati, possiamo semplicemente archiviare una copia dei vecchi dati in una voce di annullamento, pagando solo il costo della memoria dei dati delle porzioni uniche aggiunte. Ciò fornisce un equilibrio molto efficace di produttività (rendendo l'implementazione di un sistema di annullamento un gioco da ragazzi) e prestazioni.
Interfacce di alto livello
Eppure qualcosa di imbarazzante sorge con il caso precedente. In un tipo locale di contesto di funzioni, i dati mutabili sono spesso i più semplici e semplici da modificare. Dopotutto, il modo più semplice per modificare un array è spesso semplicemente eseguirne il ciclo e modificare un elemento alla volta. Possiamo finire per aumentare il sovraccarico intellettuale se avessimo un gran numero di algoritmi di alto livello tra cui scegliere per trasformare un array e dovessimo scegliere quello appropriato per garantire che tutte queste copie poco profonde vengano fatte mentre le parti che vengono modificate sono reso unico.
Probabilmente il modo più semplice in quei casi è usare localmente buffer mutabili all'interno del contesto di una funzione (dove di solito non ci fanno inciampare) che commettono modifiche atomiche alla struttura dei dati per ottenere una nuova copia immutabile (credo che alcune lingue chiamino questi "transitori") ...
... o potremmo semplicemente modellare funzioni di trasformazione di livello superiore e superiore sui dati in modo da poter nascondere il processo di modifica di un buffer mutabile e di impegnarlo nella struttura senza la logica mutabile coinvolta. In ogni caso, questo non è ancora un territorio ampiamente esplorato e abbiamo il nostro lavoro tagliato se abbracciamo progetti immutabili più per trovare interfacce significative per come trasformare queste strutture di dati.
Strutture dati
Un'altra cosa che sorge qui è che l'immutabilità usata in un contesto critico per le prestazioni probabilmente vorrà che le strutture di dati si rompano in dati pesanti in cui i blocchi non sono di dimensioni troppo ridotte ma anche non troppo grandi.
Gli elenchi collegati potrebbero voler cambiare un po 'per adattarli e trasformarsi in elenchi non srotolati. Le matrici grandi e contigue potrebbero trasformarsi in una matrice di puntatori in blocchi contigui con l'indicizzazione del modulo per l'accesso casuale.
Cambia potenzialmente il modo in cui guardiamo le strutture di dati in un modo interessante, spingendo al contempo le funzioni di modifica di queste strutture di dati per assomigliare ad una natura più voluminosa per nascondere la complessità aggiuntiva nel copiare superficialmente alcuni bit qui e renderne altri unici lì.
Prestazione
Comunque, questa è la mia piccola visione di livello inferiore sull'argomento. Teoricamente, l'immutabilità può avere un costo che va da molto grande a più piccolo. Ma un approccio molto teorico non sempre fa accelerare le applicazioni. Potrebbe renderli scalabili, ma la velocità del mondo reale spesso richiede di abbracciare la mentalità più pratica.
Da un punto di vista pratico, qualità come prestazioni, manutenibilità e sicurezza tendono a trasformarsi in una grande sfocatura, soprattutto per una base di codice molto ampia. Mentre le prestazioni in un certo senso sono degradate dall'immutabilità, è difficile sostenere i vantaggi che ha sulla produttività e sulla sicurezza (compresa la sicurezza dei thread). Con un aumento a questi può spesso derivare un aumento delle prestazioni pratiche, anche solo perché gli sviluppatori hanno più tempo per ottimizzare e ottimizzare il loro codice senza essere sciamati da bug.
Quindi, dal punto di vista pratico, penso che strutture di dati immutabili potrebbero effettivamente aiutare le prestazioni in molti casi, per quanto strano possa sembrare. Un mondo ideale potrebbe cercare una combinazione di questi due: strutture di dati immutabili e mutabili, con quelle mutabili che in genere sono molto sicure da usare in un ambito molto locale (es: locale a una funzione), mentre quelle immutabili possono evitare il lato esterno effetti e trasformare tutte le modifiche in una struttura di dati in un'operazione atomica producendo una nuova versione senza rischio di condizioni di gara.