Неизменяемость — основной принцип функционального программирования, который также может многое предложить объектно-ориентированным программам. В этой статье я расскажу вам о том, что именно является краеугольным камнем неизменяемости, как использовать эту концепцию в 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) {
// данные не изменились, прекратить
}
Если состояние приложения не изменилось, то это тот же экземпляр, что и прежде и нам вообще ничего не нужно делать. Это определённо требует того, чтобы мы отслеживали содержащую состояние ссылку, но вся проблема теперь сводится к тому, что нам необходимо управлять одной единственной ссылкой.
Выводы
Я надеюсь что в этой статье вы почерпнули определённые знания о том, как неизменяемость поможет вам улучшить свой код, и что продолженный пример может пролить свет на практические аспекты работы в данном направлении. Неизменяемость набирает популярность и это будет не последняя статья по данной теме, которую вы прочтёте в этом году. Попробуйте, и я обещаю, что она вам очень быстро понравится настолько же, насколько понравилась и мне.
Автор: Elusive_Dream