16 липня 2023 р.

WeakMap та WeakSet

Як ми знаємо з розділу Збирання сміття, рушій JavaScript зберігає значення в пам’яті, поки воно є “доступним” і потенційно може бути використаним.

Наприклад:

let john = { name: "Іван" };

// Об’єкт можна отримати, john -- це посилання на нього

// перезапишемо посилання
john = null;

// об’єкт буде видалено з пам’яті

Зазвичай властивості об’єкта або елементів масиву або іншої структури даних вважаються доступними та зберігаються в пам’яті, поки та структура даних є в пам’яті.

Наприклад, якщо ми покладемо об’єкт в масив, то, поки масив живий, об’єкт буде живим, навіть якщо немає інших посилань на цей об’єкт.

Ось так:

let john = { name: "Іван" };

let array = [ john ];

john = null; // перезапишемо посилання

// об’єкт, на який раніше посилалася змінна john, зберігається всередині масиву
// тому він не буде видалений збирачем сміття
// ми можемо отримати його як array[0]

Подібно до цього, якщо ми використовуємо об’єкт як ключ у звичайному Map, то в той час, коли Map існує, цей об’єкт також існує. Він займає пам’ять і не може бути видалений збирачем сміття.

Наприклад:

let john = { name: "Іван" };

let map = new Map();
map.set(john, "...");

john = null; // перезапишемо посилання

// john зберігається всередині map,
// ми можемо отримати його, використовуючи map.keys()

WeakMap – принципово відрізняється в цьому аспекті. Він не перешкоджає збиранню сміття серед об’єктів, що є ключами.

Подивимося, що це означає на прикладах.

WeakMap

Перша відмінність між Map та WeakMap – це те, що ключі повинні бути об’єктами, а не примітивними значеннями:

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ок"); // працює (об’єкт є ключем)

// не можна використовувати рядок як ключ
weakMap.set("тест", "Ой!"); // Помилка, тому що "тест" не є об’єктом

Тепер, якщо ми використовуємо об’єкт як ключ, і немає інших посилань на цей об’єкт – його буде видалено з пам’яті (і з мапи) автоматично.

let john = { name: "Іван" };

let weakMap = new WeakMap();
weakMap.set(john, "...");

john = null; // перезапишемо посилання

// john видалено з пам’яті!

Порівняйте його зі звичайним Map, що наведений вище. Тепер, якщо john існує лише як ключ WeakMap – він буде автоматично видалений з мапи (і з пам’яті).

WeakMap не підтримує ітерацію та методи keys(), values(), entries(), тому немає способу отримати всі ключі або значення від нього.

WeakMap має лише такі методи:

Чому є таке обмеження? Це з технічних причин. Якщо об’єкт втратив всі інші посилання (наприклад, john у коді вище), то він буде автоматично видалений збирачем сміття. Але технічно немає точних вказівок коли відбувається видалення.

Рушій JavaScript вирішує це. Він може вибрати очищення пам’яті негайно або почекати, і зробити очищення пізніше, коли трапиться більше видалень. Отже, технічно, поточна кількість елементів WeakMap невідома. Рушій, можливо, очистив його чи ні, або зробив це частково. З цієї причини методи, які дають доступ до всіх ключів/значень не підтримуються.

Отже, де нам потрібна така структура даних?

Приклад використання: додаткові дані

Основна область застосування для WeakMap – це зберігання додаткових даних.

Якщо ми працюємо з об’єктом, що “належить” до іншого коду, можливо навіть сторонньої бібліотеки, і хотіли б зберегти деякі дані, пов’язані з ним, що повинні існувати лише поки об’єкт живий – тоді WeakMap цє саме те, що потрібно.

Ми покладемо дані в WeakMap, використовуючи об’єкт як ключ, і коли об’єкт буде видалено збирачем сміття, то ці дані також автоматично зникнуть.

weakMap.set(john, "секретні документи");
// якщо об’єкт john зникне, секретні документи будуть знищені автоматично

Подивімося на приклад.

У нас є код, який зберігає кількість відвідувань користувачів. Інформація зберігається в мапі: об’єкт користувачів є ключем, а кількість відвідувань – це значення. Коли користувач зникає (його об’єкт видаляється збирачем сміття), ми більше не хочемо зберігати його кількість відвідувань.

Ось приклад функції підрахунку з Map:

