¿Hay alguna forma práctica de que una estructura de nodo vinculado sea inmutable?

26

Decidí escribir una lista vinculada individualmente y tenía el plan en marcha para hacer que la estructura del nodo interno vinculado fuera inmutable.

Sin embargo, me encontré con un inconveniente. Digamos que tengo los siguientes nodos vinculados (de addoperaciones anteriores ):

1 -> 2 -> 3 -> 4

y decir que quiero agregar a 5.

Para hacer esto, dado que el nodo 4es inmutable, necesito crear una nueva copia de 4, pero reemplazar su nextcampo con un nuevo nodo que contenga un 5. El problema ahora 3es hacer referencia a lo viejo 4; el que no tiene el anexo 5. Ahora necesito copiar 3y reemplazar su nextcampo para hacer referencia a la 4copia, pero ahora 2hace referencia al viejo 3...

O, en otras palabras, para hacer un apéndice, parece que se necesita copiar toda la lista.

Mis preguntas:

  • ¿Es correcto mi pensamiento? ¿Hay alguna forma de hacer un anexo sin copiar toda la estructura?

  • Aparentemente, "Java efectivo" contiene la recomendación:

    Las clases deben ser inmutables a menos que haya una muy buena razón para hacerlas mutables ...

    ¿Es este un buen caso para la mutabilidad?

No creo que este sea un duplicado de la respuesta sugerida ya que no estoy hablando de la lista en sí; eso obviamente tiene que ser mutable para ajustarse a la interfaz (sin hacer algo como mantener la nueva lista internamente y recuperarla a través de un getter. Sin embargo, pensándolo bien, incluso eso requeriría algo de mutación; simplemente se mantendría al mínimo). Estoy hablando de si las partes internas de la lista deben ser inmutables o no.

Carcigenicate
fuente
Yo diría que sí, las raras colecciones inmutables en Java son las que tienen métodos de mutación anulados para lanzar, o las CopyOnWritexxxclases increíblemente caras que se usan para subprocesos múltiples. Nadie espera que las colecciones sean realmente inmutables (aunque crea algunas peculiaridades)
Ordous
99
(Comente la cita.) Eso, una gran cita, a menudo es muy mal entendido. La inmutabilidad debe aplicarse a ValueObject debido a sus beneficios , pero si está desarrollando en Java, no es práctico utilizar la inmutabilidad en lugares donde se anticipa la mutabilidad. La mayoría de las veces, una clase tiene ciertos aspectos que deberían ser inmutables y ciertos aspectos que deben ser mutables según los requisitos.
rwong
2
relacionado (posiblemente un duplicado): ¿Es mejor que el objeto de colección sea inmutable? "Sin gastos generales de rendimiento, ¿se puede presentar esta colección (clase LinkedList) como colección inmutable?"
mosquito
¿Por qué harías una lista enlazada inmutable? El mayor beneficio de una lista vinculada es la mutabilidad, ¿no estaría mejor con una matriz?
Pieter B
3
¿Podría ayudarme a comprender sus requisitos? ¿Quieres tener una lista inmutable, que quieres mutar? ¿No es eso una contradicción? ¿Qué quieres decir con "lo interno" de la lista? ¿Quiere decir que los nodos agregados previamente no deberían poder modificarse más adelante, sino que solo permitirían agregarse al último nodo de la lista?
nulo

Respuestas:

46

Con listas en lenguajes funcionales, casi siempre trabaja con cabeza y cola, el primer elemento y el resto de la lista. Preponer es mucho más común porque, como supusiste, agregar requiere copiar toda la lista (u otras estructuras de datos perezosas que no se parecen exactamente a una lista vinculada).

En los idiomas imperativos, agregar es mucho más común, ya que tiende a sentirse semánticamente más natural, y no le importa invalidar referencias a versiones anteriores de la lista.

Como ejemplo de por qué los preparativos previos no requieren copiar toda la lista, considere que tiene:

2 -> 3 -> 4

Anteponer a 1te da:

1 -> 2 -> 3 -> 4

Pero tenga en cuenta que no importa si alguien más sigue teniendo una referencia 2como encabezado de su lista, porque la lista es inmutable y los enlaces solo van en una dirección. No hay forma de saber 1si existe incluso si solo tiene una referencia 2. Ahora, si agregaste una 5a cualquiera de las listas, tendrías que hacer una copia de toda la lista, porque de lo contrario también aparecería en la otra lista.

Karl Bielefeldt
fuente
Ahh, buen punto sobre por qué los preparativos no requieren una copia; No pensé en eso.
Carcigenicate
17

