¿En qué punto las clases inmutables se convierten en una carga?

16

Cuando diseñe clases para contener su modelo de datos que he leído, puede ser útil crear objetos inmutables, pero ¿en qué momento la carga de las listas de parámetros del constructor y las copias profundas se vuelven demasiado y tiene que abandonar la restricción inmutable?

Por ejemplo, aquí hay una clase inmutable para representar una cosa con nombre (estoy usando la sintaxis de C # pero el principio se aplica a todos los lenguajes OO)

class NamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    
    public NamedThing(NamedThing other)
    {
         this._name = other._name;
    }
    public string Name
    {
        get { return _name; }
    }
}

Las cosas con nombre se pueden construir, consultar y copiar en cosas nuevas con nombre, pero el nombre no se puede cambiar.

Todo esto está bien, pero ¿qué sucede cuando quiero agregar otro atributo? Tengo que agregar un parámetro al constructor y actualizar el constructor de la copia; lo cual no es demasiado trabajo, pero los problemas comienzan, por lo que puedo ver, cuando quiero hacer que un objeto complejo sea inmutable.

Si la clase contiene muchos atributos y colecciones, que contienen otras clases complejas, me parece que la lista de parámetros del constructor se convertiría en una pesadilla.

Entonces, ¿en qué punto una clase se vuelve demasiado compleja para ser inmutable?

Tony
fuente
Siempre hago un esfuerzo para hacer que las clases en mi modelo sean inmutables. Si tiene grandes y largas listas de parámetros de contructor, ¿tal vez su clase es demasiado grande y se puede dividir? Si sus objetos de nivel inferior también son inmutables y siguen el mismo patrón, entonces sus objetos de nivel superior no deberían sufrir (demasiado). Me resulta MUCHO más difícil cambiar una clase existente para que sea inmutable que hacer que un modelo de datos sea inmutable cuando empiezo desde cero.
Nadie
1
Puede ver el patrón del generador sugerido en esta pregunta: stackoverflow.com/questions/1304154/…
Ant
¿Has mirado en MemberwiseClone? No tiene que actualizar el constructor de copia para cada nuevo miembro.
Kevin Cline
3
@ Tony Si sus colecciones y todo lo que contienen también son inmutables, no necesita una copia profunda, una copia superficial es suficiente.
mjcopple
2
Como un aparte, comúnmente uso los campos "set-once" en clases donde la clase necesita ser "bastante" inmutable, pero no completamente. Creo que esto resuelve el problema de los grandes constructores, pero proporciona la mayoría de los beneficios de las clases inmutables. (es decir, su código de clase interno no tiene que preocuparse por un cambio de valor)
Earlz

Respuestas:

23

Cuando se convierten en una carga? Muy rápidamente (especialmente si su idioma de elección no proporciona suficiente soporte sintáctico para la inmutabilidad).

La inmutabilidad se vende como la bala de plata para el dilema multinúcleo y todo eso. Pero la inmutabilidad en la mayoría de los lenguajes OO lo obliga a agregar artefactos y prácticas artificiales en su modelo y proceso. Para cada clase inmutable compleja debe tener un constructor igualmente complejo (al menos internamente). No importa cómo lo diseñe, todavía presenta un acoplamiento fuerte (por lo tanto, es mejor que tengamos una buena razón para presentarlos).

No es necesariamente posible modelar todo en clases pequeñas no complejas. Entonces, para clases y estructuras grandes, las dividimos artificialmente, no porque eso tenga sentido en nuestro modelo de dominio, sino porque tenemos que lidiar con su compleja creación de instancias y constructores en código.

Es aún peor cuando las personas llevan la idea de la inmutabilidad demasiado lejos en un lenguaje de propósito general como Java o C #, haciendo que todo sea inmutable. Luego, como resultado, verá personas forzando construcciones de expresión s en lenguajes que no admiten tales cosas con facilidad.

