Неизменяемость — основной принцип функционального программирования, который также может многое предложить объектно-ориентированным программам. В этой статье я расскажу вам о том, что именно является краеугольным камнем неизменяемости, как использовать эту концепцию в JavaScript и почему это полезно.

Что такое неизменяемость?


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

Если это кажется странным, то позвольте вам напомнить, что большинство из тех значений, которые мы всё время используем, в действительности неизменяемы:
var statement = "I am an immutable value";
var otherStr = statement.slice(8, 17);    

Я думаю, что никто не удивится, если узнает, что вторая строчка никоим образом не изменят строковое значение statement. В действительности не существует строковых методов, которые бы изменяли строку над которой они выполняют действие, все они возвращают новые строки. Причина состоит в том, что строки неизменяемы — их нельзя преобразовывать, мы можем лишь создавать новые строки.

Строки — не единственные неизменяемые значения, встроенные в JavaScript. Числа также неизменяемы. Вы вообще можете себе представить окружение, где вычисление выражения 2 + 3 меняет значение числа 2? Это звучит абсурдно, хотя мы и делаем это всё время с нашими объектами и массивами.

В JavaScript изменяемость имеется в изобилии


В JavaScript строки и числа спроектированы быть неизменяемыми. Однако, рассмотрите следующий пример с использованием массивов:
var arr = [];
var v2 = arr.push(2); 

Каково значение v2? Если бы массивы вели себя сообразно строкам и числам, то v2 содержал бы новый массив с одним элементом внутри — 2. Однако это другой случай. Вместо этого была изменена ссылка arr дабы содержать число, а v2 содержит новую длину arr.

Вообразите себе тип ImmutableArray. Его поведение, заимствуя у чисел и строк, выглядело бы следующим образом:
var arr = new ImmutableArray([1, 2, 3, 4]);
var v2 = arr.push(5);
 
arr.toArray(); // [1, 2, 3, 4]
v2.toArray();  // [1, 2, 3, 4, 5]

Аналогичным образом неизменяемый ассоциативный массив, который можно было бы использовать вместо большинства объектов, обладал бы методами для «установки» свойств, которые ничего бы не устанавливали на самом деле, а возвращали бы новый объект с требуемыми изменениями:
var person = new ImmutableMap({name: "Chris", age: 32});
var olderPerson = person.set("age", 33);
 
person.toObject(); // {name: "Chris", age: 32}
olderPerson.toObject(); // {name: "Chris", age: 33}

Подобно тому как 2 + 3 не меняет значений ни 2 ни 3, празднование кем-либо своего 33го дня рождения, не отменяет той истины, что человек ранее был 32 лет отроду.

Неизменяемость в JavaScript на практике


У JavaScript (пока что) не имеется неизменяемых списков и ассоциативных массивов, поэтому сейчас нам потребуется сторонняя библиотека. Существуют 2 очень хорошие доступные библиотеки. Первая из них — Mori, которая позволяет применять постоянные структуры данных из ClojureScript, а также API поддержки в JavaScript. Второй — immutable.js, написанный разработчиками из Facebook. В этой демонстрации я буду применять immutable.js по той простой причине, что его API более знакомо JavaScript разработчикам.

В данной демонстрации мы рассмотрим принцип работы с неизменяемыми данными в Сапёре. Доска представлена неизменяемым ассоциативным массивом, в котором tiles являются наиболее интересной частью данных. Это — неизменяемый список из неизменяемых ассоциативных массивов, где каждый из последних (т. е. ассоц. масс. — прим. пер.) представляет отдельную плитку на доске. Вся конструкция инициализируется с помощью объектов и массивов JavaScript, а затем становится «бессмертной» благодаря функции fromJS из immutable.js:
function createGame(options) {
  return Immutable.fromJS({
    cols: options.cols,
    rows: options.rows,
    tiles: initTiles(options.rows, options.cols, options.mines)
  });
}

Остальная часть ядра игровой логики реализована в виде функций, которые берут эту неизменяемую структуру в качестве своего первого аргумента и возвращают новый экземпляр. Наиболее важной функцией является revealTile. При вызове она помечает плитку как открытую, чтобы открыть её. С изменяемой структурой данных, это будет очень просто:
function revealTile(game, tile) {
  game.tiles[tile].isRevealed = true;
}

Но с неизменяемыми структурами, подобными предложенным выше, это становится более чем сложно:
function revealTile(game, tile) {
  var updatedTile = game.get('tiles').get(tile).set('isRevealed', true);
  var updatedTiles = game.get('tiles').set(tile, updatedTile);
  return game.set('tiles', updatedTiles);
}

Фе! К счастью, подобные вещи — нередкое явление. Поэтому в нашем инструментарии есть метод для подобных целей:
function revealTile(game, tile) {
  return game.setIn(['tiles', tile, 'isRevealed'], true);
}

Теперь функция revealTile возвращает новый неизменяемый экземпляр, в котором одна из плиток отличается от предыдущей версии. setIn null-устойчива и заполнится пустыми объектами, если какая-либо из частей ключа не существует. В случае с доской Сапёра это не желательно, поскольку отсутствующая плитка означает, что мы пытаемся открыть плитку вне доски. Это можно смягчить, используя getIn для поиска плитки перед выполнением действий над нею:
function revealTile(game, tile) {
  return game.getIn(['tiles', tile]) ?
    game.setIn(['tiles', tile, 'isRevealed'], true) :
    game;
}

