JavaScript и ужасы мутаций +15


Мутация — это изменение. Изменение формы или изменение сути. То, что подвержено мутациям, может меняться. Для того чтобы лучше осознать природу мутации — подумайте о героях фильма «Люди Икс». Они могли внезапно получать потрясающие возможности. Однако проблема заключается в том, что неизвестно, когда именно эти возможности проявятся. Представьте себе, что ваш товарищ ни с того ни с сего посинел и оброс шерстью. Страшновато, правда? В JavaScript существуют те же проблемы. Если ваш код подвержен мутациям, это значит, что вы можете, совершенно неожиданно, что-то изменить и поломать.



Объекты в JavaScript и мутация


В JavaScript-объекты можно добавлять свойства. Когда это делают после создания экземпляра объекта, объект необратимо изменяется. Он мутирует, как один из персонажей «Людей Икс».

В следующем примере константа egg, объект, мутирует после того, как к ней добавляют свойство isBroken. Такие объекты (вроде egg) мы называем мутабельными (то есть, имеющими возможность мутировать, изменяться).

const egg = { name: "Humpty Dumpty" };
egg.isBroken = false;

console.log(egg);
// {
//   name: "Humpty Dumpty",
//   isBroken: false
// }

Мутации — вполне обычное явление в JavaScript. Столкнуться с ними можно буквально всегда и везде.

Об опасности мутаций


Предположим, создана константа с именем newEgg, в которую записан объект egg. Затем понадобилось изменить свойство name у newEgg:

const egg = { name: "Humpty Dumpty" };

const newEgg = egg;
newEgg.name = "Errr ... Not Humpty Dumpty";

Когда мы меняем newEgg (подвергаем объект мутации), автоматически меняется и egg. Вы знали об этом?

console.log(egg);
// {
//   name: "Errr ... Not Humpty Dumpty"
// }

Вышеприведённый пример иллюстрирует опасность мутаций. Она сводится к тому, что когда вы меняете что-то в коде, то нечто, находящееся где-то в другом месте, тоже может поменяться, причём так, что вы об этом и знать не будете. В результате — ошибки, которые сложно находить и исправлять.

Все эти странности являются следствием того, что объекты в JavaScript передаются по ссылке.

Объекты в JavaScript и ссылки на них


Для того чтобы осознать смысл утверждения «объекты передаются по ссылке», сначала нужно понять то, что у каждого объекта в JavaScript есть уникальный идентификатор. Когда вы назначаете объект переменной, вы связываете переменную с идентификатором этого объекта (то есть, переменная теперь ссылается на объект) вместо того, чтобы записывать в переменную значение объекта, копировать его. Именно поэтому, сравнивая два разных объекта, даже содержащих одни и те же значения (или не содержащих их вовсе), мы получаем false.

console.log({} === {}); // false

Когда, в примере выше, константа egg была присвоена константе newEgg, в newEgg была записана ссылка на тот же объект, на который ссылалась константа egg. Так как egg и newEgg ссылаются на один и тот же объект, то, когда меняется newEgg, egg меняется автоматически.

console.log(egg === newEgg); // true

К сожалению, в ситуациях, схожих с описанной, обычно не нужно, чтобы то, что записано в одну переменную, менялось при воздействии на другую, так как это приводит к неправильному поведению кода, которое проявляется тогда, когда этого ждут меньше всего. Итак, как же предотвратить мутации объектов? Прежде чем найти ответ на этот вопрос, хорошо бы сначала узнать, что в JS является иммутабельным, то есть — неизменным.

Иммутабельные примитивы


В JavaScript примитивы (речь идёт о типах данных String, Number, Boolean, Null, Undefined, и Symbol) иммутабельны. То есть, нельзя изменить структуру примитива, нельзя добавить к нему свойства или методы. Например, при попытке добавить к примитиву новое свойство не произойдёт абсолютно ничего.

const egg = "Humpty Dumpty";
egg.isBroken = false;

console.log(egg); // Humpty Dumpty
console.log(egg.isBroken); // undefined

Ключевое слово const и иммутабельность


Многие думают, что переменные (константы), объявленные с использованием ключевого слова const, иммутабельны. Однако, это не так.

