¿Por qué Haskell y Scheme usan listas enlazadas individualmente?

12

Una lista doblemente vinculada tiene una sobrecarga mínima (solo otro puntero por celda), y le permite agregar a ambos extremos, ir y venir y, en general, divertirse mucho.

Elliot Gorokhovsky
fuente
El constructor de listas puede insertarse al comienzo de una lista vinculada individualmente, sin modificar la lista original. Esto es importante para la programación funcional. La lista doblemente vinculada implica modificaciones, que no son muy puras.
tp1
3
Piénselo, ¿cómo podría incluso construir una lista inmutable doblemente vinculada? Debe hacer que el nextpuntero del elemento anterior apunte al elemento siguiente y que el prevpuntero del elemento siguiente apunte al elemento anterior. Sin embargo, uno de esos dos elementos se crea antes que el otro, lo que significa que uno de esos elementos debe tener un puntero apuntando a un objeto que aún no existe. Recuerde, no puede crear primero un elemento, luego el otro y luego configurar los punteros: son inmutables. (Nota: Sé que hay una manera, explotando la pereza, llamada "Atar el nudo".)
Jörg W Mittag
1
Las listas doblemente vinculadas suelen ser innecesarias en la mayoría de los casos. Si necesita acceder a ellos en reversa, inserte los elementos de la lista en una pila y revíselos uno por uno para obtener un algoritmo de reversión O (n).
Neil

Respuestas:

23

Bueno, si miras un poco más profundo, ambos incluyen matrices en el lenguaje base también:

  • El quinto informe de esquema revisado (R5RS) incluye el tipo de vector , que son colecciones indexadas de tamaño entero con un tiempo de acceso aleatorio mejor que el lineal.
  • El Informe Haskell 98 también tiene un tipo de matriz .

Sin embargo, la instrucción de programación funcional ha enfatizado durante mucho tiempo las listas de un solo enlace sobre las matrices o las listas de doble enlace. Muy probablemente exagerado, de hecho. Sin embargo, hay varias razones para ello.

El primero es que las listas de enlaces únicos son uno de los tipos de datos recursivos más simples y útiles. Un equivalente definido por el usuario del tipo de lista de Haskell se puede definir así:

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. 

El hecho de que las listas sean un tipo de datos recursivo significa que las funciones que funcionan en las listas generalmente usan recursividad estructural . En términos de Haskell: el patrón coincide en los constructores de la lista y se repite en una subparte de la lista. En estas dos definiciones de funciones básicas, uso la variable aspara referirme al final de la lista. Tenga en cuenta que las llamadas recursivas "descienden" en la 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

Esta técnica garantiza que su función finalizará para todas las listas finitas, y también es una buena técnica de resolución de problemas: tiende a dividir los problemas de manera natural en subpartes más simples y más manejables.

Por lo tanto, las listas de enlace único son probablemente el mejor tipo de datos para presentar a los estudiantes estas técnicas, que son muy importantes en la programación funcional.

La segunda razón es menos una razón de "por qué listas unidas", pero más una razón de "por qué no listas o matrices de doble enlace": esos últimos tipos de datos a menudo requieren mutación (variables modificables), que la programación funcional muy a menudo huye de Entonces, como sucede:

  • En un lenguaje entusiasta como Scheme, no puede hacer una lista de doble enlace sin usar el uso de mutación.
  • En un lenguaje vago como Haskell, puede hacer una lista de doble enlace sin usar mutación. Pero cada vez que crea una nueva lista basada en esa, se ve obligado a copiar la mayoría, si no toda, la estructura del original. Mientras que con las listas de un solo enlace puede escribir funciones que usan "compartir estructura", las nuevas listas pueden reutilizar las celdas de las viejas cuando sea apropiado.
  • Tradicionalmente, si usabas matrices de manera inmutable, significaba que cada vez que querías modificar la matriz tenías que copiar todo. (Sin vectorembargo, las bibliotecas Haskell recientes han encontrado técnicas que mejoran en gran medida este problema).

La tercera y última razón se aplica principalmente a los lenguajes perezosos como Haskell: en la práctica, las listas perezosas de un solo enlace son a menudo más similares a los iteradores que a las listas en memoria propiamente dichas. Si su código consume los elementos de una lista secuencialmente y los arroja a medida que avanza, el código objeto solo materializará las celdas de la lista y su contenido a medida que avance por la lista.

Esto significa que no es necesario que toda la lista exista en la memoria a la vez, solo la celda actual. Las celdas anteriores a la actual se pueden recolectar basura (lo que no sería posible con una lista de doble enlace); las celdas posteriores a la actual no necesitan ser calculadas hasta que llegue allí.

Va incluso más allá que eso. Hay una técnica utilizada en varias bibliotecas populares de Haskell, llamada fusión , donde el compilador analiza su código de procesamiento de listas y detecta listas intermedias que se generan y consumen de forma secuencial y luego "desechan". Con este conocimiento, el compilador puede eliminar completamente la asignación de memoria de las celdas de esas listas. Esto significa que una lista de un solo enlace en un programa fuente de Haskell, después de la compilación, podría convertirse en un bucle en lugar de una estructura de datos.

