Post Top Ad

Your Ad Spot

domingo, 28 de junio de 2020

Los problemas del estado mutable compartido y cómo evitarlos

Esta publicación de blog responde las siguientes preguntas:
  • ¿Qué es el estado mutable compartido?
  • ¿Por qué es problemático?
  • ¿Cómo se pueden evitar sus problemas?
Las secciones marcadas con "(avanzado)" son más profundas y se pueden omitir si desea leer esta publicación de blog más rápidamente.

Tabla de contenido:

¿Qué es el estado mutable compartido y por qué es problemático?  

El estado mutable compartido funciona de la siguiente manera:
  • Si dos o más partes pueden cambiar los mismos datos (variables, objetos, etc.) y
  • si sus vidas se superponen,
entonces existe el riesgo de que las modificaciones de una de las partes impidan que otras partes funcionen correctamente. Esto es un ejemplo:
function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
Aquí, hay dos partes independientes: función logElements()y función main()Este último quiere registrar una matriz antes y después de ordenarla. Sin embargo, utiliza logElements(), lo que borra su parámetro. Por lo tanto, main()registra una matriz vacía en la línea A.
En el resto de esta publicación, analizamos tres formas de evitar los problemas de estado mutable compartido:
  • Evitar compartir copiando datos
  • Evitar mutaciones actualizando de forma no destructiva
  • Prevenir mutaciones haciendo que los datos sean inmutables
En particular, volveremos al ejemplo que acabamos de ver y lo arreglaremos.

Evitar compartir copiando datos   

Antes de que podamos analizar cómo la copia evita compartir, debemos echar un vistazo a cómo se pueden copiar los datos en JavaScript.

Copia superficial vs. copia profunda   

Hay dos "profundidades" con las que se pueden copiar los datos:
  • La copia superficial solo copia las entradas de nivel superior de objetos y matrices. Los valores de entrada siguen siendo los mismos en original y copia.
  • La copia profunda también copia las entradas de los valores de las entradas, etc. Es decir, atraviesa el árbol completo cuya raíz es el valor a copiar y hace copias de todos los nodos.
Las siguientes secciones cubren ambos tipos de copia. Desafortunadamente, JavaScript solo tiene soporte incorporado para copia superficial. Si necesitamos una copia profunda, necesitamos implementarla nosotros mismos.

Copia superficial en JavaScript   

Veamos varias formas de copiar datos superficialmente.

Copiar objetos simples y matrices mediante la difusión   

