۱۶ نوامبر ۲۰۲۲

ساختارهای WeakMap و WeakSet

همانطور که از فصل زباله‌روبی می‌دانیم، موتور جاوااسکریپت تا زمانی که یک مقدار «قابل دسترس» باشد و ممکن باشد استفاده شود، آن را در حافظه نگه می‌دارد.

برای مثال:

let john = { name: "John" };

// به آن رجوع می‌کند john ،می‌توان به شیء دسترسی پیدا کرد

// بازنویسی مرجع
john = null;

// شیء از حافظه پاک می‌شود

معمولا ویژگی‌های یک شیء یا المان‌های یک آرایه یا ساختارهای دیگر داده تا زمانی که در حافظه باشد، قابل دسترس فرض و در حافظه حفظ می‌شوند.

برای مثال، اگر ما یک شیء را درون یک آرایه بگذاریم، سپس تا زمانی که آرایه زنده باشد، شیء هم زنده خواهد بود، حتی اگر هیچ رجوع دیگری به آن نباشد.

مانند اینجا:

let john = { name: "John" };

let array = [ john ];

john = null; // بازنویسی مرجع

// به آن رجوع می‌شد، درون آرایه ذخیره شده است john شیءای که قبلا توسط
// به همین دلیل زباله‌روبی نمی‌شود
// آن را دریافت کنیم array[0] می‌توانیم با

مشابه همین مورد، اگر ما از شیءای به عنوان کلید در یک Map معمولی استفاده کنیم، سپس تا زمانی که Map وجود داشته باشد، آن شیء هم وجود خواهد داشت. این شیء حافظه را اشغال می‌کند و زباله‌روبی نمی‌شود.

برای مثال:

let john = { name: "John" };

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

john = null; // بازنویسی مرجع

// ،ذخیره شده است map درون john
// آن را دریافت کنیم map.keys() می‌توانیم با استفاده از

WeakMap به صورت اساسی از این جنبه تفاوت دارد. این ساختار از زباله‌روبی کلیدهایی که شیء هستند جلوگیری نمی‌کند.

بیایید با مثال‌ها ببینیم که به چه معنی است.

ساختار WeakMap

اولین تفاوت بین Map و WeakMap این است که کلیدها باید شیء باشند نه مقدار اولیه:

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // به درستی کار می‌کند (کلید از نوع شیء)

// نمی‌توان از رشته به عنوان کلید استفاده کرد
weakMap.set("test", "Whoops"); // شیء نیست "test" ارور می‌دهد چون

حالا اگر ما بخواهیم از شیء به عنوان کلید در آن استفاده کنیم و هیچ رجوع دیگری به شیء نباشد – این شیء به طور خودکار از حافظه پاک می‌شود (همچنین از map).

let john = { name: "John" };

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

john = null; // بازنویسی مرجع

// !از حافظه پاک شد john

با Map معمولی در مثال بالا مقایسه کنید. حالا اگر john فقط به عنوان کلید WeakMap وجود داشته باشد – به صورت خودکار از map (و حافظه) پاک می‌شود.

ساختار WeakMap از حلقه‌زدن و متدهای keys()، values()، entries() پشتیبانی نمی‌کند، پس هیچ راهی برای گرفتن تمام کلیدها یا مقدارها نیست.

WeakMap فقط متدهای زیر دارد:

چرا چنین محدودیت‌هایی وجود دارد؟ به خاطر دلایل فنی. اگر یک شیء تمام رجوع‌های دیگر به خود را از دست بدهد (مانند john در کد بالا)، سپس باید به طور خودکار زباله‌روبی شود. اما از لحاظ فنی مشخص نیست که زباله‌روبی چه زمانی اتفاق می‌افتد.

موتور جاوااسکریپت درباره آن تصمیم می‌گیرد. ممکن است پاک‌سازی حافظه را بلافاصله انجام دهد یا صبر کند تا حذف‌های بیشتری رخ دهند. پس به طور فنی، تعداد کنونی المان‌های WeakMap معلوم نیست. موتور ممکن است آن را پاک کرده باشد یا این کار را در چند قسمت انجام دهد. به این دلیل، متدهایی که به تمام کلیدها/مقدارها دسترسی پیدا می‌کنند، پشتیبانی نمی‌شوند.

حالا ما کجا به چنین ساختار داده‌ای احتیاج داریم؟

کاربرد: داده اضافی

حوزه اصلی کاربرد WeakMap یک حافظه داده اضافی است.

اگر در حال کار کردن با شیءای هستیم که به کد دیگری «تعلق دارد»، شاید یک کتابخانه شخص ثالث، و بخواهیم داده‌هایی که به آن تخصیص داده شده را ذخیره کنیم که فقط تا زمانی که شیء زنده است وجود داشته باشند، سپس WeakMap دقیقا چیزی است که نیاز داریم.

ما با استفاده از شیء به عنوان کلید، داده را در یک WeakMap قرار می‌دهیم و زمانی که شیء زباله‌روبی شد، داده هم به طور خودکار ناپدید می‌شود.