Fusion es también la técnica que vectorusa la biblioteca mencionada para generar código eficiente para matrices inmutables. Lo mismo ocurre con las bibliotecas extremadamente populares bytestring(matrices de bytes) y text(cadenas Unicode), que se construyeron como un reemplazo para el Stringtipo nativo no muy grande de Haskell (que es lo mismo que [Char]una lista de caracteres con un solo enlace). Entonces, en el Haskell moderno hay una tendencia en la que los tipos de matriz inmutables con soporte de fusión se están volviendo muy comunes.

La fusión de listas se ve facilitada por el hecho de que en una lista enlazada puede avanzar pero nunca hacia atrás . Esto plantea un tema muy importante en la programación funcional: usar la "forma" de un tipo de datos para derivar la "forma" de un cálculo. Si desea procesar elementos secuencialmente, una lista de un solo enlace es un tipo de datos que, cuando la consume con recursividad estructural, le proporciona ese patrón de acceso de forma muy natural. Si desea utilizar una estrategia de "divide y vencerás" para atacar un problema, entonces las estructuras de datos en árbol tienden a soportarlo muy bien.

Muchas personas abandonan el vagón de programación funcional desde el principio, por lo que se exponen a las listas de enlaces únicos pero no a las ideas subyacentes más avanzadas.

sacundim
fuente
1
¡Qué gran respuesta!
Elliot Gorokhovsky
14

Porque funcionan bien con la inmutabilidad. Supongamos que tiene dos listas inmutables, [1, 2, 3]y [10, 2, 3]. Representados como listas enlazadas individualmente donde cada elemento de la lista es un nodo que contiene el elemento y un puntero al resto de la lista, se verían así:

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

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

¿Ves cómo las [2, 3]porciones son idénticas? Con estructuras de datos mutables, son dos listas diferentes porque el código que escribe datos nuevos en uno de ellos no debe afectar el código que usa el otro. Sin embargo, con datos inmutables , sabemos que el contenido de las listas nunca cambiará y que el código no puede escribir nuevos datos. Entonces podemos reutilizar las colas y hacer que las dos listas compartan parte de su estructura:

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

Dado que el código que usa las dos listas nunca las mutará, nunca tenemos que preocuparnos de que los cambios en una lista afecten a la otra. Esto también significa que al agregar un elemento al principio de la lista, no tiene que copiar y hacer una lista completamente nueva.

Sin embargo, si intenta representar [1, 2, 3]y [10, 2, 3]como listas doblemente vinculadas:

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

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

Ahora las colas ya no son idénticas. El primero [2, 3]tiene un puntero a 1la cabeza, pero el segundo tiene un puntero a 10. Además, si desea agregar un nuevo elemento al encabezado de la lista, debe mutar el encabezado anterior de la lista para que apunte al nuevo encabezado.

El problema de múltiples cabezas podría solucionarse haciendo que cada nodo almacene una lista de cabezas conocidas y que la creación de nuevas listas modifique eso, pero luego debe trabajar para mantener esa lista en los ciclos de recolección de basura cuando las versiones de la lista con diferentes cabezas tienen diferentes vidas debido a que se utilizan en diferentes piezas de código. Agrega complejidad y gastos generales, y la mayoría de las veces no vale la pena.

Jack
fuente
8
Sin embargo, el intercambio de colas no sucede como usted implica. En general, nadie revisa todas las listas en la memoria y busca oportunidades para fusionar sufijos comunes. El intercambio simplemente ocurre , se cae de cómo se escriben los algoritmos, por ejemplo, si una función con un parámetro se xsconstruye 1:xsen un lugar y 10:xsen otro.
0

La respuesta de @ sacundim es mayormente cierta, pero también hay algunas otras ideas importantes sobre el intercambio de diseños de idiomas y requisitos prácticos.

Objetos y referencias

Estos lenguajes generalmente exigen (o asumen) objetos que tienen extensiones dinámicas no vinculadas (o en el lenguaje de C, duración , aunque no exactamente lo mismo debido a las diferencias de significado de los objetos entre estos lenguajes, ver más abajo) de forma predeterminada, evitando referencias de primera clase ( por ejemplo, punteros de objetos en C) y comportamiento impredecible en las reglas semánticas (por ejemplo, comportamiento indefinido de ISO C relacionado con la semántica).

Además, la noción de objetos (de primera clase) en dichos lenguajes es conservadoramente restrictiva: no se especifican y garantizan de forma predeterminada propiedades "locativas". Esto es completamente diferente en algunos lenguajes similares a ALGOL cuyos objetos no tienen extensiones dinámicas no vinculadas (por ejemplo, en C y C ++), donde los objetos básicamente significan algunos tipos de "almacenamiento con tipo", generalmente junto con ubicaciones de memoria.