Podemos extendernos a literales de objeto y a literales de matriz para hacer copias:
const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];
Por desgracia, la difusión tiene varias limitaciones:
  • El prototipo no se copia:
    class MyClass {}
    
    const original = new MyClass();
    assert.equal(MyClass.prototype.isPrototypeOf(original), true);
    
    const copy = {...original};
    assert.equal(MyClass.prototype.isPrototypeOf(copy), false);
    
  • Los objetos especiales como las expresiones regulares y las fechas tienen "ranuras internas" especiales que no se copian.
  • Solo se copian las propiedades propias (no heredadas). Dado cómo funcionan las cadenas de prototipos , este suele ser el mejor enfoque. Pero aún debes ser consciente de ello. En el siguiente ejemplo, la propiedad heredada .inheritedPropde originalno está disponible en copy, porque solo copiamos propiedades propias y no conservamos el prototipo.
    const proto = { inheritedProp: 'a' };
    const original = {__proto__: proto, ownProp: 'b' };
    assert.equal(original.inheritedProp, 'a');
    assert.equal(original.ownProp, 'b');
    
    const copy = {...original};
    assert.equal(copy.inheritedProp, undefined);
    assert.equal(copy.ownProp, 'b');
    
  • Solo se copian las propiedades enumerables. Por ejemplo, la propiedad propia .lengthde las instancias de matriz no es enumerable y no se copia:
    const arr = ['a', 'b'];
    assert.equal(arr.length, 2);
    assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
    
    const copy = {...arr};
    assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
    
  • Independientemente de los atributos de una propiedad, su copia siempre será una propiedad de datos que se puede escribir y configurar, por ejemplo:
    const original = Object.defineProperties({}, {
      prop: {
        value: 1,
        writable: false,
        configurable: false,
        enumerable: true,
      },
    });
    assert.deepEqual(original, {prop: 1});
    
    const copy = {...original};
    // Attributes `writable` and `configurable` of copy are different:
    assert.deepEqual(Object.getOwnPropertyDescriptors(copy), {
      prop: {
        value: 1,
        writable: true,
        configurable: true,
        enumerable: true,
      },
    });
    
    Eso significa que los captadores y establecedores no se copian fielmente: los atributos value(para propiedades de datos), get(para captadores) y set(para establecedores) son mutuamente excluyentes.
    const original = {
      get myGetter() { return 123 },
      set mySetter(x) {},
    };
    assert.deepEqual({...original}, {
      myGetter: 123, // not a getter anymore!
      mySetter: undefined,
    });
    
  • La copia es superficial: la copia tiene versiones nuevas de cada entrada de valor clave en el original, pero los valores del original no se copian ellos mismos. Por ejemplo:
    const original = {name: 'Jane', work: {employer: 'Acme'}};
    const copy = {...original};
    
    // Property .name is a copy
    copy.name = 'John';
    assert.deepEqual(original,
      {name: 'Jane', work: {employer: 'Acme'}});
    assert.deepEqual(copy,
      {name: 'John', work: {employer: 'Acme'}});
    
    // The value of .work is shared
    copy.work.employer = 'Spectre';
    assert.deepEqual(
      original, {name: 'Jane', work: {employer: 'Spectre'}});
    assert.deepEqual(
      copy, {name: 'John', work: {employer: 'Spectre'}});
    
Algunas de estas limitaciones pueden eliminarse, otras no pueden:
  • Podemos darle a la copia el mismo prototipo que el original durante la copia:
    class MyClass {}
    
    const original = new MyClass();
    
    const copy = {
      __proto__: Object.getPrototypeOf(original),
      ...original,
    };
    assert.equal(MyClass.prototype.isPrototypeOf(copy), true);
    
    Alternativamente, podemos configurar el prototipo de la copia después de su creación, a través de Object.setPrototypeOf().
  • No existe una manera simple de copiar genéricamente objetos especiales.
  • Como se mencionó, solo las propiedades propias que se copian son más una característica que una limitación.
  • Podemos usar Object.getOwnPropertyDescriptors()Object.defineProperties()copiar objetos ( cómo hacerlo se explica más adelante ):
    • Consideran todos los atributos (no solo value) y, por lo tanto, copian correctamente getters, setters, propiedades de solo lectura, etc.
    • Object.getOwnPropertyDescriptors() recupera propiedades enumerables y no enumerables.
  • Veremos la copia profunda más adelante en esta publicación.

Copia superficial a través de Object.assign()(avanzado)   

Object.assign()funciona principalmente como propagarse a objetos. Es decir, las siguientes dos formas de copia son en su mayoría equivalentes:
const copy1 = {...original};
const copy2 = Object.assign({}, original);
El uso de un método en lugar de la sintaxis tiene la ventaja de que se puede rellenar en motores JavaScript más antiguos a través de una biblioteca.
Object.assign()Sin embargo, no es completamente como propagarse. Difiere en un punto relativamente sutil: crea propiedades de manera diferente.
  • Object.assign()utiliza la asignación para crear las propiedades de la copia.
  • La difusión define nuevas propiedades en la copia.
Entre otras cosas, la asignación invoca establecedores propios y heredados, mientras que la definición no ( más información sobre la asignación frente a la definición ). Esta diferencia rara vez se nota. El siguiente código es un ejemplo, pero está ideado:
const original = {['__proto__']: null};
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
  Object.keys(copy1), ['__proto__']);

const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);

Copia superficial a través de Object.getOwnPropertyDescriptors()Object.defineProperties()(avanzado)   