Está en lo correcto, anexar requiere copiar toda la lista si no está dispuesto a mutar ningún nodo en su lugar. Porque necesitamos establecer el nextpuntero del (ahora) penúltimo nodo, que en una configuración inmutable crea un nuevo nodo, y luego necesitamos establecer el nextpuntero del penúltimo nodo, y así sucesivamente.

Creo que el problema central aquí no es la inmutabilidad, ni la appendoperación es desaconsejada. Ambos están perfectamente bien en sus respectivos dominios. Mezclarlos es malo: la interfaz natural (eficiente) para una lista inmutable enfatizó la manipulación al principio de la lista, pero para las listas mutables a menudo es más natural construir la lista agregando sucesivamente los elementos del primero al último.

Por lo tanto, le sugiero que decida: ¿Desea una interfaz efímera o persistente? ¿La mayoría de las operaciones producen una nueva lista y dejan una versión no modificada accesible (persistente), o escribes código como este (efímero):

list.append(x);
list.prepend(y);

Ambas opciones están bien, pero la implementación debe reflejar la interfaz: una estructura de datos persistente se beneficia de nodos inmutables, mientras que una efímera necesita mutabilidad interna para cumplir realmente las promesas de rendimiento que hace implícitamente. java.util.Listy otras interfaces son efímeras: implementarlas en una lista inmutable es inapropiado y, de hecho, representa un peligro para el rendimiento. Los buenos algoritmos en estructuras de datos mutables a menudo son bastante diferentes de los buenos algoritmos en estructuras de datos inmutables, por lo que vestir una estructura de datos inmutable como mutable (o viceversa) invita a algoritmos malos.

Si bien una lista persistente tiene algunas desventajas (sin anexos eficientes), esto no tiene por qué ser un problema grave cuando se programa funcionalmente: muchos algoritmos se pueden formular de manera eficiente cambiando la mentalidad y utilizando funciones de orden superior como mapo fold(por nombrar dos relativamente primitivas) ), o pretendiendo repetidamente. Además, nadie lo obliga a usar solo esta estructura de datos: cuando otros (efímeros o persistentes pero más sofisticados) son más apropiados, úselos. También debo señalar que las listas persistentes tienen algunas ventajas para otras cargas de trabajo: comparten sus colas, lo que puede conservar la memoria.


fuente
4

Si tiene una lista enlazada individualmente, trabajará con el frente si es más de lo que haría con el reverso.

Los lenguajes funcionales como prolog y haskel proporcionan formas fáciles de obtener el elemento frontal y el resto de la matriz. Anexando al reverso hay una operación O (n) con copias de cada nodo.

monstruo de trinquete
fuente
1
He usado Haskell. Afaik, también evita parte del problema al usar la pereza. Estoy haciendo anexos porque pensé que eso era lo que esperaba la Listinterfaz (aunque podría estar equivocado allí). Sin embargo, no creo que ese bit realmente responda la pregunta. Toda la lista aún necesitaría ser copiada; solo haría que el acceso al último elemento agregado sea más rápido ya que no se requeriría un recorrido completo.
Carcigenicate
Crear una clase de acuerdo con una interfaz que no coincida con la estructura de datos / algoritmo subyacente es una invitación al dolor y las ineficiencias.
monstruo de trinquete
Los JavaDoc utilizan explícitamente la palabra "agregar". ¿Estás diciendo que es mejor ignorar eso por el bien de la implementación?
Carcigenicate
2
@Carcigenicate no estoy diciendo que es un error tratar de adaptarse a una lista enlazada con nodos inmutables en el molde dejava.util.List
monstruo de trinquete
1
Con la pereza ya no habría una lista vinculada; no hay una lista, por lo tanto, no hay mutación (agregar). En cambio, hay un código que genera el siguiente elemento a pedido. Pero no se puede usar en todas partes donde se usa una lista. Es decir, si escribe un método en Java que espera mutar una lista que se pasa como argumento, esa lista tiene que ser mutable en primer lugar. El enfoque del generador requiere una reestructuración completa (reorganización) del código para que funcione, y para que sea posible eliminar físicamente la lista.
rwong
4

Como otros han señalado, tiene razón en que una lista inmutable enlazada individualmente requiere copiar toda la lista al realizar operaciones de adición.

A menudo, puede utilizar la solución alternativa para implementar su algoritmo en términos de consoperaciones (anteponer) y luego invertir la lista final una vez. Esto todavía necesita copiar la lista una vez, pero la sobrecarga de complejidad es lineal en la longitud de la lista, mientras que al usar repetidamente append puede obtener fácilmente una complejidad cuadrática.

