٢٥ يونيو ٢٠٢٠

النوع WeakMap والنوع WeakSet: الخرائط والأطقم ضعيفة الإشارة

كما عرفنا من فصل «كنس المهملات»، فمُحرّك جافاسكربت يخُزّن القيمة في الذاكرة طالما يمكن أن يصل لها شيء (أي يمكن استعمالها لاحقًا). هكذا:

let john = { name: "John" };

// ‫يمكننا الوصول إلى الكائن، فـ john هو الإشارة إليه

// عوّض تلك الإِشارة
john = null;

// سيُزال الكائن من الذاكرة

عادةً ما تكون خاصيات الكائن أو عناصر المصفوفة أو أية بنية بيانات أخرى – عادةً ما تُعدّ “مُتاحة لباقي الشيفرة” ويُبقيها المحرّك في الذاكرة طالما بنية البيانات نفسها في الذاكرة.

لنفترض أنّا وضعنا كائنًا في مصفوفة، طالما المصفوفة موجودة ومُشار إليها، فسيكون الكائن موجودًا هو الآخر حتّى لو لم يكن هناك ما يُشير إليه. مثلما في هذه الشيفرة:

let john = { name: "John" };

let array = [ john ];

john = null; // عوّض الإشارة

// ‫الكائن john مخزّن داخل مصفوفة ولن يُكنس باعتباره مهملات
// ‫إذ يمكننا أخذه بهذه: array[0]‎

وبنفس المفهوم، لو استعملنا كائنًا ليكون مفتاحًا في خارطة Map عادية، فسيبقى هذا الكائن موجدًا طالما الخارطة تلك موجودة، ويشغل الذاكرة مانعًا عملية كنس المهملات من تحريرها. إليك هذا المثال:

let john = { name: "John" };

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

john = null; // عوّض الإشارة

// ‫الكائن john مخزّن داخل خارطة
// ‫ويمكننا أخذه بهذه: map.keys()‎

على العكس فالخارطة ضعيفة الإشارة WeakMap مختلفة جذريًا عن هذا، فلا تمنع كنس مهملات أيٍّ من مفاتيحها الكائنات. لنأخذ بعض الأمثلة لتُدرك القصد هنا.

WeakMap

أولى اختلافات الخارطة ضعيفة الإشارة WeakMap عن تلك العادية Map هي أنّها تُلزم مفاتيحها بأن تكون كائنات لا أنواع أولية:

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // لا مشاكل (المفتاح كائن)

// لا يمكن استعمال السلسلة النصية مفتاحًا
weakMap.set("test", "Whoops"); // ‫خطأ، لأنّ ”test“ ليس كائنًا

بعد ذلك لو استعملنا أحد الكائنات ليكون مفتاحًا فيها، ولم يكن هناك ما يُشير إلى هذا الكائن، فسيُزال الكائن من الذاكرة (والخارطة) تلقائيا.

let john = { name: "John" };

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

john = null; // عوّض الإشارة

// ‫أُزيل الكائن john من الذاكرة!

وازِن هذه الشيفرة بشيفرة الخارطة Map أعلاه. الآن حتى لو لم يكن john موجودًا إلا مفتاحًا لِـ WeakMap، فسيُحذف تلقائيًا من الخارطة (ومن الذاكرة).

لا تدعم الخارطة ضعيفة الإشارة WeakMap التكرار (iteration) ولا التوابِع keys()‎ أو values()‎ أو entries()‎، ولهذا لا نقدر على أخذ كلّ المفاتيح أو القيم التي فيها. بل أنّ للخارطة WeakMap التوابِع الآتية:

  • weakMap.get(key)‎
  • weakMap.set(key, value)‎
  • weakMap.delete(key)‎
  • weakMap.has(key)‎

تفكّر بسبب وجود هذا التقييد؟ الجواب هو: أسباب تقنية. عُدّ الكائن الآن قد فقد كلّ إشارة له (مثلما حصل مع الكائن john في الشيفرة أعلاه)، بهذا ستُكنس مهملاته تلقائيًا، ولكن… وقت حدوث هذا الكنس غير موضّح تقنيًا. الواقع أنّ محرّك جافاسكربت يُحدّد ذلك: هو يُحدّد متى يمسح الذاكرة، الآن حالًا أو بعد قليل حتّى تحدث عمليات حذف أخرى. لذا فعدد العناصر الحالي داخل WeakMap غير معلوم تقنيًا، ربما يكون المحرّك حذفها كلها أو لم يحذفها، أو حذف بعضها، لا نعلم. لهذا السبب لا تدعم اللغة التوابِع التي تحاول الوصول إلى كلّ القيم والعناصر.