JavaScript nos permite crear propiedades a través de descriptores de propiedad , objetos que especifican atributos de propiedad. Por ejemplo, a través de Object.defineProperties(), que ya hemos visto en acción. Si combinamos ese método con Object.getOwnPropertyDescriptors(), podemos copiar más fielmente:
function copyAllOwnProperties(original) {
  return Object.defineProperties(
    {}, Object.getOwnPropertyDescriptors(original));
}
Eso elimina dos limitaciones de copiar objetos mediante difusión.
Primero, todos los atributos de propiedades propias se copian correctamente. Por lo tanto, ahora podemos copiar captadores propios y definidores propios:
const original = {
  get myGetter() { return 123 },
  set mySetter(x) {},
};
assert.deepEqual(copyAllOwnProperties(original), original);
En segundo lugar, gracias a Object.getOwnPropertyDescriptors(), las propiedades no enumerables también se copian:
const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

Copia profunda en JavaScript   

Ahora es el momento de abordar la copia profunda. Primero, copiaremos en profundidad manualmente, luego examinaremos los enfoques genéricos.

Copia profunda manual mediante difusión anidada   

Si anidamos la propagación, obtenemos copias profundas:
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};

// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);

Hack: copia profunda genérica a través de JSON   

Este es un truco, pero, en caso de apuro, proporciona una solución rápida: para copiar en profundidad un objeto original, primero lo convertimos en una cadena JSON y analizamos esa cadena JSON:
function jsonDeepCopy(original) {
  return JSON.parse(JSON.stringify(original));
}
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = jsonDeepCopy(original);
assert.deepEqual(original, copy);
La desventaja significativa de este enfoque es que solo podemos copiar propiedades con claves y valores compatibles con JSON.
Algunas claves y valores no compatibles simplemente se ignoran:
assert.deepEqual(
  jsonDeepCopy({
    [Symbol('a')]: 'abc',
    b: function () {},
    c: undefined,
  }),
  {} // empty object
);
Otros causan excepciones:
assert.throws(
  () => jsonDeepCopy({a: 123n}),
  /^TypeError: Do not know how to serialize a BigInt$/);

Implementando copia profunda genérica   

La siguiente función genéricamente copia en profundidad un valor original:
function deepCopy(original) {
  if (Array.isArray(original)) {
    const copy = [];
    for (const [index, value] of original.entries()) {
      copy[index] = deepCopy(value);
    }
    return copy;
  } else if (typeof original === 'object' && original !== null) {
    const copy = {};
    for (const [key, value] of Object.entries(original)) {
      copy[key] = deepCopy(value);
    }
    return copy;
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}
La función maneja tres casos:
  • Si originales una matriz, creamos una nueva matriz y copiamos en profundidad los elementos originalen ella.
  • Si originales un objeto, usamos un enfoque similar.
  • Si originales un valor primitivo, no tenemos que hacer nada.
Probemos deepCopy():
const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);

// Are copy and original deeply equal?
assert.deepEqual(copy, original);

// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy     !== original);
assert.ok(copy.b   !== original.b);
assert.ok(copy.b.d !== original.b.d);
Tenga en cuenta que deepCopy()solo corrige un problema de difusión: la copia superficial. Todos los demás permanecen: los prototipos no se copian, los objetos especiales solo se copian parcialmente, las propiedades no enumerables se ignoran, la mayoría de los atributos de propiedad se ignoran.
Implementar la copia de forma completamente genérica es generalmente imposible: no todos los datos son un árbol, a veces no desea todas las propiedades, etc.
Una versión más concisa de deepCopy()  
Podemos hacer que nuestra implementación previa sea deepCopy()más concisa si usamos .map()Object.fromEntries():
function deepCopy(original) {
  if (Array.isArray(original)) {
    return original.map(elem => deepCopy(elem));
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original)
        .map(([k, v]) => [k, deepCopy(v)]));
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

Implementación de copia profunda en clases (avanzado)   

