Perché Haskell e Scheme utilizzano elenchi collegati singolarmente?


12

Un elenco doppiamente collegato ha un sovraccarico minimo (solo un altro puntatore per cella) e ti consente di aggiungere ad entrambe le estremità e di andare avanti e indietro e in generale ti diverti molto.


il costruttore di elenchi può inserire all'inizio dell'elenco collegato singolarmente, senza modificare l'elenco originale. Questo è importante per la programmazione funzionale. L'elenco con doppio link comporta praticamente modifiche, che non sono molto pure.
tp1

3
Pensaci, come costruiresti un elenco immutabile doppiamente collegato? È necessario che il nextpuntatore dell'elemento precedente punti all'elemento successivo e il prevpuntatore dell'elemento successivo punti all'elemento precedente. Tuttavia, uno di questi due elementi viene creato prima dell'altro, il che significa che uno di quegli elementi deve avere un puntatore che punta a un oggetto che non esiste ancora! Ricorda, non puoi prima creare un elemento, poi l'altro e quindi impostare i puntatori: sono immutabili. (Nota: so che esiste un modo, sfruttando la pigrizia, chiamato "Legare il nodo".)
Jörg W Mittag

1
Nella maggior parte dei casi gli elenchi doppiamente collegati non sono in genere necessari. Se è necessario accedervi in ​​ordine inverso, spingere gli elementi nell'elenco su uno stack e pop uno per uno per un algoritmo di inversione O (n).
Neil,

Risposte:


23

Bene, se guardi un po 'più in profondità, entrambi in realtà includono anche array nella lingua di base:

  • Il 5 ° Rapporto sullo schema rivisto (R5RS) include il tipo di vettore , che sono raccolte indicizzate per numeri interi di dimensioni fisse con tempi migliori di quelli lineari per l'accesso casuale.
  • Anche il report Haskell 98 ha un tipo di array .

Le istruzioni di programmazione funzionale, tuttavia, hanno da sempre enfatizzato gli elenchi a collegamento singolo su array o elenchi a doppio collegamento. Molto probabilmente enfatizzato, in effetti. Ci sono diverse ragioni per questo, comunque.

Il primo è che gli elenchi a collegamento singolo sono uno dei tipi di dati ricorsivi più semplici ma utili. Un equivalente definito dall'utente del tipo di elenco di Haskell può essere definito in questo modo:

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

Il fatto che gli elenchi siano un tipo di dati ricorsivo significa che le funzioni che lavorano sugli elenchi generalmente utilizzano la ricorsione strutturale . In termini di Haskell: il modello corrisponde ai costruttori di elenchi e si ricorre su una sottoparte dell'elenco. In queste due definizioni di funzioni di base, utilizzo la variabile asper fare riferimento alla coda dell'elenco. Quindi nota che le chiamate ricorsive "discendono" in fondo alla lista:

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

Questa tecnica garantisce che la tua funzione terminerà per tutti gli elenchi finiti, ed è anche una buona tecnica di risoluzione dei problemi: tende naturalmente a dividere i problemi in sottoparti più semplici e sostenibili.

Quindi gli elenchi a collegamento singolo sono probabilmente il miglior tipo di dati per presentare agli studenti queste tecniche, che sono molto importanti nella programmazione funzionale.

Il secondo motivo è meno un motivo "perché elenchi a collegamento singolo", ma più un motivo "perché non elenchi o collegamenti a doppio collegamento": questi ultimi tipi di dati richiedono spesso la mutazione (variabili modificabili), che la programmazione funzionale molto spesso si allontana da. Così come succede:

  • In una lingua entusiasta come Scheme non è possibile creare un elenco con doppio collegamento senza usare la mutazione.
  • In un linguaggio pigro come Haskell puoi creare un elenco con doppio link senza usare la mutazione. Ma ogni volta che crei un nuovo elenco basato su quello, sei costretto a copiare la maggior parte se non tutta la struttura dell'originale. Considerando che con gli elenchi a collegamento singolo è possibile scrivere funzioni che utilizzano la "condivisione della struttura": i nuovi elenchi possono riutilizzare le celle dei vecchi elenchi quando appropriato.
  • Tradizionalmente, se si utilizzavano array in modo immutabile, ciò significava che ogni volta che si desiderava modificare l'array, si doveva copiare tutto. (Le librerie recenti di Haskell come vector, tuttavia, hanno trovato tecniche che migliorano notevolmente su questo problema).