الآن بعدما عرفناها، في أيّ حالات نستعمل هذه البنية من البيانات؟

استعمالاتها: بيانات إضافية

المجال الرئيسي لتطبيقات WeakMap هي تخزين البيانات الإضافية.

لو كنّا نتعامل مع كائن ”ينتمي“ إلى شيفرة أخرى (وحتّى مكتبة من طرف ثالث) وأردنا تخزين بيانات معيّنة لترتبط بها، وهذه البيانات لا تكون موجودة إلا لو كان الكائن موجودًا، فَـ WeakMap هي ما نريد تمامًا: نضع البيانات في خارطة بإشارة ضعيفة WeakMap (مستعملين الكائن مفتاحًا لها). متى ما كُنس الكائن باعتباره مهملات، ستختفي تلك البيانات معه أيضًا.

weakMap.set(john, "secret documents");
// ‫إن مات 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: "John" };

countUser(john); // عُدّ الزوّار
countUser(john);

// ‫بعدها يغادر john الحفلة
john = null;

هكذا ”يُفترض“ أن يُكنس الكائن john باعتباره مهملات، لكنّه سيبقى في الذاكرة إذ تستعمله الخارطة visitsCountMap مفتاحًا فيها.

علينا مسح visitsCountMap حين نُزيل المستخدمين وإلا فسيزيد حجمها في الذاكرة إلى آباد الآبدين. لو كانت بنية البرمجية معقّدة، فستكون عملية المسح هذه مرهقة جدًا وغير عملية. لهذا يمكننا تجنّب التعب واستعمال WeakMap بدل العادية:

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // خارطة بإشارة ضعيفة: المستخدم => عدد زياراته

// تزيد عدد الزيارات
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

هكذا لا نمسح visitsCountMap يدويًا بل نترك للمحرّك القرار: لو لم يكن هناك ما يُشير إلى الكائن john عدا مفتاح WeakMap، سيحرّره من الذاكرة مع المعلومات التي في ذلك المفتاح داخل الخارطة ضعيفة الإشارة WeakMap.

استعمالاتها: الخبيئة

يكثُر أيضًا استعمال الخرائط للخبيئة، أي حين علينا تذكّر ناتج الدالة (تخبئته ”cached“) كي يستعمل أيّ استدعاء لاحِق على هذا العنصر تلك الخبيئة.

يمكن أن نستعمل الخارطة Map لتخزين النتائج:

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

// نحسب النتيجة ونتذكرها
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* حسابات الكائن هذا */ obj;

    cache.set(obj, 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 cache.get(obj);
}

// 📁 main.js
let obj = {/* كائن من الكائنات */};

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

// ‫...بعدها، لو لم نرد الكائن بعد الآن:
obj = null;

هنا، ‫لا يمكن أن نعرف cache.size إذ أنها خارطة بإشارة ضعيفة، ولكن الحجم صفر، أو سيكون صفر قريبًا؛ فما أن تبدأ عملية كنس المهملات على الكائن، ستُزال البيانات المُخبّأة هي الأخرى.

WeakSet

حتّى الأطقم ضعيفة الإشارة WeakSet تسلك ذات السلوك:

  • تشبه الأطقم العادية Set ولكن لا يمكننا إلّا إضافة الكائنات إلى WeakSet (وليس الأنواع الأولية).
  • يبقى الكائن موجودًا في الطقم طالما هناك ما يصل إليه.
  • ويدعم -كما تدعم Set- التوابِع add وhas وdelete، ولكن لا تدعم size أو keys()‎ أو التعداد.

هي الأخرى تخدمنا نحن المطورون في تخزين البيانات الإضافية (إذ أنّ الإشارة إليها ”ضعيفة“)، ولكنها ليست لأيّ بيانات كانت، بل فقط التي تُعطي إجابة ”نعم/لا“. لو كان الكائن موجودًا داخل طقم بإشارة ضعيفة، فلا بدّ أنّه موجود لداعٍ.

يمكننا مثلًا إضافة المستخدمين إلى طقم بإشارة ضعيفة 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

// ت‫حتوي visitedSet الآن على مستخدمين اثنين

// ه‫ل زارنا John؟
alert(visitedSet.has(john)); // true

// ‫هل زارتنا Mary؟
alert(visitedSet.has(mary)); // false

john = null;

// ستُنظّف ‫visitedSet تلقائيًا

التقييد الأهم في هذه الأنواع WeakSet وWeakMap هي عدم موجود المُكرَّرات واستحالة أخذ محتواها كله. لربّما ترى ذلك غباءً، إلّا أنّه لا يمنع هذه الأنواع من إجراء مهامها التي صُنعت لها: مخزن “إضافي” من البيانات للكائنات المخزّنة (أو المُدارة) في مكان آخر.