A menudo se usan dos técnicas para implementar la copia profunda para instancias de clases:
  • .clone() métodos
  • Copiar constructores
.clone()métodos   
Esta técnica introduce un método .clone()por clase cuyas instancias deben copiarse en profundidad. Devuelve una copia profunda de thisEl siguiente ejemplo muestra tres clases que se pueden clonar.
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  clone() {
    return new Point(this.x, this.y);
  }
}
class Color {
  constructor(name) {
    this.name = name;
  }
  clone() {
    return new Color(this.name);
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  clone() {
    return new ColorPoint(
      this.x, this.y, this.color.clone()); // (A)
  }
}
La línea A demuestra un aspecto importante de esta técnica: los valores de propiedad de instancia compuesta también deben clonarse, de forma recursiva.
Métodos de fábrica estáticos   
Un constructor de copia es un constructor que usa otra instancia de la clase actual para configurar la instancia actual. Los constructores de copia son populares en lenguajes estáticos como C ++ y Java, donde puede proporcionar múltiples versiones de un constructor mediante sobrecarga estática ( estático, lo que significa que ocurre en tiempo de compilación).
En JavaScript, podría hacer algo como esto (pero no es muy elegante):
class Point {
  constructor(...args) {
    if (args[0] instanceof Point) {
      // Copy constructor
      const [other] = args;
      this.x = other.x;
      this.y = other.y;
    } else {
      const [x, y] = args;
      this.x = x;
      this.y = y;
    }
  }
}
Así es como usarías esta clase:
const original = new Point(-1, 4);
const copy = new Point(original);
assert.deepEqual(copy, original);
En cambio, los métodos de fábrica estáticos funcionan mejor en JavaScript ( estático, lo que significa que son métodos de clase).
En el siguiente ejemplo, las tres clases PointColorColorPointcada uno tiene un método de fábrica estática .from():
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  static from(other) {
    return new Point(other.x, other.y);
  }
}
class Color {
  constructor(name) {
    this.name = name;
  }
  static from(other) {
    return new Color(other.name);
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  static from(other) {
    return new ColorPoint(
      other.x, other.y, Color.from(other.color)); // (A)
  }
}
En la línea A, utilizamos una vez más la copia recursiva.
Así es como ColorPoint.from()funciona:
const original = new ColorPoint(-1, 4, new Color('red'));
const copy = ColorPoint.from(original);
assert.deepEqual(copy, original);

¿Cómo ayuda la copia con el estado mutable compartido?  

Mientras solo leamos del estado compartido, no tenemos ningún problema. Antes de modificarlo, necesitamos "des-compartirlo", copiándolo (tan profundamente como sea necesario).
Copia defensiva es una técnica para copiar siempre cuando los problemas podrían surgir. Su objetivo es mantener segura la entidad actual (función, clase, etc.):
  • Entrada: la copia (potencialmente) de los datos compartidos que se nos pasan, nos permite usar esos datos sin ser molestados por una entidad externa.
  • Salida: copiar datos internos antes de exponerlos a una parte externa, significa que esa parte no puede interrumpir nuestra actividad interna.
Tenga en cuenta que estas medidas nos protegen de otras partes, pero también nos protegen a otras partes.
Las siguientes secciones ilustran ambos tipos de copia defensiva.

Copiando entrada compartida   

Recuerde que en el ejemplo motivador al comienzo de esta publicación, nos metimos en problemas porque logElements()modificó su parámetro arr:
function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}
Agreguemos copia defensiva a esta función:
function logElements(arr) {
  arr = [...arr]; // defensive copy
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}
Ahora logElements()ya no causa problemas, si se llama dentro main():
function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'

Copiando datos internos expuestos   