Использование ключевого слова const не делает то, что записано в константу, иммутабельным. Оно лишь не даёт назначить константе новое значение.

const myName = "Zell";
myName = "Triceratops";
// ERROR

Когда, с использованием ключевого слова const, определяют объект, его внутреннюю структуру вполне можно менять. В примере с объектом egg, даже хотя egg — константа, созданная с использованием ключевого слова const, от мутации это объект не защищает.

const egg = { name: "Humpty Dumpty" };
egg.isBroken = false;

console.log(egg);
// {
//   name: "Humpty Dumpty",
//   isBroken: false
// }

Предотвращение мутаций объектов


Для того, чтобы предотвращать мутации объектов, можно, при работе с ними, использовать метод Object.assign, реализующий операцию создания новых объектов путём комбинирования существующих объектов с присвоением результирующему объекту их свойств.

?Метод Object.assign


Конструкция Object.assign позволяет комбинировать два объекта (или большее число объектов), получая на выходе один новый объект. Пользоваться ей можно так:

const newObject = Object.assign(object1, object2, object3, object4);

Константа newObject будет содержать свойства из всех объектов, переданных Object.assign.

const papayaBlender = { canBlendPapaya: true };
const mangoBlender = { canBlendMango: true };

const fruitBlender = Object.assign(papayaBlender, mangoBlender);

console.log(fruitBlender);
// {
//   canBlendPapaya: true,
//   canBlendMango: true
// }

Если обнаружены два конфликтующих свойства, свойство объекта, который расположен правее в списке аргументов Object.assign, перезаписывает свойство объекта, расположенного в списке левее.

const smallCupWithEar = {
  volume: 300,
  hasEar: true
};

const largeCup = { volume: 500 };
// В этом случае свойство volume будет перезаписано, вместо 300 тут будет 500
const myIdealCup = Object.assign(smallCupWithEar, largeCup);

console.log(myIdealCup);
// {
//   volume: 500,
//   hasEar: true
// }

Однако, будьте внимательны! Когда вы комбинируете два объекта с помощью Object.assign, первый объект в списке аргументов подвержен мутациям. Другие — нет.

console.log(smallCupWithEar);
// {
//   volume: 500,
//   hasEar: true
// }

console.log(largeCup);
// {
//   volume: 500
// }

?Решение проблемы мутации при использовании Object.assign


В качестве первого объекта Object.assign можно передать новый объект для того, чтобы предотвратить мутацию существующих объектов. Однако, первый объект (пустой) всё ещё подвергается изменениям, но тут нет ничего страшного, так как мутация больше ничего важного не затрагивает.

const smallCupWithEar = {
  volume: 300,
  hasEar: true
};

const largeCup = {
  volume: 500
};

// Использование нового объекта в качестве первого аргумента
const myIdealCup = Object.assign({}, smallCupWithEar, largeCup);

Новый объект после выполнения этой операции можно менять как угодно. Это не затронет предыдущие объекты.

myIdealCup.picture = "Mickey Mouse";
console.log(myIdealCup);
// {
//   volume: 500,
//   hasEar: true,
//   picture: "Mickey Mouse"
// }

// smallCupWithEar не мутирует
console.log(smallCupWithEar); // { volume: 300, hasEar: true }

// largeCup не мутирует
console.log(largeCup); // { volume: 500 }

?Object.assign и ссылки на объекты-свойства


Ещё одна проблема с Object.assign заключается в том, что он выполняет поверхностное слияние объектов (shallow merge) — он копирует свойства напрямую из одного объекта в другой. При этом он копирует и ссылки на объекты, являющиеся свойствами обрабатываемых объектов.

Рассмотрим это на примере.

Предположим, вы купили новую звуковую систему. Вы можете управлять её питанием, устанавливать громкость, уровень баса и другие параметры. Вот как выглядит стандартная конфигурация системы.

const defaultSettings = {
  power: true,
  soundSettings: {
    volume: 50,
    bass: 20,
    // другие параметры
  }
};

Некоторые из ваших друзей любят громкую музыку, поэтому вы решили сделать предустановку, которая гарантированно поставит на уши весь дом.

const loudPreset = {
  soundSettings: {
    volume: 100
  }
};

