Иммутабельность в JavaScript

habracut
Що таке иммутабельность
Незмінним (англ. immutable) називається об'єкт, стан якого не може бути змінено після створення. Результатом будь-якої модифікації такого об'єкта завжди буде новий об'єкт, при цьому старий об'єкт не зміниться.
var mutableArr = [1, 2, 3, 4];
arr.push(5);
console.log(mutableArr); // [1, 2, 3, 4, 5]

//Use seamless-immutable.js
var immutableArr = Immutable([1, 2, 3, 4]);
var newImmutableArr = immutableArr.concat([5]);
console.log(immutableArr); //[1, 2, 3, 4];
console.log(newImmutableArr); //[1, 2, 3, 4, 5];

не Йдеться про глибоке копіювання: якщо об'єкт має вкладену структуру, то всі вкладені об'єкти, що не зазнали модифікації, будуть переиспользованы.
//Use seamless-immutable.js
var state = Immutable({
style : {
color : {
r : 128,
g : 64,
b : 32
},
font : {
family : 'sans-serif',
size : 14
}
},
text : 'Example',
bounds : {
size : {
width : 100,
height : 200
},
position : {
x : 300,
y : 400
}
}
});

var nextState = state.setIn(['style', 'color', 'r'], 99);

state.bounds === nextState.bounds; //true
state.text === nextState.text; //true
state.style.font === state.style.font; //true

В пам'яті об'єкти будуть представлені наступним чином:
In Memory
Правда чи брехня? Иммутабельные дані в JavaScript
Просте і швидке відстежування змін
Цю можливість активно використовують у зв'язці з популярним нині VirtualDOM React, Mithril, Vue, Riot), для прискорення перемальовування web-сторінок.
Візьмемо приклад з
state
, наведений трохи вище. Після модифікації об'єкта
state
потрібно порівняти його з об'єктом
nextState
і дізнатися, що конкретно в ньому змінилося. Иммутабельность сильно спрощує нам завдання: замість того, щоб порівнювати значення кожного поля кожного вкладеного в
state
об'єкта з відповідним значенням
nextState
, можна просто порівнювати посилання на відповідні об'єкти і відсіювати таким чином цілі вкладені гілки порівнянь.
state === nextState //false
state.text === nextState.text //true
state.style === nextState.style //false
state.style.color === nextState.style.color //false
state.style.color.r === nextState.style.color.r //false
state.style.color.g === nextState.style.color.g //true
state.style.color.b === nextState.style.color.b //true
state.style.font === nextState.style.font; //true
//state.style.font.family === nextState.style.font.family; //true
//state.style.font.size === nextState.style.font.size; //true
state.bounds === nextState.bounds //true
//state.bounds.size === nextState.bounds.size //true
//state.bounds.size.width === nextState.bounds.size.width //true
//state.bounds.size.height === nextState.bounds.size.height //true
//state.bounds.position === nextState.bounds.position //true
//state.bounds.position.x === nextState.bounds.position.x //true
//state.bounds.position.y === nextState.bounds.position.y //true

Всередині об'єктів
bounds
та
style.font
операції порівняння робити не потрібно, так як вони иммутабельны, і посилання на них не змінилися.
Безпечніше використовувати і легше тестувати
Нерідкі випадки, коли передані в функцію дані можуть бути випадково зіпсовані, і відстежити такі ситуації дуже складно.
var arr = [2, 1, 3, 5, 4, 0];

function render(items) {
return, arr
.sort(function(a, b) {return a < b ? -1 : a > b ? 1 : 0})
.map(function(item){
return '<div>' + item + '</div>';
});
}

render(arr);
console.log(arr); // [0, 1, 2, 3, 4, 5]

Тут иммутабельные дані врятували б ситуацію. Функція
sort
була б заборонена.
//Use seamless-immutable.js
var arr = [2, 1, 3, 5, 4, 0];

function render(items) {
return, arr
.sort(function(a, b) {return a < b ? -1 : a > b ? 1 : 0})
.map(function(item){
return '<div>' + item + '</div>';
});
}

render(arr); //Uncaught Error: The sort method cannot be invoked on an Immutable data structure.
console.log(arr);

Або повернула б новий відсортований масив, не змінюючи старий:
//Use immutable.js
var arr = Immutable.fromJS([2, 1, 3, 5, 4, 0]);

function render(items) {
return, arr
.sort(function(a, b) {return a < b ? -1 : a > b ? 1 : 0})
.map(function(item){
return '<div>' + item + '</div>';
});
}

render(arr);
console.log(arr.toJS()); // [2, 1, 3, 5, 4, 0]