Comencemos con una clase StringBuilderque no copia datos internos que expone (línea A):
class StringBuilder {
  constructor() {
    this._data = [];
  }
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // We expose internals without copying them:
    return this._data; // (A)
  }
  toString() {
    return this._data.join('');
  }
}
Mientras .getParts()no se use, todo funciona bien:
const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');
Sin embargo, si .getParts()se cambia el resultado de (línea A), entonces StringBuilderdeja de funcionar correctamente:
const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK
La solución es copiar el interno a la ._datadefensiva antes de que quede expuesto (línea A):
class StringBuilder {
  constructor() {
    this._data = [];
  }
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // Copy defensively
    return [...this._data]; // (A)
  }
  toString() {
    return this._data.join('');
  }
}
Ahora cambiar el resultado de .getParts()ya no interfiere con la operación de sb:
const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK

Evitar mutaciones actualizando de forma no destructiva   

Primero exploraremos la diferencia entre actualizar datos de manera destructiva y no destructiva. Luego aprenderemos cómo las actualizaciones no destructivas evitan las mutaciones.

Antecedentes: actualizaciones destructivas frente a actualizaciones no destructivas   

Podemos distinguir dos formas diferentes de actualizar los datos:
  • Una actualización destructiva de datos muta los datos para que tenga la forma deseada.
  • Una actualización no destructiva de datos crea una copia de los datos que tiene la forma deseada.
La última forma es similar a hacer primero una copia y luego cambiarla destructivamente, pero hace ambas cosas al mismo tiempo.

Ejemplos: actualizar un objeto de forma destructiva y no destructiva   

Así es como configuramos destructivamente la propiedad .cityde un objeto:
const obj = {city: 'Berlin', country: 'Germany'};
const key = 'city';
obj[key] = 'Munich';
assert.deepEqual(obj, {city: 'Munich', country: 'Germany'});
La siguiente función cambia no destructivamente las propiedades:
function setObjectNonDestructively(obj, key, value) {
  const updatedObj = {};
  for (const [k, v] of Object.entries(obj)) {
    updatedObj[k] = (k === key ? value : v);
  }
  return updatedObj;
}
Se usa de la siguiente manera:
const obj = {city: 'Berlin', country: 'Germany'};
const updatedObj = setObjectNonDestructively(obj, 'city', 'Munich');
assert.deepEqual(updatedObj, {city: 'Munich', country: 'Germany'});
assert.deepEqual(obj, {city: 'Berlin', country: 'Germany'});
La difusión hace setObjectNonDestructively()más conciso:
function setObjectNonDestructively(obj, key, value) {
  return {...obj, [key]: value};
}
Nota: Ambas versiones de setObjectNonDestructively()actualización son superficiales.

Ejemplos: actualizar una matriz de forma destructiva y no destructiva   

Así es como configuramos destructivamente un elemento de una matriz:
const original = ['a', 'b', 'c', 'd', 'e'];
original[2] = 'x';
assert.deepEqual(original, ['a', 'b', 'x', 'd', 'e']);
La actualización no destructiva de una matriz es más complicada que la actualización no destructiva de un objeto.
function setArrayNonDestructively(arr, index, value) {
  const updatedArr = [];
  for (const [i, v] of arr.entries()) {
    updatedArr.push(i === index ? value : v);
  }
  return updatedArr;
}

const arr = ['a', 'b', 'c', 'd', 'e'];
const updatedArr = setArrayNonDestructively(arr, 2, 'x');
assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);
.slice()y difundir hacer setArrayNonDestructively()más conciso:
function setArrayNonDestructively(arr, index, value) {
  return [
  ...arr.slice(0, index), value, ...arr.slice(index+1)]
}
Nota: Ambas versiones de setArrayNonDestructively()actualización son superficiales.

Actualización profunda manual   

Hasta ahora, solo hemos actualizado datos superficialmente. Abordemos la actualización profunda. El siguiente código muestra cómo hacerlo manualmente. Estamos cambiando de nombre y empleador.
const original = {name: 'Jane', work: {employer: 'Acme'}};
const updatedOriginal = {
  ...original,
  name: 'John',
  work: {
    ...original.work,
    employer: 'Spectre'
  },
};