La ingeniería es el acto de modelar a través de compromisos y compensaciones. Hacer que todo sea inmutable por edicto porque alguien leyó que todo es inmutable en lenguaje funcional X o Y (un modelo de programación completamente diferente), eso no es aceptable. Eso no es buena ingeniería.

Las cosas pequeñas, posiblemente unitarias, pueden hacerse inmutables. Las cosas más complejas pueden volverse inmutables cuando tiene sentido . Pero la inmutabilidad no es una bala de plata. La capacidad de reducir errores, aumentar la escalabilidad y el rendimiento, esa no es la única función de la inmutabilidad. Es una función de las prácticas de ingeniería adecuadas . Después de todo, la gente ha escrito un buen software escalable sin inmutabilidad.

La inmutabilidad se convierte en una carga realmente rápida (se agrega a la complejidad accidental) si se hace sin una razón, cuando se hace fuera de lo que tiene sentido en el contexto de un modelo de dominio.

Yo, por mi parte, trato de evitarlo (a menos que esté trabajando en un lenguaje de programación con un buen soporte sintáctico).

luis.espinal
fuente
66
luis, ¿has notado cómo las respuestas bien escritas y pragmáticamente correctas escritas con una explicación de principios de ingeniería simples pero sólidos tienden a no obtener tantos votos como los que usan las últimas novedades en codificación? Esta es una gran, gran respuesta.
Huperniketes
3
Gracias :) Me he dado cuenta de las tendencias de repeticiones, pero está bien. Código de abandono de fanboys de moda que luego podemos reparar a mejores tarifas por hora, jajaja :) jk ...
luis.espinal
44
¿Bala de plata? No. ¿Vale la pena la incomodidad en C # / Java (no es realmente tan malo)? Absolutamente. Además, el papel multinúcleo en la inmutabilidad es bastante menor ... el beneficio real es la facilidad de razonamiento.
Mauricio Scheffer
@Mauricio: si tú lo dices (esa inmutabilidad en Java no es tan mala). Después de haber trabajado en Java desde 1998 hasta 2011, suplicaría diferir, no está exento de trivial en bases de código simples. Sin embargo, las personas tienen diferentes experiencias y reconozco que mi POV no está libre de subjetividad. Lo siento, no puedo estar de acuerdo allí. Sin embargo, estoy de acuerdo con que la facilidad de razonamiento es lo más importante cuando se trata de inmutabilidad.
luis.espinal
13

Pasé por una fase de insistir en que las clases sean inmutables siempre que sea posible. Tenía constructores para casi todo, matrices inmutables, etc., etc. Encontré que la respuesta a su pregunta es simple: ¿en qué punto las clases inmutables se convierten en una carga? Muy rápidamente. Tan pronto como desee serializar algo, debe poder deserializarlo, lo que significa que debe ser mutable; Tan pronto como desee utilizar un ORM, la mayoría de ellos insisten en que las propiedades sean mutables. Y así.

Finalmente reemplacé esa política con interfaces inmutables a objetos mutables.

class NamedThing : INamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    

    public NamedThing(NamedThing other)
    {
        this._name = other._name;
    }

    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
}

interface INamedThing
{
    string Name { get; }
}

Ahora el objeto tiene flexibilidad, pero aún puede decirle al código de llamada que no debe editar estas propiedades.