Затем вы приглашаете друзей на вечеринку. Для того чтобы привести систему в рабочее состояние и при этом воспользоваться и стандартными настройками, и теми, где громкость выкручена на максимум, вы пытаетесь скомбинировать defaultSettings и loudPreset.

const partyPreset = Object.assign({}, defaultSettings, loudPreset);

Однако, включив музыку, вы понимаете, что система с partyPreset звучит странно. Громкость хороша, но совсем нет басов. Когда вы исследуете partyPreset, вы с удивлением обнаруживаете, что настроек баса тут нет!

console.log(partyPreset);
// {
//   power: true,
//   soundSettings: {
//     volume: 100
//   }
// }

Это происходит из-за того, что JavaScript копирует объект-свойство soundSettings по ссылке. Так как и у defaultSettings, и у loudPreset есть объект soundSettings, тот объект, который стоит правее в аргументах Object.assign, оказывается скопированным в новый объект.

Если вы измените partyPreset, loudPreset мутирует соответствующим образом — как свидетельство того, что в него была скопирована ссылка на soundSettings из loudPreset.

partyPreset.soundSettings.bass = 50;

console.log(loudPreset);
// {
//   soundSettings: {
//     volume: 100,
//     bass: 50
//   }
// }

Так как Object.assign выполняет поверхностное слияние объектов, в подобных ситуациях, когда новый объект является комбинацией объектов, содержащих объекты-свойства, нужно использовать что-то другое. Что? Например — библиотеку assignment.

?Библиотека assignment


Assignment — это маленькая библиотека, которую создал Николя Бевакуа из Pony Foo (ценного источника информации по JS). Она помогает выполнять глубокое слияние объектов (deep merge) и при этом не беспокоиться о мутациях. Использование assignment выглядит так же, как и работа с Object.assign, за исключением того, что тут используется другое имя метода.

// Выполнение глубокого слияния объектов с помощью assignment
const partyPreset = assignment({}, defaultSettings, loudPreset);

console.log(partyPreset);
// {
//   power: true,
//   soundSettings: {
//     volume: 100,
//     bass: 20
//   }
// }

Библиотека выполняет копирование значений всех объектов, вложенных в другие объекты, в новый объект, что предохраняет существующие объекты от мутации.

Если вы попытаетесь теперь изменить любое свойство в partyPreset.soundSettings, вы обнаружите, что loudPreset не меняется.

partyPreset.soundSettings.bass = 50;

// loudPreset не мутирует
console.log(loudPreset);
// {
//   soundSettings {
//     volume: 100
//   }
// }

Библиотека assignment — это лишь один из многих инструментов, позволяющих выполнять глубокое слияние объектов. Другие библиотеки, включая lodash.assign и merge-options, тоже могут вам в этом помочь. Можете спокойно выбрать ту, что вам больше понравится.

Всегда ли необходимо использовать глубокое слияние вместо Object.assign?


Так как теперь вы знаете, как защитить объекты от мутаций, вы можете осмысленно использовать Object.assign. Нет ничего плохого в этом стандартном методе, если знать, как пользоваться им правильно.

Однако, если вам нужно работать с объектами, которые имеют вложенные свойства, всегда старайтесь использовать глубокое слияние объектов вместо Object.assign.

Обеспечение иммутабельности объектов


Хотя те методы, о которых мы говорили выше, могут помочь защитить объекты от мутаций, они не гарантируют иммутабельность созданных с их помощью объектов. Если вы сделаете ошибку и используете Object.assign при работе с объектом, имеющим вложенные свойства-объекты, позже у вас могут быть серьёзные неприятности.

Для того чтобы от этого защититься, стоит обеспечить гарантию того, что объект не будет мутировать вообще. Для этого можно использовать библиотеку наподобие ImmutableJS. Эта библиотека выдаёт ошибку при попытке изменения обработанного с её помощью объекта.

Кроме того, можно использовать метод Object.freeze и библиотеку deep-freeze. Эти два средства не выдают ошибок, но и не позволяют объектам мутировать.

Метод Object.freeze и библиотека deep-freeze


Метод Object.freeze защищает собственные свойства объекта от изменений.

const egg = {
  name: "Humpty Dumpty",
  isBroken: false
};