assert.deepEqual(
  original, {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(
  updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});

Implementando actualización profunda genérica   

La siguiente función implementa una actualización profunda genérica.
function deepUpdate(original, keys, value) {
  if (keys.length === 0) {
    return value;
  }
  const currentKey = keys[0];
  if (Array.isArray(original)) {
    return original.map(
      (v, index) => index === currentKey
        ? deepUpdate(v, keys.slice(1), value) // (A)
        : v); // (B)
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original).map(
        (keyValuePair) => {
          const [k,v] = keyValuePair;
          if (k === currentKey) {
            return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
          } else {
            return keyValuePair; // (D)
          }
        }));
  } else {
    // Primitive value
    return original;
  }
}
Si vemos valuecomo la raíz de un árbol que estamos actualizando, deepUpdate()solo cambia profundamente una sola rama (líneas A y C). Todas las demás ramas se copian superficialmente (línea B y D).
Así es deepUpdate()como se ve el uso :
const original = {name: 'Jane', work: {employer: 'Acme'}};

const copy = deepUpdate(original, ['work', 'employer'], 'Spectre');
assert.deepEqual(copy, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(original, {name: 'Jane', work: {employer: 'Acme'}});

¿Cómo ayuda la actualización no destructiva con el estado mutable compartido?  

Con una actualización no destructiva, el intercambio de datos deja de ser problemático, porque nunca mutamos los datos compartidos. (Obviamente, esto solo funciona si todas las partes hacen esto).
Curiosamente, copiar datos se vuelve trivialmente simple:
const original = {city: 'Berlin', country: 'Germany'};
const copy = original;
La copia real de originalocurre solo cuando es necesario y estamos haciendo cambios no destructivos.

Prevención de mutaciones al hacer que los datos sean inmutables   

Podemos evitar mutaciones de datos compartidos haciendo que esos datos sean inmutables. A continuación, examinaremos cómo JavaScript admite la inmutabilidad. Luego, discutiremos cómo los datos inmutables ayudan con el estado mutable compartido.

Antecedentes: inmutabilidad en JavaScript   

JavaScript tiene tres niveles de protección de objetos:
  • La prevención de extensiones hace que sea imposible agregar nuevas propiedades a un objeto. Sin embargo, aún puede eliminar y cambiar propiedades.
    • Método: Object.preventExtensions(obj)
  • El sellado evita extensiones y hace que todas las propiedades sean inconfigurables (aproximadamente: ya no se puede cambiar el funcionamiento de una propiedad).
    • Método: Object.seal(obj)
  • La congelación sella un objeto después de hacer que todas sus propiedades no se puedan escribir. Es decir, el objeto no es extensible, todas las propiedades son de solo lectura y no hay forma de cambiar eso.
    • Método: Object.freeze(obj)
Para obtener más información, consulte "Hablar JavaScript" .
Dado que queremos que nuestros objetos sean completamente inmutables, solo los usamos Object.freeze()en esta publicación de blog.

La congelación es poco profunda   

Object.freeze(obj)solo se congela objy sus propiedades. No congela los valores de esas propiedades, por ejemplo:
const teacher = {
  name: 'Edna Krabappel',
  students: ['Bart'],
};
Object.freeze(teacher);

assert.throws(
  () => teacher.name = 'Elizabeth Hoover',
  /^TypeError: Cannot assign to read only property 'name'/);

teacher.students.push('Lisa');
assert.deepEqual(
  teacher, {
    name: 'Edna Krabappel',
    students: ['Bart', 'Lisa'],
  });

Implementación de congelación profunda   

Si queremos un congelamiento profundo, debemos implementarlo nosotros mismos:
function deepFreeze(value) {
  if (Array.isArray(value)) {
    for (const element of value) {
      deepFreeze(element);
    }
    Object.freeze(value);
  } else if (typeof value === 'object' && value !== null) {
    for (const v of Object.values(value)) {
      deepFreeze(v);
    }
    Object.freeze(value);
  } else {
    // Nothing to do: primitive values are already immutable
  } 
  return value;
}
Revisando el ejemplo de la sección anterior, podemos verificar si deepFreeze()realmente se congela profundamente:
const teacher = {
  name: 'Edna Krabappel',
  students: ['Bart'],
};
deepFreeze(teacher);

assert.throws(
  () => teacher.name = 'Elizabeth Hoover',
  /^TypeError: Cannot assign to read only property 'name'/);

assert.throws(
  () => teacher.students.push('Lisa'),
  /^TypeError: Cannot add property 1, object is not extensible$/);

Contenedores inmutables (avanzado)   

Un contenedor inmutable envuelve una colección mutable y proporciona la misma API, pero sin operaciones destructivas. Ahora tenemos dos interfaces para la misma colección: una es mutable, la otra es inmutable. Esto es útil cuando tenemos datos internos mutables que queremos exponer de forma segura.
Las siguientes dos secciones muestran envoltorios para mapas y matrices. Ambos tienen las siguientes limitaciones:
  • Son bocetos. Se necesita más trabajo para que sean adecuados para el uso práctico: mejores controles, soporte para más métodos, etc.
  • Trabajan superficialmente.

Un contenedor inmutable para Maps   

La clase ImmutableMapWrapperproduce envoltorios para mapas:
class ImmutableMapWrapper {
  constructor(map) {
    this._self = map;
  }
}

// Only forward non-destructive methods to the wrapped Map:
for (const methodName of ['get', 'has', 'keys', 'size']) {
  ImmutableMapWrapper.prototype[methodName] = function (...args) {
    return this._self[methodName](...args);
  }
}
Esta es la clase en acción:
const map = new Map([[false, 'no'], [true, 'yes']]);
const wrapped = new ImmutableMapWrapper(map);

// Non-destructive operations work as usual:
assert.equal(
  wrapped.get(true), 'yes');
assert.equal(
  wrapped.has(false), true);
assert.deepEqual(
  [...wrapped.keys()], [false, true]);

// Destructive operations are not available:
assert.throws(
  () => wrapped.set(false, 'never!'),
  /^TypeError: wrapped.set is not a function$/);
assert.throws(
  () => wrapped.clear(),
  /^TypeError: wrapped.clear is not a function$/);

Un contenedor inmutable para matrices   

Para una matriz arr, el ajuste normal no es suficiente porque necesitamos interceptar no solo llamadas a métodos, sino también accesos a propiedades como arr[1] = trueLos proxies de JavaScript nos permiten hacer esto:
const RE_INDEX_PROP_KEY = /^[0-9]+$/;
const ALLOWED_PROPERTIES = new Set([
  'length', 'constructor', 'slice', 'concat']);

function wrapArrayImmutably(arr) {
  const handler = {
    get(target, propKey, receiver) {
      // We assume that propKey is a string (not a symbol)
      if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
        || ALLOWED_PROPERTIES.has(propKey)) {
          return Reflect.get(target, propKey, receiver);
      }
      throw new TypeError(`Property "${propKey}" can’t be accessed`);
    },
    set(target, propKey, value, receiver) {
      throw new TypeError('Setting is not allowed');
    },
    deleteProperty(target, propKey) {
      throw new TypeError('Deleting is not allowed');
    },
  };
  return new Proxy(arr, handler);
}
Vamos a envolver una matriz:
const arr = ['a', 'b', 'c'];
const wrapped = wrapArrayImmutably(arr);

