Неизменяемый JavaScript: как это делается с ES6 и выше

Original author: Niels Gerritsen
  • Translation
Здравствуйте, уважаемые читатели. Сегодня мы хотели бы предложить вам перевод статьи о неизменяемости в современном JavaScript. Подробнее о различных возможностях ES6 рекомендуем почитать в вышедшей у нас замечательной книге Кайла Симпсона "ES6 и не только".

Писать неизменяемый код Javascript – правильно. Существует ряд потрясающих библиотек, например, Immutable.js, которые могли бы для этого пригодиться. Но можно ли сегодня обойтись без библиотек – писать на «ванильном» JavaScript нового поколения?

Если коротко — да. В ES6 и ES.Next есть ряд потрясающих возможностей, позволяющих добиться неизменяемого поведения без какой-либо возни. В этой статье я расскажу, как ими пользоваться – это интересно!

ES.Next – это следующ(ая/ие) верси(я/и) EcmaScript. Новые релизы EcmaScript выходят ежегодно и содержат возможности, которыми можно пользоваться уже сегодня при помощи транспилятора, например, Babel.

Проблема


Для начала определимся, почему неизменяемость так важна? Ну, если изменять данные, то может получиться сложночитаемый код, подверженный ошибкам. Если речь идет о примитивных значениях (например, числах и строках), писать «неизменяемый» код совсем просто – ведь сами эти значения изменяться не могут. Переменные, содержащие примитивные типы, всегда указывают на конкретное значение. Если передать его другой переменной, то другая переменная получит копию этого значения.

С объектами (и массивами) другая история: они передаются по ссылке. Это означает, что, если передать объект другой переменной, то обе они будут ссылаться на один и тот же объект. Если же вы впоследствии измените объект, принадлежащий любой из них, то изменения отразятся на обеих переменных. Пример:

const person = {
  name: 'John',
  age: 28
}
const newPerson = person
newPerson.age = 30
console.log(newPerson === person) // истина
console.log(person) // { name: 'John', age: 30 }

Видите, что происходит? Изменив newObj, мы автоматически поменяем и старую переменную obj. Все потому, что они ссылаются на один и тот же объект. В большинстве случаев такое поведение нежелательно, и писать код таким образом плохо. Посмотрим, как можно решить эту проблему.


Обеспечиваем неизменяемость


А что если не передавать объект и не изменять его, а вместо этого создавать совершенно новый объект:

const person = {
  name: 'John',
  age: 28
}
const newPerson = Object.assign({}, person, {
  age: 30
})
console.log(newPerson === person) // ложь
console.log(person) // { name: 'John', age: 28 }
console.log(newPerson) // { name: 'John', age: 30 }

Object.assign – это возможность ES6, позволяющая принимать объекты в качестве параметров. Она объединяет все передаваемые ей объекты с первым. Возможно, вы удивились: а почему первый параметр – это пустой объект {}? Если бы первым шел параметр ‘person’, то мы по-прежнему изменяли бы person. Если бы у нас было написано { age: 30 }, то мы бы опять перезаписали 30 значением 28, так как оно шло бы позже. Наше решение работает — person сохранилось без изменений, так как мы поступили с ним как с неизменяемым!

Хотите без лишних хлопот опробовать эти примеры? Открывайте JSBin. В левой панели щелкните Javascript и замените его на ES6/Babel. Все, уже можете писать на ES6 :).

Однако, на самом деле в EcmaScript есть специальный синтаксис, еще сильнее упрощающий такие задачи. Он называется object spread, использовать его можно при помощи транспилятора Babel. Смотрите:

const person = {
  name: 'John',
  age: 28
}
const newPerson = {
  ...person,
  age: 30
}
console.log(newPerson === person) // ложь
console.log(newPerson) // { name: 'John', age: 30 }

Тот же результат, только теперь Код еще чище. Сначала оператор ‘spread’ (...) копирует все свойства из person в новый объект. Затем мы определяем новое свойство ‘age’, которым перезаписываем старое. Соблюдайте порядок: если бы age: 30 было определено выше person, то затем оно было бы перезаписано age: 28.

