Неизменность является основным принципом в функциональном программировании, и она может многое предложить объектно-ориентированным программам. В этой статье я покажу, что такое неизменность, как использовать эту концепцию в 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 (пока) нет неизменяемых списков и карт, поэтому сейчас нам понадобится сторонняя библиотека. Есть два очень хороших доступных. Первым является Mori , который позволяет использовать постоянные структуры данных ClojureScript и поддерживать API в JavaScript. Другой — immutable.js , написанный разработчиками из Facebook. Для этой демонстрации я буду использовать immutable.js просто потому, что его API больше знаком разработчикам JavaScript.
Для этой демонстрации мы рассмотрим, как работать с Minesweeper с неизменяемыми данными. Доска представлена неизменяемой картой, где наиболее интересной частью данных являются tiles
. Это неизменный список неизменных карт, где каждая карта представляет плитку на доске. Все это инициализируется объектами и массивами JavaScript, а затем увековечивается функцией immutable.js из fromJS
:
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
является нулевым и будет setIn
пустыми объектами, если какая-либо часть ключа не существует. Это нежелательно в случае с доской Сапер, потому что отсутствующий тайл означает, что мы пытаемся раскрыть тайл вне доски. Это можно смягчить, используя getIn
для поиска плитки перед тем, как манипулировать ею:
function revealTile(game, tile) { return game.getIn(['tiles', tile]) ? game.setIn(['tiles', tile, 'isRevealed'], true) : game; }
Если тайл не существует, мы просто возвращаем существующую игру. Это был быстрый вкус неизменности на практике, чтобы глубже погрузиться в это, посмотрите на этот кодекс , который включает в себя полную реализацию правил игры Сапер.
Как насчет производительности?
Вы можете подумать, что это приведет к ужасной производительности, и в некоторых отношениях вы будете правы. Всякий раз, когда вы добавляете что-то к неизменяемому объекту, нам нужно создать новый экземпляр, скопировав существующие значения и добавив в него новое значение. Это, безусловно, потребует больше памяти и вычислительных затрат, чем изменение одного объекта.
Поскольку неизменяемые объекты никогда не меняются, они могут быть реализованы с использованием стратегии, называемой «структурное совместное использование», которая приводит к гораздо меньшим затратам памяти, чем можно было ожидать. По сравнению со встроенными массивами и объектами издержки все равно будут сохраняться, но они будут постоянными и, как правило, могут затмевать другие преимущества, связанные с неизменяемостью. На практике использование неизменяемых данных во многих случаях повышает общую производительность вашего приложения, даже если отдельные операции становятся дороже.
Улучшено отслеживание изменений
Одной из самых сложных задач в любой среде пользовательского интерфейса является отслеживание мутаций. Это настолько распространенная проблема, что 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]
не запускает нашего наблюдателя мутаций, поэтому предлагаемый механизм отслеживания мутаций не работает даже в самых простых случаях использования. Как неизменность помогает в этой ситуации? Данное состояние приложения a
и потенциально новое состояние приложения b
:
if (a === b) { // Data didn't change, abort }
Если состояние приложения не обновилось, оно будет таким же, как и раньше, и нам вообще ничего не нужно делать. Это требует, чтобы мы отслеживали ссылку, которая содержит состояние, но вся проблема теперь сводится к управлению одной ссылкой.
Вывод
Я надеюсь, что эта статья дала вам некоторое представление о том, как неизменность может помочь вам улучшить ваш код, и что приведенный пример может пролить свет на практические аспекты работы таким образом. Неизменность растет, и это будет не последняя статья, которую вы прочитали в этом году. Сделайте это, и я обещаю, что вы будете так же взволнованы, как и я.