pdr
fuente
8
Dejando a un lado las pequeñas objeciones, estoy de acuerdo en que los objetos inmutables pueden convertirse en un dolor en el trasero muy rápidamente cuando se programa en un lenguaje imperativo, pero no estoy realmente seguro de si una interfaz inmutable realmente resuelve el mismo problema o cualquier problema. La razón principal para usar un objeto inmutable es para que pueda tirarlo a cualquier parte en cualquier momento y nunca tener que preocuparse de que alguien más corrompa su estado. Si el objeto subyacente es mutable, entonces no tiene esa garantía, especialmente si la razón por la que lo mantuvo mutable fue porque varias cosas necesitan mutarlo.
Aaronaught
1
@Aaronaught - Punto interesante. Supongo que es algo psicológico más que una protección real. Sin embargo, su última línea tiene una premisa falsa. La razón para mantenerlo mutable es más que varias cosas necesitan instanciarlo y poblarlo a través de la reflexión, no mutar una vez instanciado.
pdr
1
@Aaronaught: la misma manera IComparable<T>garantiza que si X.CompareTo(Y)>0y Y.CompareTo(Z)>0, entonces X.CompareTo(Z)>0. Las interfaces tienen contratos . Si el contrato de IImmutableList<T>especifica que los valores de todos los artículos y propiedades deben "establecerse en piedra" antes de que cualquier instancia se exponga al mundo exterior, entonces todas las implementaciones legítimas lo harán. Nada impide que una IComparableimplementación viole la transitividad, pero las implementaciones que lo hacen son ilegítimas. Si un SortedDictionarymal funcionamiento cuando se le da un ilegítimo IComparable, ...
supercat
1
@Aaronaught: ¿Por qué debería confiar en que las implementaciones de IReadOnlyList<T>serán inmutable, dado que (1) no hay tal requisito se indica en la documentación de la interfaz, y (2) la aplicación más común, List<T>, ni siquiera es de sólo lectura ? No tengo muy claro qué es ambiguo acerca de mis términos: una colección es legible si los datos que contiene pueden leerse. Es de solo lectura si puede prometer que los datos contenidos no se pueden cambiar a menos que el código contenga alguna referencia externa que lo cambie. Es inmutable si puede garantizar que no se puede cambiar, punto.
supercat
1
@supercat: Por cierto, Microsoft está de acuerdo conmigo. Lanzaron un paquete de colecciones inmutables y notaron que todos son tipos concretos, porque nunca se puede garantizar que una clase o interfaz abstracta sea realmente inmutable.
Aaronaught
8

No creo que haya una respuesta general a esto. Cuanto más compleja es una clase, más difícil es razonar sobre sus cambios de estado y más costosa es crear nuevas copias de ella. Por lo tanto, por encima de cierto nivel (personal) de complejidad, será demasiado doloroso hacer / mantener una clase inmutable.

Tenga en cuenta que una clase demasiado compleja o una larga lista de parámetros de métodos son olores de diseño per se, independientemente de la inmutabilidad.

Por lo general, la solución preferida sería dividir dicha clase en varias clases distintas, cada una de las cuales puede hacerse mutable o inmutable por sí sola. Si esto no es factible, se puede convertir en mutable.

Péter Török
fuente
5

Puede evitar el problema de la copia si almacena todos sus campos inmutables en un interno struct. Esto es básicamente una variación del patrón de recuerdo. Luego, cuando desee hacer una copia, simplemente copie el recuerdo:

class MyClass
{
    struct Memento
    {
        public int field1;
        public string field2;
    }

    private readonly Memento memento;

    public MyClass(int field1, string field2)
    {
        this.memento = new Memento()
            {
                field1 = field1,
                field2 = field2
            };
    }

    private MyClass(Memento memento) // for copying
    {
        this.memento = memento;
    }

    public int Field1 { get { return this.memento.field1; } }
    public string Field2 { get { return this.memento.field2; } }

    public MyClass WithNewField1(int newField1)
    {
        Memento newMemento = this.memento;
        newMemento.field1 = newField1;
        return new MyClass(newMemento);
    }
}
Scott Whitlock
fuente
Creo que la estructura interna no es necesaria. Es solo otra forma de hacer MemberwiseClone.
Codismo
@Codism: sí y no. Hay momentos en los que puede necesitar otros miembros que no desea clonar. ¿Qué pasaría si estuviera utilizando una evaluación perezosa en uno de sus captadores y almacenando el resultado en un miembro? Si hace un MemberwiseClone, clonará el valor en caché, luego cambiará a uno de sus miembros de los que depende el valor en caché. Es más limpio separar el estado del caché.
Scott Whitlock
Vale la pena mencionar otra ventaja de la estructura interna: facilita que un objeto copie su estado en un objeto al que existen otras referencias . Una fuente común de ambigüedad en OOP es si un método que devuelve una referencia de objeto devuelve una vista de un objeto que podría cambiar fuera del control del destinatario. Si en lugar de devolver una referencia de objeto, un método acepta una referencia de objeto del llamador y le copia el estado, la propiedad del objeto será mucho más clara. Tal enfoque no funciona bien con tipos libremente heredables, pero ...
supercat
... puede ser muy útil con los titulares de datos mutables. El enfoque también hace que sea muy fácil tener clases mutables e inmutables "paralelas" (derivadas de una base "legible" abstracta) y que sus constructores puedan copiar datos entre sí.
supercat
3