Если плитка не существует, то мы просто возвращаем существующую игру. Это было краткое знакомство с неизменяемостью на практике, если хотите разобраться тщательнее, перейдите на этот codepen, там содержится полная реализация правил игры Сапёр.

А что с производительностью?


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

Поскольку неизменяемые объекты никогда не меняются, они могут быть реализованы с помощью стратегии, называемой «общие структуры» (structural sharing), которая порождает гораздо меньшую издержку в затратах на память, чем вы могли бы ожидать. В сравнении со встроенными массивами и объектами издержка все ещё будет существовать, но она будет иметь фиксированную величину и обычно может компенсироваться другим преимуществами, доступными благодаря неизменяемости. На практике, во множестве случаев применение неизменяемых данных увеличит общую производительность вашего приложения, даже если определённые операции станут более затратными в отдельности.

Улучшенное отслеживание изменений


В любом UI фреймворке одной из самых сложных задач является поиск мутаций. Это настолько широкоизвестное испытание, что EcmaScript 7 предоставляет отдельный API дабы помочь отслеживать мутации объекта с лучшей производительностью: Object.observe(). В то время как одним людям этот API по душе, другим кажется, что это ответ не на тот вопрос. В любом случае он не решает проблему отслеживания мутаций должным образом:
var tiles = [{id: 0, isRevealed: false}, {id: 1, isRevealed: true}];
Object.observe(tiles, function () { /* ... */ });
 
tiles[0].id = 2;

Мутация объекта tiles[0] не приводит в действие наш обозреватель мутаций, следовательно, предложенный механизм отслеживания мутаций не годится даже для тривиального случая применения. Каким образом неизменяемость может помочь в данной ситуации? Предположим, что у приложения состояние а, а у потенциально нового приложения состояние b:
if (a === b) {
  // данные не изменились, прекратить
}

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

Выводы


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

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


  1. k12th
    18.06.2015 14:09
    +9

    Поэтому в нашем инструментарии есть метод для подобных целей

    Героически создать себе трудности, имитируя иммутабельность там, где её нет, и потом героически их преодолевать. Зачем, чтобы обойти кривость Object.observe?


  1. 4p4
    18.06.2015 14:53
    +6

    Основные назначения неизменяемости:

    • Дать компилятору больше простора для оптимизаций.
    • Упростить жизнь разным инструментам для статического анализа кода.
    • Упростить отслеживание изменений в структурах данных.

    В данном случае, в Web/JavaScript, неизменяемость решает задачу облегчения рендеринга изменений в дереве пресдтавления. (Разновидность пункта три).


    1. MuLLtiQ
      18.06.2015 19:01
      +4

      А еще неизменяемость данных позволяет избежать многих сайд-эффектов в функциях.


    1. RusSuckOFF
      18.06.2015 23:21

      А можно поподробнее про компилятор, в чем простор?


      1. fshp
        19.06.2015 01:52
        +2

        1) Возможность вместо доступа к переменной подставить константу в выражения. И тут же провести оптимизацию свёрткой.
        2) Отсутствует необходимость в синхронизации. Если переменная read-only, то можно её безопасно читать из разных потоков минуя блокировки.


        1. RusSuckOFF
          19.06.2015 10:36

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


          1. fshp
            19.06.2015 12:35
            -3

            Как это один? JS в браузерах разве не асинхронный?


            1. RusSuckOFF
              19.06.2015 12:43
              +5

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


      1. 4p4
        20.06.2015 17:42

        Ну вот, например. Говорят, что запросы к ОЗУ можно снизить на 33-99%.

        Immutability specification and its applications

        onlinelibrary.wiley.com/doi/10.1002/cpe.853/pdf

        ABSTRACT A location is said to be immutable if its value and the values of selected locations reachable from it are guaranteed to remain unchanged during a specified time interval. We introduce a framework for immutability specification, and discuss its application to code optimization. Compared with a final declaration, an immutability assertion in our framework can express a richer set of immutability properties along three dimensions—lifetime, reachability and context. We present a framework for processing and verifying immutability annotations in Java, as well as extending optimizations so as to exploit immutability information. Preliminary experimental results show that a significant number (61%) of read accesses could potentially be classified as immutable in our framework. Further, use of immutability information yields substantial reductions (33–99%) in the number of dynamic read accesses, and also measurable speedups in the range of 5–10% for certain benchmark programs.


  1. lega
    18.06.2015 21:29
    +1

    следовательно, предложенный механизм отслеживания мутаций не годится даже для тривиального случая применения
    Object.observe всего лишь «кирпичик», при необходимости можно рекурсивно отслеживать все «дерево».


  1. nuit
    19.06.2015 06:31

    А что если у меня данные в виде графа с циклами, а не дерева?


  1. Andre_487
    21.06.2015 22:15
    +1

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

    Как частенько договаривал неизвестный Andre_487 за известным Станиславским: не верю! Есть ли бенчмарки конкретных реализаций на JS?

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