Codificar el almacenamiento dentro de los objetos tiene algunos beneficios adicionales, como poder adjuntar efectos computacionales deterministas a lo largo de su vida útil, pero es otro tema.

Problemas de simulación de estructuras de datos

Sin referencias de primera clase, las listas enlazadas individualmente no pueden simular muchas estructuras de datos tradicionales (ansiosas / mutables) de manera efectiva y portátil, debido a la naturaleza de la representación de estas estructuras de datos y las limitadas operaciones primitivas en estos idiomas. (Por el contrario, en C, puede derivar listas vinculadas con bastante facilidad incluso en un programa estrictamente conforme ). Y tales estructuras de datos alternativas como matrices / vectores tienen algunas propiedades superiores en comparación con las listas enlazadas en la práctica. Es por eso que R 5 RS introduce nuevas operaciones primitivas.

Pero existen diferencias en los tipos de vectores / matrices frente a listas doblemente vinculadas. A menudo se supone una matriz con O (1) complejidad de tiempo de acceso y menos sobrecarga de espacio, que son excelentes propiedades que no comparten las listas. (Aunque estrictamente hablando, ninguno de los dos está garantizado por ISO C, pero los usuarios casi siempre lo esperan y ninguna implementación práctica violaría estas garantías implícitas demasiado obviamente). , mientras que la iteración hacia atrás / adelante también es compatible con una matriz o un vector (junto con índices enteros) con incluso menos sobrecarga. Por lo tanto, una lista doblemente vinculada no funciona mejor en general. Peor aún, El rendimiento sobre la eficiencia de caché y la latencia en la asignación dinámica de memoria de las listas es catastróficamente peor que el rendimiento de las matrices / vectores cuando se utiliza el asignador predeterminado proporcionado por el entorno de implementación subyacente (por ejemplo, libc). Entonces, sin un tiempo de ejecución muy específico e "inteligente" que optimice en gran medida tales creaciones de objetos, los tipos de matriz / vector a menudo se prefieren a las listas vinculadas. (Por ejemplo, usando ISO C ++, hay una advertencia questd::vectordebería preferirse std::listpor defecto.) Por lo tanto, introducir nuevas primitivas para soportar específicamente (doblemente) listas enlazadas definitivamente no es tan beneficioso como para soportar estructuras de datos de matriz / vector en la práctica.

Para ser justos, las listas aún tienen algunas propiedades específicas mejores que las matrices / vectores:

  • Las listas están basadas en nodos. Eliminar elementos de las listas no invalida la referencia a otros elementos en otros nodos. (Esto también es cierto para algunas estructuras de datos de árbol o gráfico). OTOH, las matrices / vectores pueden hacer referencias a la posición final que se invalida (con una reasignación masiva en algunos casos).
  • Las listas pueden empalmarse en O (1) tiempo. La reconstrucción de nuevas matrices / vectores con las actuales es mucho más costosa.

Sin embargo, estas propiedades no son demasiado importantes para un lenguaje con soporte integrado de listas enlazadas individualmente, que ya es capaz de tal uso. Aunque todavía existen diferencias, en los lenguajes con extensiones dinámicas obligatorias de objetos (lo que generalmente significa que hay un recolector de basura que mantiene alejadas las referencias colgantes), la invalidación también puede ser menos importante, dependiendo de los intentos. Entonces, los únicos casos en que las listas doblemente vinculadas pueden ganar son:

  • Se necesitan tanto la garantía de no reasignación como los requisitos de iteración bidireccional. (Si el rendimiento del acceso a elementos es importante y el conjunto de datos es lo suficientemente grande, en su lugar elegiría árboles de búsqueda binarios o tablas hash).
  • Se necesitan operaciones de empalme bidireccionales eficientes. Esto es considerablemente raro. (Solo cumplo los requisitos solo para implementar algo como registros de historial lineal en un navegador).

Inmutabilidad y alias

En un lenguaje puro como Haskell, los objetos son inmutables. El objeto de Scheme a menudo se usa sin mutación. Tal hecho hace posible mejorar efectivamente la eficiencia de la memoria con la internación de objetos : el intercambio implícito de múltiples objetos con el mismo valor sobre la marcha.

Esta es una estrategia agresiva de optimización de alto nivel en el diseño del lenguaje. Sin embargo, esto implica problemas de implementación. En realidad, introduce alias implícitos a las celdas de almacenamiento subyacentes. Hace que el análisis de alias sea más difícil. Como resultado, es probable que haya menos posibilidades de eliminar la sobrecarga de referencias que no sean de primera clase, incluso los usuarios nunca las tocan. En lenguajes como Scheme, una vez que la mutación no se descarta totalmente, esto también interfiere en el paralelismo. Sin embargo, puede estar bien en un lenguaje perezoso (que de todos modos ya tiene problemas de rendimiento causados ​​por thunks).

Para la programación de propósito general, tal elección del diseño del lenguaje puede ser problemática. Pero con algunos patrones comunes de codificación funcional, los lenguajes parecen funcionar bien.

FrankHB
fuente