// Non-destructive operations are allowed:
assert.deepEqual(
  wrapped.slice(1), ['b', 'c']);
assert.equal(
  wrapped[1], 'b');

// Destructive operations are not allowed:
assert.throws(
  () => wrapped[1] = 'x',
  /^TypeError: Setting is not allowed$/);
assert.throws(
  () => wrapped.shift(),
  /^TypeError: Property "shift" can’t be accessed$/);

¿Cómo ayuda la inmutabilidad con el estado mutable compartido?  

Si los datos son inmutables, se pueden compartir sin ningún riesgo. En particular, no hay necesidad de copiar a la defensiva.
La actualización no destructiva complementa los datos inmutables y los hace más versátiles que los datos mutables, pero sin los riesgos asociados.

Bibliotecas para evitar el estado mutable compartido   

Hay varias bibliotecas disponibles para JavaScript que admiten datos inmutables con actualizaciones no destructivas. Dos populares son:
  • Immutable.js proporciona inmutables (versiones de) las estructuras de datos tales como ListMapSet, y Stack.
  • Immer también admite la inmutabilidad y la actualización no destructiva, pero para objetos simples y matrices.
Estas bibliotecas se describen con más detalle en las siguientes dos secciones.

Immutable.js   

En su repositorio, la biblioteca Immutable.js se describe como:
Colecciones de datos persistentes inmutables para JavaScript que aumentan la eficiencia y la simplicidad.
Immutable.js proporciona estructuras de datos inmutables como:
  • List
  • Map(que es diferente del JavaScript incorporado Map)
  • Set(que es diferente del JavaScript incorporado Set)
  • Stack
  • Etc.
