¿Vale la pena la inmutabilidad cuando no hay concurrencia?

53

Parece que la seguridad de subprocesos siempre se menciona a menudo como el principal beneficio de usar tipos inmutables y especialmente colecciones.

Tengo una situación en la que me gustaría asegurarme de que un método no modifique un diccionario de cadenas (que son inmutables en C #). Me gustaría restringir las cosas tanto como sea posible.

Sin embargo, no estoy seguro de si vale la pena agregar una dependencia a un nuevo paquete (Microsoft Immutable Collections). El rendimiento tampoco es un gran problema.

Entonces, supongo que mi pregunta es si se recomienda encarecidamente realizar colecciones inmutables cuando no hay requisitos de rendimiento estrictos y no hay problemas de seguridad de los hilos. Tenga en cuenta que la semántica de valores (como en mi ejemplo) podría o no ser un requisito también.

Guarida
fuente
1
La modificación concurrente no necesariamente significa hilos. Solo mire el nombre apropiado ConcurrentModificationExceptionque generalmente es causado por el mismo hilo que muta la colección en el mismo hilo, en el cuerpo de un foreachbucle sobre la misma colección.
1
Quiero decir que no estás equivocado, pero eso es diferente de lo que está preguntando OP. Esa excepción se produce porque no se permite la modificación durante la enumeración. Usar un ConcurrentDictionary, por ejemplo, aún tendría este error.
edthethird
13
Quizás también deberías hacerte la pregunta opuesta: ¿cuándo vale la pena la mutabilidad?
Giorgio
2
En Java, si la mutabilidad afecta hashCode()o equals(Object)se modifica el resultado, puede causar errores al usarlo Collections(por ejemplo, en un HashSetobjeto se almacenó en un "depósito" y después de mutarlo debería ir a otro).
SJuan76
2
@ Davor®dralo En lo que respecta a las abstracciones de los lenguajes de alto nivel, la inmutabilidad generalizada es bastante mansa. Es solo una extensión natural de la abstracción muy común (presente incluso en C) de crear y descartar silenciosamente "valores temporales". Quizás quiera decir que es una forma ineficiente de usar una CPU, pero ese argumento también tiene fallas: los lenguajes dinámicos pero felices de mutabilidad a menudo funcionan peor que los lenguajes inmutables pero estáticos, en parte porque hay algunos inteligentes (pero en última instancia bastante simples) trucos para optimizar programas que hacen malabares con datos inmutables: tipos lineales, deforestación, etc.

Respuestas:

101

La inmutabilidad simplifica la cantidad de información que necesita rastrear mentalmente al leer el código más adelante . Para las variables mutables, y especialmente los miembros de la clase mutable, es muy difícil saber en qué estado estarán en la línea específica sobre la que está leyendo, sin ejecutar el código con un depurador. Es fácil razonar sobre los datos inmutables : siempre serán los mismos. Si desea cambiarlo, debe crear un nuevo valor.

Sinceramente, preferiría hacer que las cosas sean inmutables por defecto , y luego cambiarlas a mutables donde se demuestre que deben ser, ya sea que esto requiera el rendimiento o un algoritmo que tenga no tiene sentido para la inmutabilidad.

KChaloux
fuente
23
+1 La concurrencia es una mutación simultánea, pero la mutación que se extiende con el tiempo puede ser tan difícil de razonar
guillaume31
20
Para ampliar esto: se puede pensar que una función que se basa en una variable mutable toma un argumento oculto adicional, que es el valor actual de la variable mutable. Cualquier función que cambie una variable mutable también puede considerarse que produce un valor de retorno adicional, es decir, el nuevo valor del estado mutable. Cuando mira un fragmento de código, no tiene idea de si depende o cambia el estado mutable, por lo que debe averiguarlo y luego realizar un seguimiento mental de los cambios. Esto también introduce el acoplamiento entre dos piezas de código que comparten un estado mutable, y el acoplamiento es malo.
Doval
29
@Mehrdad People también logró producir grandes programas en asamblea durante décadas. Luego hicimos un par de décadas de C.
Doval
11
@Mehrdad Copiar objetos enteros no es una buena opción cuando los objetos son grandes. No veo por qué importa el orden de magnitud involucrado en la mejora. ¿Rechazaría una mejora del 20% (nota: número arbitrario) en productividad simplemente porque no fue una mejora de tres dígitos? La inmutabilidad es un defecto sensato ; usted puede alejarse de él, pero se necesita una razón.
Doval
99
@Giorgio Scala me hizo darme cuenta de cuán infrecuentemente necesitas incluso hacer un valor mutable. Cada vez que uso ese lenguaje, hago que todo sea a val, y solo en muy, muy raras ocasiones, encuentro que necesito cambiar algo a a var. Muchas de las 'variables' que defino en cualquier idioma están ahí para mantener un valor que almacena el resultado de algunos cálculos, y no necesita ser actualizado.
KChaloux
22

Su código debe expresar su intención. Si no desea que se modifique un objeto una vez creado, haga que sea imposible modificarlo.

La inmutabilidad tiene varios beneficios:

  • La intención del autor original se expresa mejor.

    ¿Cómo podría saber que en el siguiente código, modificar el nombre provocaría que la aplicación genere una excepción en algún momento posterior?

    public class Product
    {
        public string Name { get; set; }
    
        ...
    }
    
  • Es más fácil asegurarse de que el objeto no aparezca en un estado no válido.

    Tienes que controlar esto en un constructor, y solo allí. Por otro lado, si tiene un conjunto de setters y métodos que modifican el objeto, dichos controles pueden volverse particularmente difíciles, especialmente cuando, por ejemplo, dos campos deberían cambiar al mismo tiempo para que el objeto sea válido.

    Por ejemplo, un objeto es válido si la dirección no lo es null o las coordenadas GPS no lo son null, pero no es válido si se especifican tanto la dirección como las coordenadas GPS. ¿Te imaginas el infierno para validar esto si tanto la dirección como las coordenadas GPS tienen un setter o ambas son mutables?

  • Concurrencia.

Por cierto, en su caso, no necesita ningún paquete de terceros. .NET Framework ya incluye una ReadOnlyDictionary<TKey, TValue>clase.

Arseni Mourzenko
fuente
1
+1, especialmente para "Tienes que controlar esto en un constructor, y solo allí". OMI, esta es una gran ventaja.
Giorgio
10
Otro beneficio: copiar un objeto es gratis. Solo un puntero.
Robert Grant
1
@MainMa Gracias por su respuesta, pero por lo que entiendo, ReadOnlyDictionary no ofrece ninguna garantía de que alguien más no cambie el diccionario subyacente (incluso sin concurrencia, me gustaría guardar la referencia al diccionario original dentro del objeto al que pertenece el método para su uso posterior). ReadOnlyDictionary incluso se declara en un extraño espacio de nombres: System.Collections.ObjectModel.
Den
2
@ Den: Eso se relaciona con una de mis manías: las personas con respecto a "solo lectura" e "inmutable" como sinónimos. Si un objeto se encapsula en un contenedor de solo lectura y no existe otra referencia o se retiene en cualquier parte del universo, entonces envolver el objeto lo hará inmutable, y una referencia al contenedor se puede usar como una abreviatura para encapsular el estado del objeto contenido en el mismo. Sin embargo, no hay ningún mecanismo por el cual el código pueda determinar si ese es el caso. Por el contrario, debido a que el contenedor oculta el tipo del objeto envuelto, envolviendo un objeto inmutable ...
supercat
2
... hará imposible que el código sepa si el contenedor resultante puede considerarse inmutable de forma segura.
supercat
13

Hay muchas razones de un solo subproceso para usar la inmutabilidad. Por ejemplo

El objeto A contiene el objeto B.

El código externo consulta su objeto B y usted lo devuelve.

Ahora tienes tres situaciones posibles:

  1. B es inmutable, no hay problema.
  2. B es mutable, haces una copia defensiva y la devuelves. Golpe de rendimiento pero sin riesgo.
  3. B es mutable, lo devuelve.

En el tercer caso, el código de usuario puede no darse cuenta de lo que ha hecho y puede hacer cambios en el objeto, y al hacerlo, cambiar los datos internos de su objeto sin tener control o visibilidad de que eso suceda.

Tim B
fuente
9

La inmutabilidad también puede simplificar enormemente la implementación de recolectores de basura. Del wiki de GHC :

[...] La inmutabilidad de los datos nos obliga a producir muchos datos temporales, pero también ayuda a recolectar esta basura rápidamente. El truco es que los datos inmutables NUNCA apuntan a valores más jóvenes. De hecho, los valores más jóvenes aún no existen en el momento en que se crea un valor antiguo, por lo que no se puede señalar desde cero. Y como los valores nunca se modifican, tampoco se puede señalar más adelante. Esta es la propiedad clave de los datos inmutables.

Esto simplifica enormemente la recolección de basura (GC). En cualquier momento podemos escanear los últimos valores creados y liberar aquellos que no se señalan desde el mismo conjunto (por supuesto, las raíces reales de la jerarquía de valores vivos están en vivo en la pila). [...] Por lo tanto, tiene un comportamiento contrario a la intuición: cuanto mayor porcentaje de sus valores son basura, más rápido funciona. [...]

Petr Pudlák
fuente
5

Ampliando lo que KChaloux resumió muy bien ...

Idealmente, tiene dos tipos de campos y, por lo tanto, dos tipos de código que los utilizan. Cualquiera de los campos son inmutables y el código no tiene que tener en cuenta la mutabilidad; o los campos son mutables, y necesitamos escribir código que tome una instantánea ( int x = p.x) o maneje con gracia dichos cambios.

En mi experiencia, la mayoría del código se encuentra entre los dos, siendo un código optimista : hace referencia libremente a datos mutables, suponiendo que la primera llamada p.xtendrá el mismo resultado que la segunda llamada. Y la mayoría de las veces, esto es cierto, excepto cuando resulta que ya no lo es. Ups

Entonces, realmente, cambie esa pregunta: ¿Cuáles son mis razones para hacer esto mutable ?

  • Reducción de la asignación de memoria / libre?
  • Mutable por naturaleza? (por ejemplo, contador)
  • Guarda modificadores, ruido horizontal? (constante / final)
  • ¿Hace algún código más corto / más fácil? (init predeterminado, posiblemente sobrescribir después)

¿Escribes código defensivo? La inmutabilidad le ahorrará algunas copias. ¿Escribes código optimista? La inmutabilidad le ahorrará la locura de ese error extraño e imposible.

JvR
fuente
3

Otro beneficio de la inmutabilidad es que es el primer paso para redondear estos objetos inmutables en un grupo. Luego puede administrarlos para no crear múltiples objetos que representen de manera conceptual y semántica la misma cosa. Un buen ejemplo sería la cadena de Java.

Es un fenómeno bien conocido en lingüística que algunas palabras aparecen mucho, también pueden aparecer en otro contexto. Entonces, en lugar de crear varios Stringobjetos, puede usar uno inmutable. Pero luego debe mantener un administrador de grupo para cuidar estos objetos inmutables.

Esto te ahorrará mucha memoria. Este es un artículo interesante para leer también: http://en.wikipedia.org/wiki/Zipf%27s_law

InformadoA
fuente
1

En Java, C # y otros lenguajes similares, los campos de tipo de clase se pueden usar para identificar objetos o para encapsular valores o estados en esos objetos, pero los lenguajes no hacen distinción entre tales usos. Supongamos que un objeto de clase Georgetiene un campo de tipo char[] chars;. Ese campo puede encapsular una secuencia de caracteres en:

  1. Una matriz que nunca se modificará, ni se expondrá a ningún código que pueda modificarlo, pero al que puedan existir referencias externas.

  2. Una matriz a la que no existen referencias externas, pero que George puede modificar libremente.

  3. Una matriz que es propiedad de George, pero a la que pueden existir vistas externas que podrían representar el estado actual de George.

Además, la variable puede, en lugar de encapsular una secuencia de caracteres, encapsular una vista en vivo en una secuencia de caracteres propiedad de otro objeto

Si charsactualmente encapsula la secuencia de caracteres [wind], y George quiere charsencapsular la secuencia de caracteres [wand], hay varias cosas que George podría hacer:

A. Construya una nueva matriz que contenga los caracteres [varita mágica] y cambie charspara identificar esa matriz en lugar de la anterior.

B. Identifique de alguna manera una matriz de caracteres preexistente que siempre contendrá los caracteres [varita mágica] y cambiará charspara identificar esa matriz en lugar de la anterior.

C. Cambie el segundo carácter de la matriz identificada por charsa a.

En el caso 1, (A) y (B) son formas seguras de lograr el resultado deseado. En el caso de que (2), (A) y (C) sean seguros, pero (B) no lo sería [no causaría problemas inmediatos, pero dado que George supondría que tiene la propiedad de la matriz, asumiría que podría cambiar la matriz a voluntad]. En el caso (3), las opciones (A) y (B) romperían las vistas externas y, por lo tanto, solo la opción (C) es correcta. Por lo tanto, saber cómo modificar la secuencia de caracteres encapsulada por el campo requiere saber qué tipo de campo semántico es.

Si en lugar de utilizar un campo de tipo char[], que encapsula una secuencia de caracteres potencialmente mutable, el código ha utilizado el tipo String, que encapsula una secuencia de caracteres inmutable, todos los problemas anteriores desaparecen. Todos los campos de tipo Stringencapsulan una secuencia de caracteres usando un objeto compartible que nunca cambiará. En consecuencia, si un campo de tipoStringencapsula "wind", la única forma de encapsular "wand" es hacer que identifique un objeto diferente, uno que contenga "wand". En los casos en que el código contiene la única referencia al objeto, mutar el objeto puede ser más eficiente que crear uno nuevo, pero cada vez que una clase es mutable es necesario distinguir entre las diferentes formas en que puede encapsular el valor. Personalmente, creo que las aplicaciones húngaras deberían haberse utilizado para esto (consideraría que los cuatro usos de char[]son tipos semánticamente distintos, a pesar de que el sistema de tipos los considera idénticos, exactamente el tipo de situación en la que brilla la aplicación húngara), pero dado que No era la forma más fácil de evitar tales ambigüedades es diseñar tipos inmutables que solo encapsulan valores de una manera.

Super gato
fuente
Parece una respuesta razonable, pero es un poco difícil de leer y comprender.
Den
1

Hay algunos buenos ejemplos aquí, pero quería saltar con algunos personales donde la inmutabilidad ayudó mucho. En mi caso, comencé a diseñar una estructura de datos simultánea inmutable principalmente con la esperanza de poder ejecutar el código con seguridad en paralelo con lecturas y escrituras superpuestas y no tener que preocuparme por las condiciones de la carrera. Hubo una charla que John Carmack me dio que me inspiró a hacerlo cuando habló de tal idea. Es una estructura bastante básica y bastante trivial para implementar de esta manera:

ingrese la descripción de la imagen aquí

Por supuesto, con algunas campanas y silbatos más como poder eliminar elementos en tiempo constante y dejar agujeros recuperables y hacer que los bloques se vuelvan a deshacer si se vuelven vacíos y potencialmente liberados para una instancia inmutable dada. Pero, básicamente, para modificar la estructura, modifica una versión "transitoria" y confirma atómicamente los cambios que realizó para obtener una nueva copia inmutable que no toque la anterior, y la nueva versión solo crea nuevas copias de los bloques que deben hacerse únicos mientras se copian poco a poco y se cuentan las referencias.

Sin embargo, no me pareció queútil para propósitos de subprocesos múltiples. Después de todo, todavía existe el problema conceptual en el que, por ejemplo, un sistema de física aplica la física simultáneamente mientras un jugador está tratando de mover elementos en un mundo. ¿Con qué copia inmutable de los datos transformados vas, la que el jugador transformó o la que transformó el sistema físico? Por lo tanto, realmente no he encontrado una solución agradable y simple para este problema conceptual básico, excepto tener estructuras de datos mutables que simplemente se bloquean de una manera más inteligente y desalientan las lecturas y escrituras superpuestas en las mismas secciones del búfer para evitar detener los hilos. Eso es algo que John Carmack parece haber descubierto cómo resolver en sus juegos; al menos habla de eso como si casi pudiera ver una solución sin abrir un auto de gusanos. No he llegado tan lejos como él en ese sentido. Todo lo que puedo ver son interminables preguntas de diseño si solo intento paralelizar todo alrededor de los inmutables. Desearía poder pasar un día hurgando en su cerebro ya que la mayoría de mis esfuerzos comenzaron con esas ideas que él arrojó.

Sin embargo, encontré un enorme valor de esta estructura de datos inmutable en otras áreas. Incluso lo uso ahora para almacenar imágenes, lo cual es realmente extraño y hace que el acceso aleatorio requiera algunas instrucciones más (desplazamiento a la derecha y un poco a lo andlargo junto con una capa de dirección indirecta del puntero), pero cubriré los beneficios a continuación.

Deshacer sistema

Uno de los lugares más inmediatos que encontré para beneficiarme de esto fue el sistema de deshacer. El código del sistema de deshacer solía ser una de las cosas más propensas a errores en mi área (industria visual FX), y no solo en los productos en los que trabajé sino en los productos de la competencia (sus sistemas de deshacer también eran inestables) porque había muchas diferencias tipos de datos para preocuparse por deshacer y rehacer correctamente (sistema de propiedad, cambios de datos de malla, cambios de sombreador que no se basaron en propiedades como el intercambio de uno con el otro, cambios en la jerarquía de la escena como cambiar el padre de un hijo, cambios de imagen / textura, etc. etc. etc.).

Por lo tanto, la cantidad de código de deshacer requerida era enorme, a menudo rivalizando con la cantidad de código que implementaba el sistema para el cual el sistema de deshacer tenía que registrar los cambios de estado. Al apoyarme en esta estructura de datos, pude hacer que el sistema de deshacer se redujera a esto:

on user operation:
    copy entire application state to undo entry
    perform operation

on undo/redo:
    swap application state with undo entry

Normalmente, el código anterior sería enormemente ineficiente cuando los datos de su escena abarcan gigabytes para copiarlos en su totalidad. Pero esta estructura de datos solo copia cosas poco profundas que no se modificaron, y en realidad lo hizo lo suficientemente barato como para almacenar una copia inmutable de todo el estado de la aplicación. Así que ahora puedo implementar sistemas de deshacer tan fácilmente como el código anterior y solo enfocarme en usar esta estructura de datos inmutable para hacer que copiar partes no modificadas del estado de la aplicación sea más barato y más barato. Desde que comencé a usar esta estructura de datos, todos mis proyectos personales tienen sistemas de deshacer simplemente usando este patrón simple.

Ahora todavía hay algo de gastos generales aquí. La última vez que medí fue alrededor de 10 kilobytes solo para copiar superficialmente el estado completo de la aplicación sin hacer ningún cambio (esto es independiente de la complejidad de la escena ya que la escena está organizada en una jerarquía, por lo que si nada debajo de la raíz cambia, solo la raíz se copia poco profundo sin tener que descender hacia los niños). Eso está lejos de 0 bytes, ya que sería necesario para un sistema de deshacer que solo almacena deltas. Pero a 10 kilobytes de gastos generales de deshacer por operación, eso sigue siendo solo un megabyte por cada 100 operaciones de usuario. Además, aún podría aplastarlo aún más en el futuro si fuera necesario.

Excepción-Seguridad

La seguridad de excepción con una aplicación compleja no es un asunto trivial. Sin embargo, cuando el estado de su aplicación es inmutable y solo está utilizando objetos transitorios para intentar realizar transacciones de cambio atómico, entonces es inherentemente seguro para excepciones, ya que si se arroja alguna parte del código, el transitorio se descarta antes de dar una nueva copia inmutable . Así que eso trivializa una de las cosas más difíciles que siempre he encontrado en un código base de C ++ complejo.

Demasiadas personas a menudo solo usan recursos conformes con RAII en C ++ y piensan que es suficiente para estar a salvo de excepciones. A menudo no lo es, ya que una función generalmente puede causar efectos secundarios a estados más allá de los locales a su alcance. Por lo general, debe comenzar a tratar con protectores de alcance y una lógica de reversión sofisticada en esos casos. Esta estructura de datos lo hizo así que a menudo no necesito molestarme con eso ya que las funciones no están causando efectos secundarios. Están devolviendo copias inmutables transformadas del estado de la aplicación en lugar de transformar el estado de la aplicación.

Edición no destructiva

ingrese la descripción de la imagen aquí

La edición no destructiva es básicamente operaciones de capas / apilamiento / conexión juntas sin tocar los datos del usuario original (solo datos de entrada y datos de salida sin tocar la entrada). Por lo general, es trivial implementarlo con una aplicación de imagen simple como Photoshop y es posible que no se beneficie tanto de esta estructura de datos, ya que muchas operaciones pueden simplemente querer transformar cada píxel de toda la imagen.

Sin embargo, con la edición de malla no destructiva, por ejemplo, muchas operaciones a menudo quieren transformar solo una parte de la malla. Una operación puede querer mover algunos vértices aquí. Otro podría simplemente querer subdividir algunos polígonos allí. Aquí, la estructura de datos inmutable ayuda mucho a evitar la necesidad de tener que hacer una copia completa de la malla completa solo para devolver una nueva versión de la malla con una pequeña parte de ella modificada.

Minimizando los efectos secundarios

Con estas estructuras en la mano, también facilita la escritura de funciones que minimizan los efectos secundarios sin incurrir en una gran penalización de rendimiento. Me he encontrado escribiendo más y más funciones que solo devuelven estructuras de datos inmutables por valor en estos días sin incurrir en efectos secundarios, incluso cuando parece un poco inútil.

Por ejemplo, típicamente la tentación de transformar un montón de posiciones podría ser aceptar una matriz y una lista de objetos y transformarlos de manera mutable. En estos días me encuentro devolviendo una nueva lista de objetos.

Cuando tiene más funciones como esta en su sistema que no causan efectos secundarios, definitivamente hace que sea más fácil razonar sobre su corrección y probar su corrección.

Los beneficios de las copias baratas

De todos modos, estas son las áreas donde encontré el mayor uso de estructuras de datos inmutables (o estructuras de datos persistentes). También me puse un poco entusiasta al principio e hice un árbol inmutable y una lista enlazada inmutable y una tabla hash inmutable, pero con el tiempo rara vez encontré tanto uso para esos. Principalmente encontré el mayor uso del grueso contenedor inmutable tipo matriz en el diagrama anterior.

También todavía tengo mucho código trabajando con mutables (considero que es una necesidad práctica al menos para el código de bajo nivel), pero el estado principal de la aplicación es una jerarquía inmutable, que se desglosa de una escena inmutable a componentes inmutables dentro de ella. Algunos de los componentes más baratos todavía se copian en su totalidad, pero los más caros, como las mallas y las imágenes, usan la estructura inmutable para permitir esas copias baratas parciales de solo las partes que necesitan ser transformadas.


fuente
0

Ya hay muchas buenas respuestas. Esta es solo una información adicional algo específica para .NET. Estaba investigando las publicaciones antiguas de blogs de .NET y encontré un buen resumen de beneficios desde el punto de vista de los desarrolladores de Microsoft Immutable Collections:

  1. La semántica de instantáneas, que le permite compartir sus colecciones de una manera en la que el receptor puede contar sin cambiar nunca.

  2. Seguridad implícita de subprocesos en aplicaciones de subprocesos múltiples (no se requieren bloqueos para acceder a las colecciones).

  3. Cada vez que tenga un miembro de la clase que acepte o devuelva un tipo de colección y desee incluir una semántica de solo lectura en el contrato.

  4. Programación funcional amigable.

  5. Permitir la modificación de una colección durante la enumeración, mientras se asegura que la colección original no cambie.

  6. Implementan las mismas interfaces IReadOnly * que su código ya maneja, por lo que la migración es fácil.

Si alguien le entrega un ReadOnlyCollection, un IReadOnlyList o un IEnumerable, la única garantía es que no puede cambiar los datos; no hay garantía de que la persona que le entregó la colección no lo cambie. Sin embargo, a menudo necesita cierta confianza en que no cambiará. Estos tipos no ofrecen eventos para notificarle cuando cambian sus contenidos, y si cambian, ¿podría ocurrir en un hilo diferente, posiblemente mientras enumera sus contenidos? Tal comportamiento conduciría a la corrupción de datos y / o excepciones aleatorias en su aplicación.

Guarida
fuente