Las listas de diferencias (ver, por ejemplo, aquí ) son una alternativa interesante. Una lista de diferencias envuelve una lista y proporciona una operación de adición en tiempo constante. Básicamente, trabaja con el contenedor todo el tiempo que necesite agregar, y luego vuelve a convertirlo en una lista cuando haya terminado. Esto es de alguna manera similar a lo que haces cuando usas a StringBuilderpara construir una cadena, y al final obtienes el resultado como un String(¡inmutable!) Llamando toString. Una diferencia es que a StringBuilderes mutable, pero una lista de diferencias es inmutable. Además, cuando convierte una lista de diferencias de nuevo en una lista, aún tiene que construir la lista nueva completa, pero, nuevamente, solo necesita hacer esto una vez.

Debería ser bastante fácil implementar una DListclase inmutable que proporcione una interfaz similar a la de Haskell Data.DList.

Giorgio
fuente
4

Debes ver este gran video de 2015 React conf del creador de Immutable.js , Lee Byron. Le dará punteros y estructuras para comprender cómo implementar una lista inmutable eficiente que no duplique el contenido. Las ideas básicas son: - siempre que dos listas usen nodos idénticos (el mismo valor, el mismo nodo siguiente), se usa el mismo nodo - cuando las listas comienzan a diferir, se crea una estructura en el nodo de divergencia, que mantiene punteros al siguiente nodo particular de cada lista

Esta imagen de un tutorial de reacción puede ser más clara que mi inglés roto:ingrese la descripción de la imagen aquí

feydaykyn
fuente
2

No es estrictamente Java, pero le sugiero que lea este artículo sobre una estructura de datos persistente indexada inmutable pero performante escrita en Scala:

http://www.codecommit.com/blog/scala/implementing-persistent-vectors-in-scala

Dado que es una estructura de datos Scala, también se puede usar desde Java (con un poco más de verbosidad). Se basa en una estructura de datos disponible en Clojure, y estoy seguro de que también hay una biblioteca Java más "nativa" que lo ofrece.

Además, una nota sobre la construcción de estructuras de datos inmutables: lo que generalmente desea es algún tipo de "Generador", que le permite "mutar" su estructura de datos "en construcción" añadiéndole elementos (dentro de un solo hilo); una vez que haya terminado de agregar, llama a un método en el objeto "en construcción" como .build()o .result(), que "construye" el objeto, proporcionándole una estructura de datos inmutable que luego puede compartir de manera segura.

Sandro Gržičić
fuente
1
Hay una pregunta sobre los puertos Java de PersistentVector en stackoverflow.com/questions/15997996/…
James_pic
1

Un enfoque que a veces puede ser útil sería tener dos clases de objetos que contengan listas: un objeto de lista hacia adelante con una finalreferencia al primer nodo en una lista vinculada hacia adelante y una no finalreferencia inicialmente nula que (cuando no -nulo) identifica un objeto de lista inversa que contiene los mismos elementos en el orden opuesto, y un objeto de lista inversa con una finalreferencia al último elemento de una lista vinculada inversa y una referencia no final inicialmente nula que (cuando no -null) identifica un objeto de lista directa que contiene los mismos elementos en orden opuesto.

Anteponer un elemento a una lista de reenvío o agregar un elemento a una lista inversa simplemente requeriría crear un nuevo nodo que se vincule con el nodo identificado por la finalreferencia y crear un nuevo objeto de lista del mismo tipo que el original, con una finalreferencia a ese nuevo nodo.

Agregar un elemento a una lista de reenvío o anteponer una lista de reversa requeriría tener una lista del tipo opuesto; la primera vez que se hace con una lista particular, debe crear un nuevo objeto del tipo opuesto y almacenar una referencia; repetir la acción debería reutilizar la lista.

Tenga en cuenta que el estado externo de un objeto de lista se considerará igual si su referencia a una lista de tipo opuesto es nula o identifica una lista de orden opuesto. No habría necesidad de hacer ninguna variable final, incluso cuando se usa código de subprocesos múltiples, ya que cada objeto de la lista tendría una finalreferencia a una copia completa de su contenido.

Si el código en un hilo crea y almacena en caché una copia invertida de una lista, y el campo en el que se almacena esa copia en caché no es volátil, sería posible que el código en otro hilo no vea la lista en caché, pero el único efecto adverso a partir de eso sería que el otro hilo necesitaría hacer un trabajo extra para construir otra copia invertida de la lista. Debido a que tal acción perjudicaría en el peor de los casos la eficiencia, pero no afectaría la corrección, y debido a que las volatilevariables crean deficiencias en la eficiencia por sí mismas, a menudo será mejor que la variable sea no volátil y acepte la posibilidad de operaciones redundantes ocasionales.

Super gato
fuente