weakMap.set(john, "مستندات مخفی");
// ازبین برود، مستندات مخفی هم به طور خودکار نابود می‌شوند john اگر

بیایید یک مثال ببینیم.

برای مثال، ما کدی داریم که تعداد بازدید را برای کاربران ذخیره می‌کند. اطلاعات درون یک map ذخیره شده است: یک شیء user کلید است و تعداد بازدید مقدار است. زمانی که کاربر خارج شود (شیء آن زباله‌روبی شود)، ما دیگر نمی‌خواهیم تعداد بازدید آنها را داشته باشیم.

یک مثال از تابع شمارنده با استفاده از Map:

// 📁 visitsCount.js
let visitsCountMap = new Map(); // map: user => تعداد بازدید

// افزایش تعداد بازدید
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

و اینجا قسمت دیگری از کد را داریم، شاید یک فایل دیگر از آن استفاده کند:

// 📁 main.js
let john = { name: "John" };

countUser(john); // را می‌شمارد john تعداد بازدید

// ما را ترک کند john بعدا که
john = null;

حالا، شیء john باید زباله‌روبی شود اما در حافظه می‌ماند، به دلیل اینکه در visitsCountMap کلید است.

ما نیاز داریم که visitsCountMap را زمانی که کاربران را حذف می‌کنیم پاک کنیم، در غیر این صورت به طور نامحدود در حافظه گسترده‌تر می‌شود. چنین پاک کردنی در معماری‌های پیچیده کاری خسته‎کننده می‌شود.

می‌توانیم با استفاده از WeakMap از این موضوع دوری کنیم:

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // weakmap: user => تعداد بازدید

// افزایش تعداد بازدید
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

حالا ما حتما نباید visitsCountMap را تمیز کنیم. بعد از اینکه شیء john غیرقابل دسترس شود، یعنی به جز کلید WeakMap هیچ رجوعی نداشته باشد، همراه با اطلاعاتی که کلید آنها این شیء بود، از حافظه پاک می‌شوند.

کاربرد: کَش کردن (caching)

یکی دیگر از مثال‌های متداول کَش کردن است. ما می‌توانیم نتایج یک تابع را ذخیره («کَش») کنیم تا فراخوانی‌های آینده که شیء یکسانی را می‌گیرند، دوباره از آن استفاده کنند.

برای این کار، ما می‌توانیم از 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;

// است WeakMap را دریافت کرد، چون یک  cache.size نمی‌توان
// اما یا 0 است یا به زودی 0 می‌شود
// زباله‌روبی شود، داده کش‌شده هم پاک می‌شود obj زمانی که

ساختار WeakSet

WeakSet هم رفتار مشابهی دارد:

  • این ساختار مانند Set است اما فقط می‌توانیم شیءها را به WeakSet اضافه کنیم (نه مقدارهای اصلی).
  • یک شیء تا زمانی که از جایی دیگر قابل دسترس باشد در set وجود خواهد داشت.
  • مانند Set، این ساختار از add، has و delete پشتیبانی می‌کند اما size و keys() ندارد و نمی‌توان در آن حلقه زد.

به دلیل اینکه “weak(ضعیف)” است، به عنوان حافظه اضافی هم نقشش را ایفا می‌کند. اما نه برای هر داده دلخواهی، بلکه ترجیحا برای گزاره‌های «بله/خیر» استفاده می‌شود. اینکه یک شیء در WeakSet وجود داشته باشد، ممکن است به معنای چیزی درباره آن باشد.

برای مثال، ما می‌توانیم کاربران را به WeakSet اضافه کنیم تا پیگیری کنیم که چه کسی سایت ما را دیده است:

let visitedSet = new WeakSet();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

visitedSet.add(john); // سایت ما را دید John
visitedSet.add(pete); // Pete سپس
visitedSet.add(john); // John و دوباره

// الان 2 کاربر دارد visitedSet

// سایت را دیده است؟ John چک می‌کنیم که آیا
alert(visitedSet.has(john)); // true

// سایت را دیده است؟ Mary چک می‌کنیم که آیا
alert(visitedSet.has(mary)); // false

john = null;

// به طور خودکار از آن شیء تمیز می‌شود visitedSet

بزرگ‌ترین محدودیت WeakMap و WeakSet این است که نمی‌توان در آن حلقه زد و تمام محتوای حال حاضر را گرفت. این موضوع ممکن است اذیت کننده باشد اما باعث نمی‌شود که WeakMap/WeakSet کار اصلی خودشان را انجام ندهند – اینکه یک حافظه «اضافیِ» داده، برای شیءهایی باشند که در جایی دیگر ذخیره/مدیریت می‌شوند.

Summary

WeakMap یک مجموعه Map-like است که فقط شیءها را به عنوان کلید قبول می‌کند و همان شیء و داده‌های تخصیص داده شده به آن را زمانی که شیء از طرق دیگر غیر قابل دسترس شود، حذف می‌کند.