خلاصة

الخارطة ضعيفة الإشارة هي تجميعة تشبه الخرائط العادية، ولا تتيح إلا استعمال الكائنات مفاتيحٍ فيها، كما وتُزيلها هي والقيمة المرتبطة بها ما إن تنعدم الإشارة إليها.

الطقم ضعيفة الإشارة هي تجميعة تشبه الأطقم العادية، ولا تخزّن إلا الكائنات فيها، كما وتُزيلها ما إن تنعدم الإشارة إليها.

كِلا النوعان لا يدعمان التوابِع والخاصيات التي تُشير إلى كل المفاتيح فيهما، أو حتى عددها. المسموح فقط هو العمليات على العناصر فيها عنصرًا بعنصر.

يُستعمل هذان النوعان 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 [0]‎؟
alert("Read message 0: " + readMessages.has(messages[0])); // نعم ‫true

messages.shift();
// الآن في ‫readMessages عنصر واحد (تقنيًا فستُنظّف الذاكرة فيما بعد)

يتيح لنا الطقم ضعيفة الإشارة تخزينَ مجموعة من الرسائل والتأكّد من وجود كلّ منها بسهولة تامة. كما وأنّها تمسح نفسها بنفسها. للأسف بهذا نُضحّي بميزة التكرار، فلا يمكن أن نجلب ”كلّ الرسائل المقروءة“ منها مباشرةً، ولكن… يمكننا المرور على عناصر كل الرسائل في messages وترشيح تلك التي في الطقم لدينا.

يمكن أن يكون الحل الآخر هو إضافة خاصية مثل 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 المضمّن في اللغة، وسنتكلم عنه لاحقًا.

الحل

يمكن أن نستعمل الخارطة ضعيفة الإشارة لتخزين التاريخ:

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));
// سنرى أمر كائن التاريخ لاحقًا

ترجمة -وبتصرف- للفصل WeakMap and WeakSet من كتاب The JavaScript language

مهمه

الأهمية: 5

There’s an array of messages:

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

Your code can access it, but the messages are managed by someone else’s code. New messages are added, old ones are removed regularly by that code, and you don’t know the exact moments when it happens.

Now, which data structure could you use to store information about whether the message “has been read”? The structure must be well-suited to give the answer “was it read?” for the given message object.

P.S. When a message is removed from messages, it should disappear from your structure as well.

P.P.S. We shouldn’t modify message objects, add our properties to them. As they are managed by someone else’s code, that may lead to bad consequences.

Let’s store read messages in WeakSet:

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

let readMessages = new WeakSet();

// two messages have been read
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages has 2 elements

// ...let's read the first message again!
readMessages.add(messages[0]);
// readMessages still has 2 unique elements

// answer: was the message[0] read?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// now readMessages has 1 element (technically memory may be cleaned later)

The WeakSet allows to store a set of messages and easily check for the existance of a message in it.

It cleans up itself automatically. The tradeoff is that we can’t iterate over it, can’t get “all read messages” from it directly. But we can do it by iterating over all messages and filtering those that are in the set.

Another, different solution could be to add a property like message.isRead=true to a message after it’s read. As messages objects are managed by another code, that’s generally discouraged, but we can use a symbolic property to avoid conflicts.

Like this:

// the symbolic property is only known to our code
let isRead = Symbol("isRead");
messages[0][isRead] = true;

Now third-party code probably won’t see our extra property.

Although symbols allow to lower the probability of problems, using WeakSet is better from the architectural point of view.

الأهمية: 5

There’s an array of messages as in the previous task. The situation is similar.

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

The question now is: which data structure you’d suggest to store the information: “when the message was read?”.

In the previous task we only needed to store the “yes/no” fact. Now we need to store the date, and it should only remain in memory until the message is garbage collected.

P.S. Dates can be stored as objects of built-in Date class, that we’ll cover later.

To store a date, we can use 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 object we'll study later
خريطة الدورة التعليمية

التعليقات

إقرأ هذا قبل أن تضع تعليقًا…
  • إذا كان لديك اقتراحات أو تريد تحسينًا - من فضلك من فضلك إفتح موضوعًا فى جيتهاب أو شارك بنفسك بدلًا من التعليقات.
  • إذا لم تستطع أن تفهم شيئّا فى المقال - وضّح ماهو.
  • إذا كنت تريد عرض كود استخدم عنصر <code> ، وللكثير من السطور استخدم <pre>، ولأكثر من 10 سطور استخدم (plnkr, JSBin, codepen…)