Здравствуйте, уважаемые читатели. Сегодня мы хотели бы предложить вам перевод статьи о неизменяемости в современном 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:
Поделиться с друзьями
-->

Комментарии (37)


  1. svboobnov
    09.12.2016 16:10

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


    1. DexterHD
      09.12.2016 16:39
      +2

      Именно. Дожили называется, Хипстеры вдруг решили, что использование ссылочных типов зло, а передача по значению единственный верный вариант… :D И назвали то как пафосно «Иммутабельность» :D


      1. Dark_Daiver
        10.12.2016 17:01

        Но… Бывает же передача иммутабельного аргумента по ссылке? Не скажу за JS, но в том же C++ вполне (а думаю, что еще в C#/Java/Scala)


  1. Stronix
    09.12.2016 16:28
    +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. vlreshet
      09.12.2016 16:41
      +1

      Так суть в обратном. В следующем коде

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


      нашей задачей было получить новый объект newPerson, такой же самый как person только с age 30. А проблема возникает в том что возраст изменялся и у первого объекта. В этом то и суть проблемы, о которой весь пост.


      1. Shifty_Fox
        09.12.2016 18:57

        Ну так получайте новый объект, а не приравнивайте указатель к старому :)
        В js переменные это не значения, только указатели. И даже на числа и строки. Просто так уж вышло, что как и в Lua, в js просто нет функций для изменения строк и чисел по указателю — все функции чистые и возвращают новую строку\число. Но поверьте, переменная со строкой все равно есть указатель на нее.
        Я конечно капитан, но статья капитан не меньше.


    1. DaleMartinWatson
      09.12.2016 16:43

      Еще и статью не читал. Автор жалуется на то, что при присвоении происходит копирование ссылки, а не самого объекта.


      1. Stronix
        09.12.2016 16:44

        Тогда при чём здесь в примере const?


        1. vlreshet
          09.12.2016 16:46
          +1

          Я не автор, но уверен что const — просто так, и с таким же успехом его можно заменить на var. Просто в es6 принято всё что не будет изменяемо — объявлять с const.


        1. Fen1kz
          09.12.2016 17:15
          +1

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


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


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


          1. Stronix
            09.12.2016 19:55

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

            vlreshet, Fen1kz, ок, теперь ясно.


        1. gearbox
          09.12.2016 18:08
          +1

          Fen1kz все расписал.


  1. Odrin
    09.12.2016 17:04
    +3

    Костыли для тех, кто не понимает разницы между ссылкой и значением? Да, бывают условия, при которых нужно копирование объектов, но использовать это всегда и везде — это как-то странно. И да, ваш «object spread» сломается, как только внутри объекта появится ссылка на другой объект, поэтому без отдельной функции для «deep clone» все равно не обойтись.


    1. lynch513
      09.12.2016 19:50

      В статье описывается функциональный подход к программированию JS. Можно использовать для Redux, когда требуется обеспечить иммутабельность данных в хранилище данных store, именно такой код используется для редюсеров (чистых функции которые принимают хранилище данных store и возвращают новое измененное хранилище).

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


    1. staticlab
      09.12.2016 23:56
      +1

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


  1. Strain
    09.12.2016 17:40

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

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

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


  1. Ivanq
    09.12.2016 19:50
    +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. Отсутствие запятых — зло.


    1. aremedy
      10.12.2016 11:09

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


  1. savelichalex
    09.12.2016 19:50

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


  1. asm0dey
    10.12.2016 20:06
    +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 и в конце конструктора фризить объект.


    1. Large
      14.12.2016 20:42

      Лучше всего клонирование делать через конструкторы. То есть в идеале конструктор объекта должен принимать объект такого же типа и возвращать копию.


  1. Yozi
    12.12.2016 13:15
    +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 раз копировать объект, добавляя по одному свойству за итерацию?


    1. asm0dey
      12.12.2016 14:01

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


      1. Yozi
        12.12.2016 16:46
        +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
        }, {})


        1. asm0dey
          12.12.2016 21:12

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


      1. faiwer
        14.12.2016 08:52

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


        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 вызывать", не знаю куда именно его воткнуть. Покажите на примере выше, плз. Может быть это улучшит ситуацию.


        1. faiwer
          14.12.2016 08:59

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


        1. Yozi
          14.12.2016 09:14
          +1

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


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


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


          1. faiwer
            14.12.2016 09:18

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


          1. asm0dey
            14.12.2016 10:18

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


        1. asm0dey
          14.12.2016 10:21

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


          1. faiwer
            14.12.2016 10:23

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


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


            1. asm0dey
              14.12.2016 12:31

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


              1. faiwer
                14.12.2016 12:57

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


    1. faiwer
      12.12.2016 14:35

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


  1. ilfroloff
    14.12.2016 11:49

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

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


    1. faiwer
      14.12.2016 13:03

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