Il terzo e ultimo motivo si applica principalmente ai linguaggi pigri come Haskell: gli elenchi pigri a collegamento singolo, in pratica, sono spesso più simili agli iteratori che agli elenchi in memoria propri. Se il tuo codice sta consumando gli elementi di un elenco in sequenza e li butta via man mano che procedi, il codice oggetto materializzerà solo le celle dell'elenco e il suo contenuto man mano che avanzi nell'elenco.

Ciò significa che l'intero elenco non deve necessariamente esistere in memoria contemporaneamente, solo la cella corrente. Le celle precedenti a quella corrente possono essere raccolte in modo inutile (cosa impossibile con un elenco a doppio collegamento); le celle successive a quella attuale non devono essere calcolate finché non ci si arriva.

Va anche oltre. Esiste una tecnica utilizzata in diverse librerie Haskell popolari, chiamata fusion , in cui il compilatore analizza il codice di elaborazione degli elenchi e individua gli elenchi intermedi che vengono generati e consumati in sequenza e quindi "eliminati". Con questa conoscenza, il compilatore può eliminare completamente l'allocazione di memoria delle celle di tali elenchi. Ciò significa che un elenco a collegamento singolo in un programma sorgente Haskell, dopo la compilazione, potrebbe effettivamente essere trasformato in un ciclo anziché in una struttura di dati.