Більший витрата пам'яті
Кожного разу при модифікації иммутабельного об'єкта створюється його копія з необхідними змінами. Це призводить до більшого витраті пам'яті, ніж при роботі зі звичайним об'єктом. Але оскільки иммутабельные об'єкти ніколи не змінюються, вони можуть бути реалізовані з допомогою стратегії, званої «загальні структури» (structural sharing), яка породжує набагато меншу витрати у витратах на пам'ять, ніж можна було б очікувати. У порівнянні з вбудованими масивами та об'єктами витрата все ще буде існувати, але вона буде мати фіксовану величину і зазвичай може компенсуватися іншими перевагами, доступними завдяки незмінності.
Легше кешувати (мемоизировать)
У більшості випадків кешувати легше не стане. Цей приклад прояснить ситуацію:
var step_1 = Immutable({
data : {
value : 0
}
});
var step_2 = step_1.setIn(['data', 'value'], 1);
var step_3 = step_2.setIn(['data', 'value'], 0);
step_1.data === step_3.data; //false

Незважаючи на те, що
data.value
з першого кроку не відрізняється від
data.value
з останнього кроку, сам об'єкт
data
вже інший, і посилання на нього теж змінилася.
Відсутність побічних ефектів
Це теж неправда:
function test(immutableData) {
var value = immutableData.get('value');
window.title = value;
return immutableData.set('value', 42);
}

Гарантій того, що функція стане чистой, або що у неї будуть відсутні побічні ефекти — ні.
Прискорення коду. Більше простору для оптимізацій
Тут не все так очевидно, і продуктивність залежить від конкретної реалізації иммутабельных структур даних, з якою доводиться працювати. Але якщо взяти і просто заморозити об'єкт за допомогою
Object.freeze
, то звернення до нього та його властивостями швидше не стане, а в деяких браузерах стане навіть повільніше.
Thread safety
JavaScript — однопотоковий, і говорити тут особливо немає про що. Багато плутають асинхронність і багатопоточність — це не одне і теж.
За замовчуванням є тільки один потік, який асинхронно обслуговує чергу повідомлень.
У браузері для багатопоточності є WebWorkers, але єдине можливе спілкування між потоками здійснюється через відправку рядків або сериализованного JSON; до одним і тим же змінним із різних воркеров звернутися не можна.
Можливості мови
Ключове слово const
Використання
const
замість
var
або
let
не говорить про те, що значення є константою або що воно иммутабельно (неизменяемо). Ключове слово
const
просто вказує компілятору стежити за тим, що змінної більше не буде надано жодних інших значень.
У разі використання
const
сучасні JavaScript-движки можуть виконати ряд додаткових оптимізацій.
Приклад:
const obj = { text : 'test'};
obj.text = 'abc';
obj.color = 'red';
console.log(obj); //Object {text: "abc", color: "red"}
obj = {}; //Uncaught TypeError: Assignment to constant variable.(...)

Object.freeze
Метод
Object.freeze
заморожує об'єкт. Це означає, що він запобігає додавання нових властивостей об'єкта, видалення старих властивостей об'єкта і зміна існуючих властивостей або значення їх атрибутів перечисляемости, настраиваемости і записываемости. По суті, об'єкт стає ефективно незмінним. Метод повертає заморожений об'єкт.
Сторонні бібліотеки
Seamless-Immutable
Бібліотека пропонує иммутабельные структури даних, назад сумісні з звичайними масивами і об'єктами. Тобто доступ до значень по ключу або по індексу не буде відрізнятися від звичного, будуть працювати стандартні цикли, а також все це можна використовувати в зв'язці зі спеціалізованими високопродуктивними бібліотеками для маніпуляцій з даними, на зразок Lodash або Underscore.
var array = Immutable(["totally", "immutable", {hammer: "can't Touch This"}]);

array[1] = "i'm going to mutate you!"
array[1] // "immutable"

array[2].hammer = "hm, surely I can mutate this nested object..."
array[2].hammer // "can't Touch This"

for (var index in array) {
console.log(array[index]);
}
// "totally"
// "immutable"
// { hammer: can't Touch This' }

JSON.stringify(array) // '["totally","immutable",{"hammer":"can't Touch This"}]'

Для роботи ця бібліотека використовує
Object.freeze
, а також забороняє використання методів, які можуть змінити дані.
Immutable([3, 1, 4]).sort()
// This will throw an ImmutableError, because sort() is a mutating method.