А если нужно убрать элемент? Нет, удалять мы его не будем, ведь при этом объект вновь бы изменился. Такой прием немного сложнее, и мы могли бы поступить, например, вот так:

const person = {
  name: 'John',
  password: '123',
  age: 28
}
const newPerson = Object.keys(person).reduce((obj, key) => {
  if (key !== property) {
    return { ...obj, [key]: person[key] }
  }
  return obj
}, {})

Как видите, практически всю операцию приходится программировать самостоятельно. Эту функциональность можно было бы поставить во главу угла как универсальный инструмент. Но как изменение и неизменяемость применяются с массивами?

Массивы


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

const characters = [ 'Obi-Wan', 'Vader' ]
const newCharacters = characters
newCharacters.push('Luke')
console.log(characters === newCharacters) // истина :-(

Та же проблема, что и с объектами. Нам решительно не удалось создать новый массив, мы просто изменили старый. К счастью, в ES6 есть оператор spread для массива! Вот как его использовать:

const characters = [ 'Obi-Wan', 'Vader' ]
const newCharacters = [ ...characters, 'Luke' ]
console.log(characters === newCharacters) // false
console.log(characters) // [ 'Obi-Wan', 'Vader' ]
console.log(newCharacters) // [ 'Obi-Wan', 'Vader', 'Luke' ]

Как же просто! Мы создали новый массив, в котором содержатся старые символы плюс ‘Luke’, а старый массив не тронули.

Рассмотрим, как делать с массивами другие операции, не изменяя исходного массива:

const characters = [ 'Obi-Wan', 'Vader', 'Luke' ]
// Удаляем Вейдера
const withoutVader = characters.filter(char => char !== 'Vader')
console.log(withoutVader) // [ 'Obi-Wan', 'Luke' ]
// Меняем Вейдера на Энекина
const backInTime = characters.map(char => char === 'Vader' ? 'Anakin' : char)
console.log(backInTime) // [ 'Obi-Wan', 'Anakin', 'Luke' ]
// Все символы в верхнем регистре
const shoutOut = characters.map(char => char.toUpperCase())
console.log(shoutOut) // [ 'OBI-WAN', 'VADER', 'LUKE' ]
// Объединяем два множества символов
const otherCharacters = [ 'Yoda', 'Finn' ]
const moreCharacters = [ ...characters, ...otherCharacters ]
console.log(moreCharacters) // [ 'Obi-Wan', 'Vader', 'Luke', 'Yoda', 'Finn' ]

Видите, какие приятные «функциональные» операторы? Действующий в ES6 синтаксис стрелочных функций их только красит. Каждый раз при запуске такой функции такая функция возвращает новый массив, одно исключение – древний метод сортировки:

const characters = [ 'Obi-Wan', 'Vader', 'Luke' ]
const sortedCharacters = characters.sort()
console.log(sortedCharacters === characters) // истина :-(
console.log(characters) // [ 'Luke', 'Obi-Wan', 'Vader' ]

Да, знаю. Я считаю, что push и sort должны действовать точно как map, filter и concat, возвращать новые массивы. Но они этого не делают, и если что-то поменять, то, вероятно, можно сломать Интернет. Если вам требуется сортировка, то, пожалуй, можно воспользоваться slice, чтобы все получилось:

const characters = [ 'Obi-Wan', 'Vader', 'Luke' ]
const sortedCharacters = characters.slice().sort()
console.log(sortedCharacters === characters) // false :-D
console.log(sortedCharacters) // [ 'Luke', 'Obi-Wan', 'Vader' ]
console.log(characters) // [ 'Obi-Wan', 'Vader', 'Luke' ]

Остается ощущение, что slice() – немного «хак», но он работает.
Как видите, неизменяемость легко достигается при помощи самого обычного современного JavaScript! В конце концов, важнее всего – здравый смысл и понимание, что именно делает ваш код. Если программировать неосторожно, JavaScript может быть непредсказуем.

Замечание о производительности


Что насчет производительности? Ведь создавать новые объекты – напрасная трата времени и памяти? Да, действительно, возникают лишние издержки. Но этот недостаток с лихвой компенсируют приобретаемые преимущества.