En el siguiente ejemplo, usamos un inmutable Map:
import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
  [false, 'no'],
  [true, 'yes'],
]);

const map1 = map0.set(true, 'maybe'); // (A)
assert.ok(map1 !== map0); // (B)
assert.equal(map1.equals(map0), false);

const map2 = map1.set(true, 'yes'); // (C)
assert.ok(map2 !== map1);
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (D)
Explicaciones:
  • En la línea A creamos una versión nueva y diferente map1de map0donde truese asigna 'maybe'.
  • En la línea B, verificamos que el cambio no fue destructivo.
  • En la línea C, actualizamos map1y deshacemos el cambio realizado en la línea A.
  • En la línea D, utilizamos el .equals()método incorporado de Immutable para verificar que realmente deshacemos el cambio.

Immer   

En su repositorio, la biblioteca Immer se describe como:
Crea el siguiente estado inmutable mutando el actual.
Immer ayuda con la actualización no destructiva (potencialmente anidada) de objetos y matrices simples. Es decir, no hay estructuras de datos especiales involucradas.
Así es como se ve usar Immer:
import {produce} from 'immer/dist/immer.module.js';

const people = [
  {name: 'Jane', work: {employer: 'Acme'}},
];

const modifiedPeople = produce(people, (draft) => {
  draft[0].work.employer = 'Cyberdyne';
  draft.push({name: 'John', work: {employer: 'Spectre'}});
});

assert.deepEqual(modifiedPeople, [
  {name: 'Jane', work: {employer: 'Cyberdyne'}},
  {name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
  {name: 'Jane', work: {employer: 'Acme'}},
]);
Los datos originales se almacenan en peopleproduce()nos provee de una variable draftFingimos que esta variable es peopley utilizamos operaciones con las que normalmente haríamos cambios destructivos. Immer intercepta estas operaciones. En lugar de mutar draft, cambia de forma no destructiva peopleEl resultado es referenciado por modifiedPeopleComo beneficio adicional, es profundamente inmutable.

Agradecimientos   

  • Ron Korvig me recordó que usara métodos de fábrica estáticos y constructores no sobrecargados para copiar en profundidad en JavaScript.

Lectura adicional   

  • Extensión:
    • Sección "Difundir en literales de objeto" en "JavaScript para programadores impacientes"
    • Sección "Difundir en literales de matriz" en "JavaScript para programadores impacientes"
  • Atributos de propiedad:
    • Sección "Atributos de propiedad y descriptores de propiedad" en "Hablando de JavaScript"
    • Sección "Protección de objetos" en "Hablar JavaScript"
  • Cadenas prototipo:
    • Sección "Cadenas de prototipos" en "JavaScript para programadores impacientes"
    • Sección "Propiedades: definición frente a asignación" en "Hablar JavaScript"
  • Capítulo "Metaprogramación con proxies" en "Explorando ES6"

No hay comentarios.:

Publicar un comentario

Dejanos tu comentario para seguir mejorando!

outbrain

Páginas