WeakSet یک مجموعه Set-like است که فقط شیءها را ذخیره می‌کند و زمانی که آنها از طرق دیگر غیر قابل دسترس شوند، حذف‌شان می‌کند.

فایده‌های اصلی‌شان این است که یک رجوع ضعیف به شیءها دارند، پس شیءها به راحتی می‌توانند با زباله‌روبی ازبین بروند.

اما به این قیمت که از clear، size، keys، values و… پشتیبانی نمی‌کنند.

WeakMap و WeakSet به عنوان ساختار داده‌های «ثانویه» در کنار حافظه شیء «اصلی» استفاده می‌شوند. زمانی که شیء از حافظه اصلی حذف شود و فقط به عنوان کلید WeakMap یا عضوی در WeakSet باشد، به طور خودکار حذف می‌شود.

تمارین

اهمیت: 5

یک آرایه از پیام‌هایی داریم:

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

کد شما می‌تواند به آن دسترسی پیدا کند اما پیام‌ها توسط کد شخص دیگری مدیریت می‌شود. پیام‌های جدید اضافه می‌شوند و قدیمی‌ها توسط آن کد به طور منظم حذف می‌شوند و شما نمی‌دانید دقیقا کی اتفاق می‌افتد.

حالا شما کدام ساختار داده را استفاده می‌کنید تا اطلاعاتی درباره اینکه پیام «خوانده شده یا نه» را دخیره کنید؟ ساختار باید برای جواب دادن به این سوال که «آیا خوانده شد؟» برای شیء داده شده به خوبی پاسخ دهد.

پی‌نوشت: زمانی که یک پیام از messages حذف شود، باید از ساختار شما هم حذف شود.

پی‌نوشت دوم: ما نباید شیء‌های پیام را تغییر دهیم یا ویژگی‌های خودمان را به آنها اضافه کنیم. به دلیل اینکه آنها توسط کد شخص دیگری کنترل می‌شوند، این کار ممکن است نتایج بدی داشته باشد.

بیایید پیام‌های خوانده شده را در WeakSet ذخیره کنیم:

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

let readMessages = new WeakSet();

// دو پیام خوانده شد
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// دو المان دارد readMessages

// !بیایید اولین پیام را دوباره بخوانیم...
readMessages.add(messages[0]);
// همچنان دو المان یکتا دارد readMessages

// خوانده شده است؟ message جواب: آیا
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// یک المان دارد (از لحاظ فنی، حافظه ممکن است بعدا از آن المان تمیز شود) readMessages حالا

ساختار WeakSet به ما این امکان را می‌دهد که یک دسته از پیام‌ها را ذخیره کنیم و به راحتی بررسی کنیم که پیامی درون آن هست یا نه.

این ساختار به طور خودکار محتوای دورنش را پاک می‌کند. اما بدی آن این است که ما نمی‌توانیم درون آن حلقه بزنیم، نمی‌توانیم به طور مستقیم «تمام پیام‌های خوانده شده» را از آن بگیریم. اما می‌توانیم این کار را با حلقه‌زدن درون تمام پیام‌ها و جداسازی آن‌هایی که درون set هستند، انجام دهیم.

یک راه حل متفاوت دیگر می‌تواند اضافه کردن ویژگی message.isRead=true به پیام، بعد از اینکه خوانده شد باشد. به دلیل اینکه شیءهای پیام‌ها توسط کد دیگری انجام می‌شود، این کار توصیه نمی‌شود اما می‌توانیم از ویژگی سمبلی برای جلوگیری از تناقضات استفاده کنیم.

مثلا اینگونه:

// ویژگی سمبلی تنها در کد ما شناخته شده است
let isRead = Symbol("isRead");
messages[0][isRead] = true;

حالا کد شخص ثالث احتمالا ویژگی اضافی ما را نخواهد دید.

اگرچه سمبل‌ها به ما این امکان را می‌دهند که از احتمال بروز مشکل را کم کنیم، استفاده از WeakSet از نظر معماری بهتر است.

اهمیت: 5

یک آرایه از پیام‌ها مانند تکلیف قبلی داریم. وضعیت هم مشابه است.

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

حالا سوال این است: کدام ساختار داده را برای ذخیره اطلاعات «در چه تاریخی پیام خوانده شد؟» استفاده می‌کنید.

در تکلیف قبلی ما فقط نیاز داشتیم که «بله/خیر» را ذخیره کنیم. حالا نیاز داریم که تاریخ را ذخیره کنیم و باید تا زمانی که پیام زباله‌روبی شود باقی بماند.

پی‌نوشت: تاریخ‌ها می‌توانند به عنوان شیء از کلاس درون ساخت Date ذخیره شوند که بعدا آن را پوشش می‌دهیم.

برای ذخیره یک تاریخ، می‌توانیم از WeakMap استفاده کنیم:

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

let readMap = new WeakMap();

readMap.set(messages[0], new Date(2017, 1, 1));
// را بعدا می‌آموزیم Date شیء
نقشه آموزش