Одна из наиболее сложных операций в JavaScript – это отслеживание изменений объекта. Решения вроде Object.observe(object, callback) довольно тяжеловесны. Однако, если держать состояние неизменяемым, то можно обойтись oldObject === newObject и таким образом проверять, не изменился ли объект. Такая операция не так сильно нагружает CPU.

Второе важное достоинство – улучшается качество кода. Когда нужно гарантировать неизменяемость состояния, приходится лучше продумывать структуру всего приложения. Вы программируете «функциональнее», весь код проще отслеживать, а гнусные баги в нем заводятся реже. Куда ни кинь – всюду вин, верно?

Для справки


» Таблица совместимости ES6:
» Таблица совместимости ES.Next:
Издательский дом «Питер»
172.28
Company
Share post

Comments 42

    0
    Это что, целая книга про разные способы передачи параметров в функцию: передача по ссылке (функция получает ссылку на значение аргумента и может его менять) и передача по значению (функция получает копию аргумента и не может его изменить)?
    И про то, в каких случаях какой способ передачи надо использовать?
      +2
      Именно. Дожили называется, Хипстеры вдруг решили, что использование ссылочных типов зло, а передача по значению единственный верный вариант… :D И назвали то как пафосно «Иммутабельность» :D
        0
        Но… Бывает же передача иммутабельного аргумента по ссылке? Не скажу за JS, но в том же C++ вполне (а думаю, что еще в C#/Java/Scala)
      +2
      Далеко не специалист в JS, однако мне не понятен смысл примеров с объектами и массивами.
      const person = {
        name: 'John',
        age: 28
      }
      const newPerson = person
      newPerson.age = 30
      

      Достаточно просто
      person.age = 30;
      

      Также и с массивом
      const characters = [ 'Obi-Wan', 'Vader' ]
      const newCharacters = characters
      newCharacters.push('Luke')
      

      с таким же успехом можно просто
      characters.push('Luke');
      
        +1
        Так суть в обратном. В следующем коде

        const person = {
          name: 'John',
          age: 28
        }
        const newPerson = person
        newPerson.age = 30
        


        нашей задачей было получить новый объект newPerson, такой же самый как person только с age 30. А проблема возникает в том что возраст изменялся и у первого объекта. В этом то и суть проблемы, о которой весь пост.
          0
          Ну так получайте новый объект, а не приравнивайте указатель к старому :)
          В js переменные это не значения, только указатели. И даже на числа и строки. Просто так уж вышло, что как и в Lua, в js просто нет функций для изменения строк и чисел по указателю — все функции чистые и возвращают новую строку\число. Но поверьте, переменная со строкой все равно есть указатель на нее.
          Я конечно капитан, но статья капитан не меньше.
          0
          Еще и статью не читал. Автор жалуется на то, что при присвоении происходит копирование ссылки, а не самого объекта.
            0
            Тогда при чём здесь в примере const?
              +1
              Я не автор, но уверен что const — просто так, и с таким же успехом его можно заменить на var. Просто в es6 принято всё что не будет изменяемо — объявлять с const.
                +1

                const не позволит перезаписать person. (+ мелкие бонусы в виде отсутствия hoisting и нормальной работы с block scopes)


                const person = {age: 26}
                person = {age: 30} // ошибка


                В примере используется потому что просто привычка, после const/let var кажется отвратительным (ну он такой и есть)

                  0
                  уверен что const — просто так
                  В примере используется потому что просто привычка

                  vlreshet, Fen1kz, ок, теперь ясно.
                  +1
                  Fen1kz все расписал.
              +3
              Костыли для тех, кто не понимает разницы между ссылкой и значением? Да, бывают условия, при которых нужно копирование объектов, но использовать это всегда и везде — это как-то странно. И да, ваш «object spread» сломается, как только внутри объекта появится ссылка на другой объект, поэтому без отдельной функции для «deep clone» все равно не обойтись.
                0
                В статье описывается функциональный подход к программированию JS. Можно использовать для Redux, когда требуется обеспечить иммутабельность данных в хранилище данных store, именно такой код используется для редюсеров (чистых функции которые принимают хранилище данных store и возвращают новое измененное хранилище).

                И да, если внутри объекта есть ссылка на другой объект, то она копируется поверхностно, надо это учитывать и использовать deep clone или деструктуризацию и последующую структуризацию объектов, что кстати описано в указанной книге Кайла Симпсона на стр.62. Так что книга дельная
                  +1

                  С точки зрения использования иммутабельности для сравнения объектов тут всё логично: ссылки на подобъекты одинаковые, значит и сами эти подобъекты одинаковые.

                  0
                  В реальных скриптах объекты более сложные и вложенные.
                  Никакие 'object assign', 'object spread', 'object freeze' итп не помогут в таких условиях.
                  Только рекурсивное клонирование, вот пример

                  https://github.com/timCF/jsfunky/blob/8d73af422c4e3c30f78a8e1689d2f09b7d5ccbb0/jsfunky.iced#L10-L21

                  Написал для упрощения жизни какое-то время назад этот модуль, пользуюсь часто в реальных задачах.
                    +1
                    Хотя это перевод, все равно скажу.

                    Одна из наиболее сложных операций в JavaScript – это отслеживание изменений объекта. Решения вроде Object.observe(object, callback) довольно тяжеловесны. Однако, если держать состояние неизменяемым, то можно обойтись oldObject === newObject и таким образом проверять, не изменился ли объект. Такая операция не так сильно нагружает CPU.

                    Вообще, даже для равных objObject и newObject они не равны, ведь сравниваются по ссылке:
                    var a = {value: 1};
                    var b = a;
                    console.log(a === b); // true
                    

                    var a = {value: 1};
                    var b = {value: 2};
                    console.log(a === b); // false :-(
                    


                    PS. Отсутствие запятых — зло.
                      0

                      В тексте немного странно написано.
                      В редуксе смысл в том, что проверка наличия изменений сводится к newState !== oldState.
                      Поэтому так важно возвращать новое состояние при изменениях, а не изменять текущее

                      0
                      Ну вообще в Immutable.js реализованы persistence hash trees, так же как и в clojure. Так что в целях производительности лучше использовать библиотеку все таки
                        +1

                        Мне кажется иммутабельные объекты делать лучше как-то так:


                        class Copiable {
                          copy(key,value){
                            let result = Object.assign({},this);
                            result[key]=value;
                            result.__proto__ = this.__proto__
                            Object.freeze(result);
                            return result;
                          }
                        }
                        
                        class Person extends Copiable {
                          constructor(name='Adam', age=31){
                            super();
                            this.name=name;
                            this.age=age;
                            Object.freeze(this);
                          }
                        }

                        То есть два простых правила — наследоваться от Copiable и в конце конструктора фризить объект.

                          0
                          Лучше всего клонирование делать через конструкторы. То есть в идеале конструктор объекта должен принимать объект такого же типа и возвращать копию.
                          +1
                          const person = {
                            name: 'John',
                            password: '123',
                            age: 28
                          }
                          const newPerson = Object.keys(person).reduce((obj, key) => {
                            if (key !== property) {
                              return { ...obj, [key]: person[key] }
                            }
                            return obj
                          }, {})

                          А это вообще нормально N-1 раз копировать объект, добавляя по одному свойству за итерацию?

                            0

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

                              +1

                              Вы, верно, троллите, если пишете, что не видите проблемы насилования устройства аллокациями в цикле


                              const newPerson1 = {...person};
                              delete newPerson1[property];
                              
                              // vs
                              
                              const newPerson2 = Object.keys(person).reduce((obj, key) => {
                                if (key !== property) {
                                  return { ...obj, [key]: person[key] }
                                }
                                return obj
                              }, {})
                                0

                                Ну весь хаскель так работает, но ещё раз — можно оптимизировать достаточно сильно. Главное н езаьывать фриз сделать.

                                0

                                Сложно сказать. Попробовал вот на коленке пример собрать:


                                const keys = []; for(let i = 0; i < 100000; i += 10) keys.push(i); 
                                
                                console.time('mutable');
                                const obj1 = {};
                                for(let key of keys) obj1[key] = key;
                                console.timeEnd('mutable');
                                
                                console.time('reduce');
                                const obj2 = keys.reduce((o, key) => Object.assign({}, o, { [key]: key }), {});
                                console.timeEnd('reduce');

                                Результат: 7.7ms vs 18'781ms. Таки JS не Haskell.
                                Касательно "не забывать freeze вызывать", не знаю куда именно его воткнуть. Покажите на примере выше, плз. Может быть это улучшит ситуацию.

                                  0

                                  А вот промежуточный вариант с delete срабатывает за тоже самое время, что и чисто mutable-вариант. Т.е. в 2600 раз быстрее.

                                    +1

                                    Конечно, с чего бы это оптимизатору js понимать что за ужс натворил писатель этого творения.


                                    Object.freeze тоже совсем не обязательная вещь — глубокая заморозка очень дорогая вещь, она полезна на стадии разработки, но в продакшене мы даже не должны пытаться ошибочно изменять иммутабельный объект, или же мы совершим ошибку, за которую нас справедливо покарают исключением TypeError (в строгом режиме).


                                    Т.е. моё мнение, что с Object.freeze хорошо тестировать код и находить ошибочные попытки изменить объект, но плохо костылить "иммутабельность" в продакшене

                                      0

                                      Мне кажется, что пока внятной поддержки имутабельных структур в js не появится ― не нужно уродоваться со всякими freeze, и уж тем более с такой дичью, как этот reduce + {..., [key]: }. Это уже ни в какие ворота не лезет :) Нужно искать компромиссы, вроде вышеописанного delete.

                                        0

                                        Вот тут согласен. Как решили что функциональность работает — можно выпилить freeze.

                                        0

                                        Не-не, оно гарантированно ничего не ускорит. Более того, копирование объектов очевидно не может быть быстрее. Оно может быть только надёжнее в плане иммутабельности.

                                          0

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


                                          Про что-то подобное в JS я ещё нигде не натыкался.

                                            0

                                            Ну в спеке на хаскел открытым текстом сказано что новые инстансы создаются постоянно и на месте же собираются GC если на старые версии больше никто не ссылается. Но за счёт этого immutability gc работает очень эффективно.

                                              0

                                              Полагаю, что там всё не так просто, как вы пишете. Выделять память и тут же её освобождать копируя большие куски данных на каждый чих ― это даже в теории не может быть сопоставимым, с примитивной мутацией. Разница на порядки. Полагаю, что в Haskell-е за счёт его нативной немутабельности просто много хитростей. А в JS про них пока ни намёка. Оттого и разница в 2600 раз.

                                            +1

                                            Копирование объектов может быть быстрее: https://habrahabr.ru/post/283090/

                                            +1

                                            Насчет клонирования можно (и скорее всего нужно) применить оптимизацию: https://habrahabr.ru/post/283090/. Про freeze лучше забыть, слишком медленно.

                                              +1

                                              А я уже и забыл про ту статью. Спасибо, что напомнили. Правда это такой "хак", что всем хакам хак. Не думаю, что стоит полагаться на такое в production-е.

                                                +1

                                                Согласен. Насчет примера выше, имеет ли смысл каждый раз создавать объект? Mожет лучше сразу:


                                                const obj2 = keys.reduce((o, key) => 
                                                  Object.assign(o, { [key]: key }), {});
                                          0

                                          Присоединяюсь к вопросу. Может кто проводил полноценные замеры? В том числе и по .concat в []. На первый взгляд такие вещи использовать при итерации просто дико. Но может быть там есть под капотом оптимизации под это?

                                          0
                                          Однако, на самом деле в EcmaScript есть специальный синтаксис, еще сильнее упрощающий такие задачи. Он называется object spread, использовать его можно при помощи транспилятора Babel.

                                          А можно ссылку в спецификации про object spread? Потому что ни в последнем Chrome, ни в FF, ни в node.js 6/7 "из коробки" эта возможность не работает. Однако по таблице поддержки из статьи spread везде поддерживается полностью. Полагаю, что это какая-то драфтовая возможность

                                            0

                                            Вот тут это называется как object spread properties. Stage 3, значит почти принято.

                                            0
                                            let person = {};
                                            Object.defineProperty(person, 'name', {
                                              value: 'John',
                                              writable: false
                                            });
                                            

                                            Думаю как-то так можно сделать «иммутабельность». Так же можно попробовать freeze

                                            Only users with full accounts can post comments. Log in, please.