Деякі браузери, наприклад, Safari, мають проблеми з продуктивністю при роботі з замороженими за допомогою
Object.freeze
об'єктами, так що
production
складанні це відключено для збільшення продуктивності.
Immutable.js
Завдяки просуванню з боку Facebook ця бібліотека для роботи з иммутабельными даними стала найбільш поширеною та популярною серед web-розробників. Вона надає наступні незмінні структури даних:
  • List — иммутабельный аналог JavaScript Array
    var list = Immutable.List([1, 3, 2, 4, 5]);
    console.log(list.size); //5
    list = list.pop().pop(); //[1, 3, 2]
    list = list.push(6); //[1, 3, 2, 6]
    list = list.shift(); //[3, 2, 6]
    list = list.concat(9, 0, 1, 4); //[3, 2, 6, 9, 0, 1, 4]
    list = list.sort(); //[0, 1, 2, 3, 4, 6, 9]
  • Stack — иммутабельный список елементів, організованих за принципом LIFO (last in — first out, «останнім прийшов — першим вийшов»)
    var stack = new Immutable.Stack();
    stack = stack.push( 2, 1, 0 );
    stack.size;
    stack.get(); //2
    stack.get(1); //1
    stack.get(2); //0
    stack = stack.pop(); // [1, 0]
  • Map — иммутабельный аналог JavaScript Object
    var map = new Immutable.Map();
    map = map.set('value', 5); //{value : 5}
    map = map.set('text', 'Test'); //{value : 5, text : "Test"}
    map = map.delete('text'); // {value : 5}
  • OrderedMap — иммутабельный аналог JavaScript Object, що гарантує такий же порядок обходу елементів, який він був при запису
    var map = new Immutable.OrderedMap();
    map = map.set('m', 5); //{m : 5}
    map = map.set('a', 1); //{m : 5, a : 1}
    map = map.set('p', 8); //{m : 5, a : 1, p : 8}
    for(var elem of map) {
    console.log(elem);
    }
  • Set — иммутабельное безліч для зберігання унікальних значень
    var s1 = Immutable.Set( [2, 1] );
    var s2 = Immutable.Set( [2, 3, 3] );
    var s3 = Immutable.Set( [1, 1, 1] );
    console.log( s1.count(), s2.size, s3.count () ); // 2 2 1
    console.log( s1.toJS(), s2.toArray(), s3.toJSON() ); // [2, 1] [2, 3] [1]
    var s1S2IntersectArray = s1.intersect( s2 ).toJSON(); // [2]
  • OrderedSet — иммутабельное безліч для зберігання унікальних значень, що гарантує такий же порядок обходу елементів, який він був при записі.
    var s1 = Immutable.OrderedSet( [2, 1] );
    var s2 = Immutable.OrderedSet( [2, 3, 3] );
    var s3 = Immutable.OrderedSet( [1, 1, 1] );
    var s1S2S3UnionArray = s1.union( s2, s3 ).toJSON();// [2, 1, 3]
    var s3S2S1UnionArray = s3.union( s2, s1 ).toJSON();// [1, 2, 3]
  • Record — конструктор иммутабельных даних зі значеннями за замовчуванням
    var Data = Immutable.Record({
    value: 5
    });
    var Test = Immutable.Record({
    text: ",
    data: new Data()
    });
    var test = new Test();
    console.log( test.get('data').get('value') ); //5 the default value
Mori
Бібліотека, яка привносить персистентные структури даних ClojureScript (Lists, Vectors, Maps і т. д.) в JavaScript.
Відмінності від Immutable.js:
  • Функціональне API без публічних методів
  • Швидше
  • Більший розмір бібліотеки
  • Функціональні плюшки (ледачі колекції і т. д.)
Приклад використання:
var inc = function(n) {
return n+1;
};

mori.intoArray(mori.map(inc, mori.vector(1,2,3,4,5)));
// => [2,3,4,5,6]

//Efficient non-destructive updates!

var v1 = mori.vector(1,2,3);
var v2 = mori.conj(v1, 4);
v1.toString(); // => '[1 2 3]'
v2.toString(); // => '[1 2 3 4]'
var sum = function(a, b) {
return a + b;
};
mori.reduce(sum, mori.vector(1, 2, 3, 4)); // => 10

//Lazy sequences!

var _ = mori;
_.intoArray(_.interpose("foo", _.vector(1, 2, 3, 4)));
// => [1, "foo", 2, "foo", 3, "foo", 4]

Проблеми при розробці, з якими ви зіткнетеся
Мова піде про використання Immutable.js (Mori все приблизно також). У разі роботи з Seamless-Immutable таких проблем у вас не виникне через зворотної сумісності з нативними структурами JavaScript.
Робота з серверним API
Справа в тому, що в більшості випадків серверне API приймає і повертає дані у форматі JSON, який відповідає стандартним об'єктів та масивів з JavaScript. Це означає, що потрібно буде якимось чином перетворювати Immutable-дані в звичайні і навпаки.
Immutable.js для конвертації звичайних даних в иммутабельные пропонує наступну функцію:
Immutable.fromJS(json: any, reviver?: (k: any, v: Iterable<any, any>) => any): any