// "Заморозим" объект egg
Object.freeze(egg);

// Попытка изменения свойства потерпит неудачу без сообщений об ошибках
egg.isBroken = true;

console.log(egg); // { name: "Humpty Dumpty", isBroken: false }

Однако этот метод не поможет, если попытаться изменить объект, являющийся свойством «замороженного» объекта, вроде defaultSettings.soundSettings.base.

const defaultSettings = {
  power: true,
  soundSettings: {
    volume: 50,
    bass: 20
  }
};
Object.freeze(defaultSettings);
defaultSettings.soundSettings.bass = 100;

// Несмотря на это soundSettings мутирует
console.log(defaultSettings);
// {
//   power: true,
//   soundSettings: {
//     volume: 50,
//     bass: 100
//   }
// }

Для предотвращения мутации объектов-свойств, можно использовать библиотеку deep-freeze, которая рекурсивно вызывает Object.freeze для всех свойств «замораживаемого» объекта, являющихся объектами.

const defaultSettings = {
  power: true,
  soundSettings: {
    volume: 50,
    bass: 20
  }
};

// Выполнение "глубокой заморозки" (после подключения библиотеки deep-freeze)
deepFreeze(defaultSettings);

// Попытка изменения вложенных свойств не удастся, сообщений об ошибках не возникнет
defaultSettings.soundSettings.bass = 100;

// soundSettings больше не мутирует
console.log(defaultSettings);
// {
//   power: true,
//   soundSettings: {
//     volume: 50,
//     bass: 20
//   }
// }

О перезаписи значений и мутации


Не стоит путать запись в переменные и в свойства объектов новых значений с мутацией.
Когда в переменную записывают новое значение, фактически, изменяют то, на что она указывает. В следующем примере значение переменной a меняется с 11 на 100.

let a = 11;
a = 100;

При мутации же меняется сам объект. Ссылка на объект, записанная в переменную или константу, остаётся той же самой.

const egg = { name: "Humpty Dumpty" };
egg.isBroken = false;

Итоги


Мутации опасны потому, что они могут нарушить работу кода, причём, сделать это совершенно незаметно и непредсказуемо. Если даже вы подозреваете, что причина проблемы в мутации, поиск проблемного места — та ещё задачка. Поэтому лучший способ защитить код от неприятных неожиданностей — это обеспечить, с момента создания объектов, их защиту от мутаций.

Для того, чтобы защитить объекты от мутаций, можно использовать библиотеки вроде ImmutableJS и Mori.js, или применять стандартные методы JS Object.assign и Object.freeze.

Обратите внимание на то, что методы Object.assign и Object.freeze могут защитить от изменений только собственные свойства объектов. Если нужно защитить от мутаций и свойства, которые сами являются объектами, понадобятся библиотеки вроде assignment или deep-freeze.