Tienes un par de cosas en el trabajo aquí. Los conjuntos de datos inmutables son excelentes para la escalabilidad multiproceso. Esencialmente, puede optimizar su memoria bastante para que un conjunto de parámetros sea una instancia de la clase, en todas partes. Debido a que los objetos nunca cambian, no tiene que preocuparse por sincronizar el acceso a sus miembros. Eso es bueno. Sin embargo, como usted señala, cuanto más complejo es el objeto, más necesita mutabilidad. Comenzaría con el razonamiento en este sentido:

  • ¿Hay alguna razón comercial por la que un objeto puede cambiar su estado? Por ejemplo, un objeto de usuario almacenado en una base de datos es único en función de su ID, pero debe poder cambiar de estado con el tiempo. Por otro lado, cuando cambia las coordenadas en una cuadrícula, deja de ser la coordenada original, por lo que tiene sentido hacer que las coordenadas sean inmutables. Lo mismo con las cuerdas.
  • ¿Se pueden calcular algunos de los atributos? En resumen, si los otros valores en la nueva copia de un objeto son una función de algún valor central que pase, puede calcularlos en el constructor o bajo demanda. Esto reduce la cantidad de mantenimiento, ya que puede inicializar esos valores de la misma manera en copiar o crear.
  • ¿Cuántos valores forman el nuevo objeto inmutable? En algún momento, la complejidad de crear un objeto se vuelve no trivial y en ese momento tener más instancias del objeto puede convertirse en un problema. Los ejemplos incluyen estructuras de árbol inmutables, objetos con más de tres parámetros pasados, etc. Cuantos más parámetros, más posibilidades hay de desordenar el orden de los parámetros o anular el incorrecto.

En lenguajes que solo admiten objetos inmutables (como Erlang), si hay alguna operación que parece modificar el estado de un objeto inmutable, el resultado final es una nueva copia del objeto con el valor actualizado. Por ejemplo, cuando agrega un elemento a un vector / lista:

myList = lists:append([[1,2,3], [4,5,6]])
% myList is now [1,2,3,4,5,6]

Esa puede ser una forma sensata de trabajar con objetos más complicados. A medida que agrega un nodo de árbol, por ejemplo, el resultado es un nuevo árbol con el nodo agregado. El método en el ejemplo anterior devuelve una nueva lista. En el ejemplo de este párrafo tree.add(newNode), devolvería un nuevo árbol con el nodo agregado. Para los usuarios, resulta fácil trabajar con ellos. Para los escritores de la biblioteca se vuelve tedioso cuando el lenguaje no admite la copia implícita. Ese umbral depende de tu propia paciencia. Para los usuarios de su biblioteca, el límite más sensato que he encontrado es de tres a cuatro parámetros como máximo.

Berin Loritsch
fuente
Si uno se inclina a usar una referencia de objeto mutable como valor [lo que significa que no hay referencias contenidas dentro de su propietario y nunca expuestas], construir un nuevo objeto inmutable que contenga los contenidos "modificados" deseados es equivalente a modificar el objeto directamente, aunque es probable que sea más lento. Sin embargo, los objetos mutables también se pueden usar como entidades . ¿Cómo se podrían hacer cosas que se comporten como entidades sin objetos mutables?
supercat
0