де з допомогою функції
reviver
можна додавати власні правила перетворення і управляти існуючими.
Припустимо, серверне API повернуло нам наступний об'єкт:
var response = [
{_id : '573b44d91fd2f10100d5f436', value : 1},
{_id : '573dd87b212dc501001950f2', value : 2},
{_id : '5735f6ae2a380401006af05b', value : 3}, 
{_id : '56bdc2e1cee8b801000ff339', value : 4}
]

Найзручніше такий об'єкт буде представити як OrderedMap. Напишемо відповідний
reviver
:
var state = Immutable.fromJS(response, function(k, v){
if(Immutable.Iterable.isIndexed(v)) {
for(var elem of v) {
if(!elem.get('_id')) {
return elem;
}
}
var ordered = [];
for(var elem of v) {
ordered.push([elem.get('_id'), elem.get('value')]);
}
return Immutable.OrderedMap(ordered);
}
return v;
});

console.log(state.toJS());
//Object {573b44d91fd2f10100d5f436: 1, 573dd87b212dc501001950f2: 2, 5735f6ae2a380401006af05b: 3, 56bdc2e1cee8b801000ff339: 4}

Припустимо, нам потрібно змінити дані і відправити їх назад на сервер:
state = state.setIn(['573dd87b212dc501001950f2', 5]);
console.log(state.toJS());
//Object {573b44d91fd2f10100d5f436: 1, 573dd87b212dc501001950f2: 5, 5735f6ae2a380401006af05b: 3, 56bdc2e1cee8b801000ff339: 4}

Immutable.js для конвертації иммутабельных даних у звичайні пропонує наступну функцію:
toJS(): any

Як ви бачите,
reviver
відсутня, а це означає, що доведеться писати власний зовнішній
immutableHelper
. І він якимось чином повинен вміти відрізняти звичайний OrderMap від того, який відповідає структурі ваших вихідних даних. Унаследоваться від OrderMap ви теж не можете. У цьому додатку структури, швидше за все, виявляться вкладеними, що додасть вам додаткових складнощів.
Можна, звичайно, використовувати при розробці тільки List і Map, але тоді навіщо ж все інше? І в чому плюси використання конкретно Immutable.js?
Иммутабельность скрізь
Якщо проект раніше працював з нативними структурами даних, то легко перейти на иммутабельные не вийде. Доведеться переписувати весь код, який хоч якось взаємодіє з даними.
Серіалізація/Десериализация
Immutable.js не пропонує нам нічого крім функцій
fromJS
,
toJS
, які працюють наступним чином:
var set = Immutable.Set([1, 2, 3, 2, 1]);
set = Immutable.fromJS(set.toJS());
console.log(Immutable.Set.isSet(set)); //false
console.log(Immutable.List.isList(set)); //true

тобто абсолютно марні для серіалізації/десеріалізації.
Існує стороння бібліотека transit-immutable-js. Приклад її використання:
var transit = require('transit-immutable-js');
var Immutable = require('immutable");

var m = Immutable.Map({with: "Some", data: "In"});
var str = transit.toJSON(m);
console.log(str) // ["~#cmap",["з","Some","data","In"]]

var m2 = transit.fromJSON(str);
console.log(Immutable.is(m, m2));// true

Продуктивність
Для тестування продуктивності були написані бенчмарки. Щоб запустити їх у себе, виконайте команди:
git clone https://github.com/MrCheater/immutable-benchmarks.git
cd ./immutable-benchmarks
npm install
npm start

Результати бенчмарків можна побачити на графіках (який repeats / ms). Чим більше час виконання, тим гірше результат.
При читанні найшвидшими виявилися нативні структури даних і Seamless-immutable.
Read
При запису найшвидшим виявився Mori. Seamless-immutable показав найгірший результат.
Write
Висновок
Ця стаття буде корисна JavaScript-розробникам, які зіткнулися з необхідністю використовувати иммутабельные дані у своїх програмах для підвищення продуктивності. Зокрема, це стосується frontend-розробників, які працюють з фреймворками, використовують VirtualDOM React, Mithril, Vue, Riot), а також Flux/Redux рішення.
Підводячи підсумки, можна сказати, що найбільш зручна і проста у використанні утиліта для підтримки иммутабельности в JavaScript-додатках це Seamless-immutable. Найстабільніша і поширена — Immutable.js. Найшвидша і незвичайна — Mori. Сподіваюся дане дослідження допоможе вибрати вам рішення для свого проекту. Удачі.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.