// 📁 visitsCount.js
let visitsCountMap = new Map(); // мапа: користувач => кількість відвідувань

// збільшити кількість відвідувань
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

І ось ще одна частина коду, можливо, інший файл використовує це:

// 📁 main.js
let john = { name: "Іван" };

countUser(john); // рахує його візити

// пізніше john покидає нас
john = null;

Зараз, об’єкт john повинен бути видалений збирачем сміттям, але залишається в пам’яті тому, що це ключ visitsCountMap.

Нам потрібно очищувати visitsCountMap, коли ми видаляємо користувачів, інакше він буде рости в пам’яті необмежено довго. Таке очищення може стати нудним завданням у складних архітектурах.

Ми можемо уникнути цього, перейшовши на WeakMap:

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // weakmap: користувач => кількість відвідувань

// збільшити кількість відвідувань
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

Тепер ми не повинні очищати visitsCountMap. Після того, як об’єкт john стає недоступним будь-яким способом, за винятком того, як ключ WeakMap, він видаляється з пам’яті, разом з інформацією за цим ключем в WeakMap.

Приклад використання: кешування

Іншим загальним прикладом є кешування. Ми можемо зберігати (“кеш”) результати з функції, щоб майбутні виклики на тому ж об’єкті могли повторно використовувати його.

Щоб досягти цього, ми можемо використовувати Map (не оптимальний сценарій):

// 📁 cache.js
let cache = new Map();

// обчислити та запам’ятати результат
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* розрахунки результату для */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

// Тепер ми використовуємо process() в іншому файлі:

// 📁 main.js
let obj = {/* скажімо, у нас є об’єкт */};

let result1 = process(obj); // розраховано

// ...пізніше, з іншого місця в коді...
let result2 = process(obj); // запам’ятований результат, взятий з кешу

// ...пізніше, коли об’єкт більше не потрібний:
obj = null;

alert(cache.size); // 1 (Ой! Об’єкт досі в кеші і займає пам’ять!)

Для багаторазових викликів process(obj) з тим самим об’єктом він лише обчислює результат вперше, а потім просто бере його з cache. Недоліком є те, що нам потрібно чистити cache, коли об’єкт більше не потрібний.

Якщо ми замінимо Map на WeakMap, то ця проблема зникає. Кешований результат буде видалено з пам’яті автоматично після того, як об’єкт видаляється збирачем сміття.

// 📁 cache.js
let cache = new WeakMap();

// обчислити та запам’ятати результат
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* розрахувати результат для */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

// 📁 main.js
let obj = {/* якийсь об’єкт */};

let result1 = process(obj);
let result2 = process(obj);

// ...пізніше, коли об’єкт більше не потрібний:
obj = null;

// Не можна отримати cache.size тому, що це WeakMap,
// але це 0 або незабаром буде 0
// Коли obj видаляється збирачем сміття, кешовані дані будуть вилучені також

WeakSet

WeakSet поводитися аналогічно:

  • Це аналог Set, але ми можемо додати лише об’єкти до WeakSet (не примітиви).
  • Об’єкт існує в наборі, коли він доступний з де-небудь ще.
  • Так само як Set, він підтримує add, has і delete, але не підтримує size, keys() та ітерацію.

Будучи “слабким”, він також служить зберігання додаткових даних. Але не для довільних даних, а для фактів “так/ні”. Приналежність до WeakSet може означати щось про об’єкт.

Наприклад, ми можемо додати користувачів до WeakSet, щоб відстежувати тих, хто відвідав наш сайт:

let visitedSet = new WeakSet();

let john = { name: "Іван" };
let pete = { name: "Петро" };
let mary = { name: "Марія" };

visitedSet.add(john); // Іван відвідав нас
visitedSet.add(pete); // Потім Петро
visitedSet.add(john); // Знову Іван

// visitedSet має зараз 2-ох користувачів

// перевірте, чи відвідав Іван?
alert(visitedSet.has(john)); // true

// перевірте, чи відвідала Марія?
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet буде очищено автоматично

Найбільш помітним обмеженням WeakMap та WeakSet є відсутність ітерацій та нездатність отримати весь поточний вміст. Це може виявитися незручним, але не перешкоджає WeakMap/WeakSet виконувати свою основну роботу – бути «додатковим» сховищем даних для об’єктів, які зберігаються/управляються в іншому місці.

Підсумки