Si tiene varios miembros finales de clase y no desea que estén expuestos a todos los objetos que necesitan crearlo, puede usar el patrón de construcción:

class NamedThing
{
    private string _name;    
    private string _value;
    private NamedThing(string name, string value)
    {
        _name = name;
        _value = value;
    }    
    public NamedThing(NamedThing other)
    {
        this._name = other._name;
        this._value = other._value;
    }
    public string Name
    {
        get { return _name; }
    }

    public static class Builder {
        string _name;
        string _value;

        public void setValue(string value) {
            _value = value;
        }
        public void setName(string name) {
            _name = name;
        }
        public NamedThing newObject() {
            return new NamedThing(_name, _value);
        }
    }
}

La ventaja es que puede crear fácilmente un nuevo objeto con solo un valor diferente de un nombre diferente.

Salandur
fuente
Creo que su constructor siendo estático no es correcto. Otro hilo podría cambiar el nombre estático o el valor estático después de configurarlos, pero antes de llamar newObject.
ErikE
Solo la clase de constructor es estática, pero sus miembros no lo son. Esto significa que para cada constructor que cree, tienen su propio conjunto de miembros con los valores correspondientes. La clase debe ser estática para que pueda usarse e instanciarse fuera de la clase que lo contiene ( NamedThingen este caso)
Salandur
Veo lo que estás diciendo, solo imagino un problema con esto porque no lleva a un desarrollador a "caer en el pozo del éxito". El hecho de que use variables estáticas significa que si Builderse reutiliza a, existe un riesgo real de que ocurra lo que mencioné. Alguien podría estar construyendo muchos objetos y decidir que, dado que la mayoría de las propiedades son las mismas, simplemente reutilizar Buildery, de hecho, ¡hagámoslo como un singleton global que se inyecte a la dependencia! Whoops Errores importantes introducidos. Entonces, creo que este patrón de instancia mixta vs. estática es malo.
ErikE
1
@Salandur Las clases internas en C # son siempre "estáticas" en el sentido de clase interna de Java.
Sebastian Redl
0

Entonces, ¿en qué punto una clase se vuelve demasiado compleja para ser inmutable?

En mi opinión, no vale la pena molestarse en hacer que las clases pequeñas sean inmutables en idiomas como el que está mostrando. Estoy usando pequeños aquí y no complejos , porque incluso si agrega diez campos a esa clase y realmente les hace operaciones sofisticadas, dudo que tome kilobytes, mucho menos megabytes, mucho menos gigabytes, por lo que cualquier función que use instancias de su class simplemente puede hacer una copia barata de todo el objeto para evitar modificar el original si quiere evitar causar efectos secundarios externos.

Estructuras de datos persistentes

Donde encuentro uso personal para la inmutabilidad es para grandes estructuras de datos centrales que agregan un montón de datos pequeños como instancias de la clase que está mostrando, como una que almacena un millón NamedThings. Al pertenecer a una estructura de datos persistente que es inmutable y estar detrás de una interfaz que solo permite acceso de solo lectura, los elementos que pertenecen al contenedor se vuelven inmutables sin que la clase de elemento ( NamedThing) tenga que ocuparse de él.

Copias Baratas

La estructura de datos persistente permite que las regiones se transformen y se hagan únicas, evitando modificaciones al original sin tener que copiar la estructura de datos en su totalidad. Esa es la verdadera belleza de esto. Si quisiera escribir ingenuamente funciones que eviten los efectos secundarios que ingresan una estructura de datos que toma gigabytes de memoria y solo modifica el valor de la memoria de un megabyte, entonces tendría que copiar todo el asunto para evitar tocar la entrada y devolver un nuevo salida. Puede copiar gigabytes para evitar efectos secundarios o causar efectos secundarios en ese escenario, por lo que debe elegir entre dos opciones desagradables.

Con una estructura de datos persistente, le permite escribir dicha función y evitar hacer una copia de toda la estructura de datos, solo requiere aproximadamente un megabyte de memoria adicional para la salida si su función solo transformó el valor de la memoria de un megabyte.