La fusione è anche la tecnica che la vectorlibreria di cui sopra utilizza per generare codice efficiente per array immutabili. Lo stesso vale per le librerie estremamente popolari bytestring(array di byte) e text(stringhe Unicode), che sono state costruite in sostituzione del Stringtipo nativo non molto grande di Haskell (che è lo stesso [Char]dell'elenco di caratteri a collegamento singolo). Quindi nella moderna Haskell c'è una tendenza in cui i tipi di array immutabili con supporto alla fusione stanno diventando molto comuni.

La fusione delle liste è facilitata dal fatto che in una lista a link singolo è possibile andare avanti ma mai indietro . Questo fa apparire un tema molto importante nella programmazione funzionale: usare la "forma" di un tipo di dati per ricavare la "forma" di un calcolo. Se si desidera elaborare gli elementi in sequenza, un elenco a collegamento singolo è un tipo di dati che, quando lo si consuma con ricorsione strutturale, offre tale modello di accesso in modo molto naturale. Se si desidera utilizzare una strategia di "divisione e conquista" per attaccare un problema, le strutture dati dell'albero tendono a supportarlo molto bene.

Molte persone abbandonano presto il vagone di programmazione funzionale, quindi ottengono esposizione alle liste a link singolo ma non alle idee sottostanti più avanzate.


1
Che grande risposta!
Elliot Gorokhovsky,

14

Perché funzionano bene con l'immutabilità. Supponiamo di avere due elenchi immutabili [1, 2, 3]e [10, 2, 3]. Rappresentati come elenchi collegati singolarmente in cui ogni elemento nell'elenco è un nodo contenente l'elemento e un puntatore al resto dell'elenco, apparirebbero così:

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

Vedi come le [2, 3]porzioni sono identiche? Con strutture di dati mutabili, sono due elenchi diversi perché il codice che scrive nuovi dati su uno di essi non deve influire sul codice che utilizza l'altro. Tuttavia, con dati immutabili , sappiamo che il contenuto degli elenchi non cambierà mai e il codice non può scrivere nuovi dati. Quindi possiamo riutilizzare le code e fare in modo che le due liste condividano parte della loro struttura:

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

Poiché il codice che utilizza le due liste non le muterà mai, non dobbiamo mai preoccuparci delle modifiche a una lista che incidono sull'altra. Ciò significa anche che quando si aggiunge un elemento in cima all'elenco, non è necessario copiare e creare un elenco completamente nuovo.

Tuttavia, se provi a rappresentare [1, 2, 3]e [10, 2, 3]come elenchi doppiamente collegati:

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

Ora le code non sono più identiche. Il primo [2, 3]ha un puntatore a 1alla testa, ma il secondo ha un puntatore a 10. Inoltre, se si desidera aggiungere un nuovo elemento in testa all'elenco, è necessario mutare il capo precedente dell'elenco per farlo puntare al nuovo capo.

Il problema relativo alle teste multiple potrebbe essere risolto potenzialmente facendo in modo che ciascun nodo memorizzi un elenco di teste conosciute e la modifica di tali elenchi, ma è necessario lavorare per mantenere tale elenco in cicli di garbage collection quando le versioni dell'elenco con teste diverse hanno tempi di vita diversi a causa dell'utilizzo in diversi pezzi di codice. Aggiunge complessità e spese generali e la maggior parte delle volte non ne vale la pena.


8
Tuttavia, la condivisione della coda non avviene come si suppone. Generalmente, nessuno passa in rassegna tutte le liste in memoria e cerca opportunità per unire i suffissi comuni. La condivisione avviene , cade dal modo in cui gli algoritmi sono scritti, ad esempio se una funzione con un parametro si xscostruisce 1:xsin un posto e 10:xsin un altro.

0

La risposta di @ sacundim è per lo più vera, ma ci sono anche altre importanti intuizioni sul trade-off in merito a progetti linguistici e requisiti pratici.

Oggetti e riferimenti

Questi linguaggi di solito obbligano (o assumono) oggetti con estensioni dinamiche non associate (o nel linguaggio di C, durata , sebbene non esattamente la stessa a causa delle differenze di significato degli oggetti tra questi linguaggi, vedi sotto) per impostazione predefinita, evitando riferimenti di prima classe ( es. puntatori di oggetti in C) e comportamento imprevedibile nelle regole semantiche (es. comportamento indefinito dell'ISO C relativo alla semantica).

Inoltre, la nozione di oggetti (di prima classe) in tali linguaggi è prudentemente restrittiva: nulla di "locativo" è specificato e garantito di default. Ciò è completamente diverso in alcuni linguaggi simili ad ALGOL i cui oggetti sono privi di estensioni dinamiche non associate (ad esempio in C e C ++), dove gli oggetti significano fondamentalmente una sorta di "archivio tipizzato", solitamente accoppiato con posizioni di memoria.

Codificare la memoria all'interno degli oggetti ha alcuni vantaggi aggiuntivi come la possibilità di collegare effetti computazionali deterministici per tutta la loro vita, ma è un altro argomento.

Problemi di simulazione delle strutture dati

Senza riferimenti di prima classe, gli elenchi collegati singolarmente non possono simulare in modo efficace e portabile molte strutture di dati tradizionali (desiderosi / mutabili), a causa della natura della rappresentazione di queste strutture di dati e delle operazioni primitive limitate in questi linguaggi. (Al contrario, in C, è possibile ricavare elenchi collegati abbastanza facilmente anche in un programma strettamente conforme .) E tali strutture di dati alternativi come array / vettori hanno alcune proprietà superiori rispetto agli elenchi collegati singolarmente nella pratica. Ecco perché R 5 RS introduce nuove operazioni primitive.

Esistono tuttavia differenze tra i tipi di vettore / matrice e gli elenchi doppiamente collegati. Un array viene spesso assunto con O (1) complessità del tempo di accesso e meno sovraccarico di spazio, che sono proprietà eccellenti non condivise dagli elenchi. (Anche se in senso stretto, nessuno dei due è garantito dall'ISO C, ma quasi sempre gli utenti se lo aspettano e nessuna implementazione pratica violerebbe queste garanzie implicite troppo ovviamente.) OTOH, un elenco doppiamente collegato spesso rende entrambe le proprietà persino peggiori di un elenco collegato singolarmente , mentre le iterazioni indietro / avanti sono supportate anche da un array o un vettore (insieme a indici interi) con un sovraccarico ancora minore. Pertanto, un elenco doppiamente collegato non offre prestazioni migliori in generale. Ancora peggio ancora, le prestazioni relative all'efficienza della cache e alla latenza nell'allocazione dinamica della memoria degli elenchi sono catastroficamente peggiori delle prestazioni degli array / vettori quando si utilizza l'allocatore predefinito fornito dall'ambiente di implementazione sottostante (ad esempio libc). Quindi, senza un runtime molto specifico e "intelligente" che ottimizza pesantemente tali creazioni di oggetti, i tipi di array / vettori sono spesso preferiti agli elenchi collegati. (Ad esempio, usando ISO C ++, c'è un avvertimento chestd::vectordovrebbe essere preferito per std::listimpostazione predefinita.) Pertanto, introdurre nuove primitive per supportare specificamente (doppiamente) elenchi collegati non è sicuramente così vantaggioso da supportare in pratica le strutture di dati array / vettoriale.

Per essere onesti, gli elenchi hanno ancora alcune proprietà specifiche migliori degli array / vettori:

  • Gli elenchi sono basati su nodi. La rimozione di elementi dagli elenchi non invalida il riferimento ad altri elementi in altri nodi. (Ciò vale anche per alcune strutture di dati di alberi o grafici.) OTOH, matrici / vettori possono fare riferimento alla posizione di trascinamento che viene invalidata (con riallocazione massiccia in alcuni casi).
  • Gli elenchi possono unire in O (1) tempo. La ricostruzione di nuovi array / vettori con quelli attuali è molto più costosa.

Tuttavia, queste proprietà non sono troppo importanti per una lingua con supporto per elenchi collegati singolarmente collegati, che è già in grado di utilizzare tale. Sebbene esistano ancora differenze, nei linguaggi con estensioni dinamiche obbligatorie degli oggetti (che di solito significa che c'è un garbage collector che tiene lontani i riferimenti penzolanti), l'invalidazione può anche essere meno importante, a seconda degli intenti. Quindi, gli unici casi in cui vincono liste doppiamente collegate possono essere:

  • Sono necessari sia la garanzia di non riallocazione sia i requisiti di iterazione bidirezionale. (Se le prestazioni dell'accesso agli elementi sono importanti e l'insieme di dati è abbastanza grande, sceglierei invece alberi di ricerca binari o tabelle hash.)
  • Sono necessarie efficienti operazioni di giunzione bidirezionale. Questo è considerevolmente raro. (Soddisfo solo i requisiti per l'implementazione di qualcosa come record cronologici lineari in un browser.)

Immutabilità e aliasing

In un linguaggio puro come Haskell, gli oggetti sono immutabili. Gli oggetti dello schema sono spesso usati senza mutazione. Tale fatto consente di migliorare efficacemente l'efficienza della memoria con l'internet degli oggetti - condivisione implicita di più oggetti con lo stesso valore al volo.

Questa è una strategia aggressiva di ottimizzazione di alto livello nella progettazione del linguaggio. Tuttavia, ciò comporta problemi di attuazione. In realtà introduce alias impliciti nelle celle di memoria sottostanti. Rende più difficile l'analisi di aliasing. Di conseguenza, potrebbero esserci probabilmente meno possibilità di eliminare il sovraccarico di riferimenti non di prima classe, persino gli utenti non li toccheranno affatto. In linguaggi come Scheme, una volta esclusa la mutazione, ciò interferisce anche con il parallelismo. Potrebbe essere OK in un linguaggio pigro (che comunque ha già problemi di prestazioni causati da thunk).

Per la programmazione generale, tale scelta del design della lingua può essere problematica. Ma con alcuni schemi di codifica funzionale comuni, le lingue sembrano funzionare ancora bene.

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.