WeakMap – це подібна до Map колекція, яка дозволяє використовувати лише об’єкти, як ключі і видаляє їх разом з пов’язаним значенням, коли вони стануть недоступними іншим засобам.

WeakSet – це подібна до Set колекція, яка зберігає тільки об’єкти та видаляє їх після того, як вони стануть недоступними іншим засобам.

Їх основна перевага полягає у тому, що вони мають слабке посилання на об’єкти, тому вони можуть бути легко видаленими збирачем сміття.

Це досягається внаслідок відсутності підтримки clear, size, keys, values

WeakMap та WeakSet використовуються як “вторинні” структури даних, на додаток до “первинного” сховища об’єктів. Після того, як об’єкт видаляється з первинного сховища, якщо він виявляється лише як ключ WeakMap або в WeakSet, він буде очищений автоматично.

Завдання

важливість: 5

Є масив повідомлень:

let messages = [
  {text: "Привіт", from: "Іван"},
  {text: "Як справи?", from: "Іван"},
  {text: "До зустрічі", from: "Аліса"}
];

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

Тепер, яку структуру даних ви могли б використати для зберігання інформації про те, чи було повідомлення прочитаним? Структура повинна добре підходити, щоб дати відповідь на питання “чи він прочитаний?” для об’єкта даного повідомлення.

P.S. Коли повідомлення видаляється з messages, воно також повинне зникнути з вашої структури.

P.P.S. Ми не повинні змінювати об’єкти повідомлення, додаючи до них наші властивості. Оскільки вони керуються іншим кодом, це може призвести до поганих наслідків.

Збережемо прочитані повідомлення у WeakSet:

let messages = [
  {text: "Привіт", from: "Іван"},
  {text: "Як справи?", from: "Іван"},
  {text: "До зустрічі", from: "Аліса"}
];

let readMessages = new WeakSet();

// були прочитані два повідомлення
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages має 2 елементи

// ...давайте знову прочитаємо перше повідомлення!
readMessages.add(messages[0]);
// readMessages все ще має 2 унікальних елементів

// відповідь: чи було messages[0] прочитано?
alert("Прочитано повідомлення 0: " + readMessages.has(messages[0])); // true

messages.shift();
// зараз readMessages має 1 елемент (з технічної точки зору пам’ять може бути очищена пізніше)

WeakSet дозволяє зберігати набір повідомлень і легко перевірити наявність повідомлення в наборі.

Він автоматично очищає себе. Компроміс полягає в тому, що ми не можемо ітеруватися через нього, не можемо отримати “всі прочитані повідомлення” від нього безпосередньо. Але ми можемо це зробити, ітеруючись через всі повідомлення та відфільтрувавши тих, що знаходяться у наборі.

Інше рішення може полягати у додаванні властивості message.isRead=true до повідомлення після його прочитання. Оскільки об’єкти повідомлень керуються іншим кодом, це, як правило, збентежує, але ми можемо використовувати символьну властивість, щоб уникнути конфліктів.

Ось так:

// символьна властивість відома лише в нашому коді
let isRead = Symbol("isRead");
messages[0][isRead] = true;

Тепер сторонній код, ймовірно, не побачить нашу додаткову властивість.

Незважаючи на те, що символи дозволяють знизити ймовірність проблем, використання WeakSet краще з архітектурної точки зору.

важливість: 5

Є масив повідомлень, як у попередньому завдані. Ситуація схожа.

let messages = [
  {text: "Привіт", from: "Іван"},
  {text: "Як справи?", from: "Іван"},
  {text: "До зустрічі", from: "Аліса"}
];

Зараз питання наступне: яку структуру даних ви б запропонували для того, щоб зберегти інформацію: “Коли повідомлення було прочитано?”.

У попередньому завданні нам потрібно лише зберігати інформацію “так/ні”. Тепер нам потрібно зберігати дату, і це повинно залишитися в пам’яті лише доки повідомлення не буде видалено.

P.S. Дати можуть зберігатися як об’єкти вбудованого класу Data, що ми розглянемо пізніше.

Щоб зберегти дату, ми можемо використовувати WeakMap:

let messages = [
  {text: "Привіт", from: "Іван"},
  {text: "Як справи?", from: "Іван"},
  {text: "До зустрічі", from: "Аліса"}
];

let readMap = new WeakMap();

readMap.set(messages[0], new Date(2017, 1, 1));
// об’єкт Date ми розглянемо пізніше
Навчальна карта