Carga

En cuanto a la carga, hay una inmediata al menos en mi caso. Necesito esos constructores de los que la gente habla o "transitorios" como los llamo para poder expresar efectivamente transformaciones a esa estructura de datos masiva sin tocarla. Código como este:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
     // Transform stuff in the range, [first, last).
     for (; first != last; ++first)
          transform(stuff[first]);
}

... entonces tiene que escribirse así:

ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
     // Grab a "transient" (builder) list we can modify:
     TransientList<Stuff> transient(stuff);

     // Transform stuff in the range, [first, last)
     // for the transient list.
     for (; first != last; ++first)
          transform(transient[first]);

     // Commit the modifications to get and return a new
     // immutable list.
     return stuff.commit(transient);
}

Pero a cambio de esas dos líneas de código adicionales, la función ahora es segura para llamar a través de subprocesos con la misma lista original, no causa efectos secundarios, etc. También hace que sea realmente fácil hacer que esta operación sea una acción que el usuario puede deshacer ya que deshacer puede almacenar una copia superficial barata de la lista anterior.

Excepción-Seguridad o recuperación de errores

No todos podrían beneficiarse tanto como yo de las estructuras de datos persistentes en contextos como estos (encontré mucho uso para ellos en sistemas de deshacer y edición no destructiva, que son conceptos centrales en mi dominio VFX), pero una cosa es aplicable a casi todos deben tener en cuenta la excepción de seguridad o recuperación de errores .

Si desea hacer que la función de mutación original sea segura para excepciones, entonces necesita una lógica de reversión, para lo cual la implementación más simple requiere copiar toda la lista:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
    // Make a copy of the whole massive gigabyte-sized list 
    // in case we encounter an exception and need to rollback
    // changes.
    MutList<Stuff> old_stuff = stuff;

    try
    {
         // Transform stuff in the range, [first, last).
         for (; first != last; ++first)
             transform(stuff[first]);
    }
    catch (...)
    {
         // If the operation failed and ran into an exception,
         // swap the original list with the one we modified
         // to "undo" our changes.
         stuff.swap(old_stuff);
         throw;
    }
}

En este punto, la versión mutable segura para excepciones es aún más costosa desde el punto de vista computacional y podría decirse que es aún más difícil de escribir correctamente que la versión inmutable que utiliza un "generador". Y muchos desarrolladores de C ++ simplemente descuidan la seguridad de excepciones y tal vez eso esté bien para su dominio, pero en mi caso me gusta asegurarme de que mi código funcione correctamente incluso en el caso de una excepción (incluso escribir pruebas que arrojan excepciones deliberadamente a la excepción de prueba) seguridad), y eso hace que tenga que poder revertir cualquier efecto secundario que una función cause a la mitad de la función si algo arroja.

Cuando desee estar a salvo de excepciones y recuperarse de errores con gracia sin que su aplicación se bloquee y se queme, entonces debe revertir / deshacer cualquier efecto secundario que una función pueda causar en caso de error / excepción. Y allí el constructor puede ahorrar más tiempo de programador de lo que cuesta junto con el tiempo de cálculo porque: ...

¡No tiene que preocuparse por revertir los efectos secundarios en una función que no causa ninguno!

Volviendo a la pregunta fundamental:

¿En qué punto las clases inmutables se convierten en una carga?

Siempre son una carga en los idiomas que giran más en torno a la mutabilidad que a la inmutabilidad, por lo que creo que debería usarlos donde los beneficios superan significativamente los costos. Pero a un nivel lo suficientemente amplio para estructuras de datos lo suficientemente grandes, creo que hay muchos casos en los que es una compensación digna.

También en el mío, solo tengo unos pocos tipos de datos inmutables, y todas ellas son enormes estructuras de datos destinadas a almacenar grandes cantidades de elementos (píxeles de una imagen / textura, entidades y componentes de un ECS, y vértices / bordes / polígonos de una malla).


fuente