Что такое неизменяемость?
Книжное определение изменяемости звучит следующим образом: «склонность предмета к изменениям или преобразованиям». В программировании мы используем это слово, когда подразумеваем объекты, состояние которых можно изменить с течением времени. Неизменяемое значение — это с точностью до наоборот, оно уже никогда не изменится после создания.
Если это кажется странным, то позвольте вам напомнить, что большинство из тех значений, которые мы всё время используем, в действительности неизменяемы:
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)
4p4
18.06.2015 14:53+6Основные назначения неизменяемости:
- Дать компилятору больше простора для оптимизаций.
- Упростить жизнь разным инструментам для статического анализа кода.
- Упростить отслеживание изменений в структурах данных.
В данном случае, в Web/JavaScript, неизменяемость решает задачу облегчения рендеринга изменений в дереве пресдтавления. (Разновидность пункта три).MuLLtiQ
18.06.2015 19:01+4А еще неизменяемость данных позволяет избежать многих сайд-эффектов в функциях.
RusSuckOFF
18.06.2015 23:21А можно поподробнее про компилятор, в чем простор?
fshp
19.06.2015 01:52+21) Возможность вместо доступа к переменной подставить константу в выражения. И тут же провести оптимизацию свёрткой.
2) Отсутствует необходимость в синхронизации. Если переменная read-only, то можно её безопасно читать из разных потоков минуя блокировки.RusSuckOFF
19.06.2015 10:36Наверное, есть еще какие-то теоретические возможности это оптимизировать, но применимо ли это к тому же V8. Поток исполнения один, синхронизировать между потоками не нужно. Насчет константы тоже не факт.
fshp
19.06.2015 12:35-3Как это один? JS в браузерах разве не асинхронный?
RusSuckOFF
19.06.2015 12:43+5Асинхронный и многопоточный — это не одно и тоже.
По умолчанию есть только один поток, которые асинхронно обслуживает очередь сообщений.
В браузере для многопоточности есть WebWorkers, но единственное возможное общение между потоками осуществляется через отправку строк или сериализованного в строку JSON-а, к одним и тем же переменным из разных воркеров обратиться нельзя.
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.
lega
18.06.2015 21:29+1следовательно, предложенный механизм отслеживания мутаций не годится даже для тривиального случая применения
Object.observe всего лишь «кирпичик», при необходимости можно рекурсивно отслеживать все «дерево».
Andre_487
21.06.2015 22:15+1На практике, во множестве случаев применение неизменяемых данных увеличит общую производительность вашего приложения, даже если определённые операции станут более затратными в отдельности.
Как частенько договаривал неизвестный Andre_487 за известным Станиславским: не верю! Есть ли бенчмарки конкретных реализаций на JS?
Можно понять, как иммутабельность и чистые функции делают быстрее программы на языках, где это ядерная концепция, позволяющая оптимизировать и разводить по потокам при компиляции, но в JS ведь это отсутствует и оптимизации в современных машинах рассчитаны совсем не на подобные паттерны.
k12th
Героически создать себе трудности, имитируя иммутабельность там, где её нет, и потом героически их преодолевать. Зачем, чтобы обойти кривость Object.observe?