Уважаемые читатели! Сталкивались ли вы с неожиданными ошибками в JS-приложениях, вызванными мутациями объектов?

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (35):

  1. vasIvas
    /#10624960

    Хватит уже жути нагонять. Если убрать мутации и отменить ссылки на объекты, создавать приложения будет в сотни раз сложнее.

    • zxxc
      /#10625074

      const egg = { name: "Humpty Dumpty" };
      egg.isBroken = false;

      Такой подход в целом и общем делает код медленнее и часто может приводить к неожиданным последствиям в будущем
      Избегая такого код разработка станет дешевле в долгосрочной перспективе

      • vasIvas
        /#10625148

        Нужно добавить что такой код пишут только те, кому ураган, на память, в голове инородный предмет оставил. Ну или совсем новичок в программировании.

        • vanxant
          /#10625236

          Или авторы плагинов

          • vasIvas
            /#10625402

            Тогда стоит добавить что авторов плагинов для плохо продуманных библиотек.
            Зачем делать объекты динамическими, когда можно создать основу конфигурации в виде класса, наделив его базовым состояние, а авторам плагинов позволить его расширять?

            • vanxant
              /#10625434

              Думать сейчас некогда. Херак херак и в продакшн Везде скрам cd аджайл

  2. Mycolaos
    /#10624994

    Когда мы меняем newEgg (подвергаем объект мутации), автоматически меняется и egg. Вы знали об этом?

    Я думал, каждый кто изучает джаваскрипт это узнает в первую очередь. Не?

  3. dmitry_pacification
    /#10625146

    Что если использовать… (spread) оператор? В Redux рекомендуют изменять данные в редьюсерах с помощью Object.assign или этого оператора. Он иммутабельный или нет?

  4. Farxial2
    /#10625208

    Или можно просто помнить о том, что объекты в JavaScript передаются по ссылке.
    Вообще, на мой взгляд, это лучший подход в данной области. Представьте, что будет, если веб-разработчики, в целях лучшей стабильности, будут копировать все объекты. Никто не говорит о том, что все будут следить за тем, что копировать, а что нет, т.к. проще получить «по шапке» за некорректную работу программы, чем за её прожорливость. А ведь это всего лишь сценарий на веб-странице, которых у пользователя может быть открыт не один десяток. W3C мог бы создать нативные методы глубокого копирования, если бы счёл нужным.
    [UPD] Упс, я забыл о рекурсиях и объектах типа элементов DOM. > +2 сложности

    • mayorovp
      /#10625424

      Фразу "объекты в JavaScript передаются по ссылке" способен произнести только тот программист, который никогда не работал с другими языками, где и правда существует передача по ссылке...

      • Zenitchik
        /#10625472

        Вы про передачу аргументов по ссылке?
        Да, согласен, в функцию даже ссылка на объект передаётся по значению (значением переменной объектного типа является ссылка).
        А как правильно сформулировать процитированную мысль?

        • mayorovp
          /#10625498

          «Объекты относятся к ссылочному типу данных» или как-то так. Ну или «свойства объектов-параметров доступны по ссылке».

  5. inoyakaigor
    /#10625220

    del

  6. Lain_13
    /#10625580

    Боже, эта любовь к зависимостям…
    deepFreeze:


    const deepFreeze = o => {
        Object.freeze(o);
        return Object.getOwnPropertyNames(o).every(
            p => o.hasOwnProperty(p) && o[p] instanceof Object && !Object.isFrozen(o[p]) ? deepFreeze(o[p]) : true
        );
    };

    Можно ещё сразу же: deepFreeze(deepFreeze);

    • mayorovp
      /#10625738

      Зачем вы проверяете hasOwnProperty?


      Кстати, проверка o[p] instanceof Object слишком опасная. Можно случайно вмешаться во внутреннюю структуру сложного класса и все поломать. Лучше проверять на Object.getPrototypeOf(o[p]) === Object.prototype.


      Кстати, o[p] может оказаться вычисляемым свойством. Лучше получать дескриптор через Object.getOwnPropertyDescriptor и проверять его value.

      • Lain_13
        /#10625852

        Зачем вы проверяете hasOwnProperty?

        А и правда, в сочетании с getOwnPropertyNames оно явно лишнее.


        А вот про instanceof можно поподробнее? Впрочем, на сколько я понимаю проверять прототип тоже не самая здравая идея:
        http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
        Если в том примере с xArray добавить Object.getPrototypeOf(arr) === Array.prototype, то тоже будет false. Тогда уже лучше o !== null && typeof o === 'object', наверное?


        Т.е. полный варианту будет выглядеть как-то так:


        const deepFreeze = o => {
            Object.freeze(o);
            Object.getOwnPropertyNames(o).forEach(
                (p, i, o) => {
                    let d = Object.getOwnPropertyDescriptor(o, p);
                    if (d && d.value !== null &&
                        typeof d.value === 'object' &&
                        !Object.isFrozen(d.value))
                        deepFreeze(d.value);
                }
            );
            return o;
        };

        • mayorovp
          /#10625856

          Попробуйте применить ваш код к объекту Date или какому-нибудь HTMLElement… Или к модели mobx, компоненту React или еще чему-нибудь подобному.

          Идея строгой проверки прототипа — убедиться, что на входе лежит именно литерал объекта, а не что-то более сложное.

  7. k12th
    /#10625690

    Камон, если бы не React, который несмотря на название, совсем не реактивный, про иммутабельность никто бы и не вспомнил. И даже React с Mobx не нуждается в иммутабельных структурах.


    Не мутации страшны, а неконтролируемые и неотслеживаемые мутации. vuex предупреждает разработчика — «не меняй состояние вручную», а в redux можно запросто это сделать и полдня потом дебажить, «какая сволочь стреляла».

  8. Eika
    /#10625752

    Еще есть Object.seal.
    Он такой же как freeze, только позволяет изменения существующим ключам.

  9. MikailBag
    /#10625848

    Для полноты картины отмечу, что персистентные структуры данных сильно сложнее в написании и отладке, чем свои "рядовые" "братья". В лучшем случае они работают медленнее лишь в константу раз. Но проигрыш в скорости (например я не слышал о персистентном списке работающем за O(1)) может быть на больших объемах данных может быть очень серьезным.
    Таким образом, не стоит пихать такие структуры туда, где идет много вычислений.

    • mayorovp
      /#10625860

      Вот список как раз — классика персистентных структур. { head: ..., tail: ...}


      За O(1) у него работают операции "добавить элемент в начало" и "удалить первый элемент". Итерация по списку делается удалением всех элементов.

      • MikailBag
        /#10625884

        Вы привели пример персистентного стека, и он действительно работает за O(1).
        Я же имею в виду обычный список типа массива, т.е. чтение/запись в произвольные места.
        Лучшее, что мне известно в этой области — персистентные деревья, с запросами за O(log N) и большой константой.
        А плюшки, которые дает персистентность (много версий струкутры с возможностью одновременной работы с ними), в типичном веб-приложении вряд ли нужны (например в Redux хранилище единственно, т.о. версия нужна одна).

  10. vintage
    /#10626240

    1. Object.freeze, Object.assign, ImmutableJS и прочие такие штуки серьёзно так замедляют работу приложения. Я бы рекомендовал воспользоваться TypeScript, который не позволит вам динамически изменить сигнатуру объекта или изменить readonly свойство. При этом ещё на стадии написания кода, а не в рантайме и соответственно без замедления исполнения.


    2. Объекты можно условно разделить на два типа: значения и контейнеры. Беда JS в том, что, например, один и тот же Array выступает и в роли контейнера (push, pop, ...) и в качестве значения (map, filter, ...).


    3. Никаких опасностей мутыций вы не продемонстрировали. Только описали азы языка и назвали их "опасными мутантами". Кстати, тот самый синий и волосатый монстр, о котором вы говорили, — весьма душевный человек и проницательный собеседник :-)

    • abyrkov
      /#10626590 / -1

      1. Решать проблемы JS перейдя на TS это так же как решать проблемы C++ перейдя на Java. Не всегда это оправданно

      • Zenitchik
        /#10626600

        Согласен. Лучше просто знать язык и писать нормально. Хорошему программисту мутабельность не мешала никогда. Не хочешь изменять — не изменяй.

      • mayorovp
        /#10626648

        Нет, это как решать проблемы Си перейдя на С++.

        • abyrkov
          /#10626836

          Вот это хороший пример, потому, что у С++ есть некоторые Сишные проблемы

      • k12th
        /#10626708

        Java не компилируется в C++.

        • abyrkov
          /#10626830

          А это разве влияет на мое утверждение, что переход ради решения одной (зачастую несущественной) проблемы конкретным путем, не всегда целесообразно переходить на другой язык?

          • abyrkov
            /#10626916

            Очень интересно узнать, за что минусуют то? Я же не предлагаю писать все и всия на JS, а просто не писать на TS там, где он избыточен

  11. PYXRU
    /#10626730

    На этой вашей мутабельность держиться большинство фраемворков(angular), тот же Vue биндит реактивные свойства через замыкание по сути вообще костыль для избежания рекурсии, и таких приемов очень много. Проблема кажется более надуманная, поскольку большинство знают что не примитивные типы передаются по ссылке. Если для вас это действительно проблема Object.defineProperty или typescript вам в помощь.

  12. rockon404
    /#10628452

    Странно, что в статье не упомянули spread оператор:

    const obj = { foo: 'bar' };
    const objCopy = { ...obj };
    

    Он код с его использованием понятен и лаконичен. Пусть он и является синтаксическим сахаром над Object.assign.