الوزن الخفيف
^title Flyweight ^section Design Patterns Revisited
ينقشع الضباب، كاشفاً غابةً عتيقةً راسخةً وقورةً. فأشجار الصنوبر العملاقة، وعددها لا يُحصى، تعلو فوقك مكوّنةً كاتدرائيةً من الخضرة. وتنثر مظلّة الأوراق أشعّة الشمس فتُقطّعها إلى أعمدة ذهبية من الضباب. وبين جذوع الأشجار العملاقة، يمكنك أن تلمح الغابة الضخمة تتراجع إلى الأفق.
هذا هو نوع البيئات خارج نطاق هذا العالم الذي أحلم به كمطوّري ألعاب، ومشاهدُ مثل هذه غالباً ما تتيحها الفكرة التي لا يمكن أن يكون اسمها أكثر تواضعاً: الوزن الخفيف (Flyweight).
غابة من أجل الأشجار
أستطيع أن أصف غابةً مترامية بضع جمل فقط، لكن تنفيذها فعلاً في لعبة فورية مسألة أخرى. فحين تملأ الشاشة غابةً كاملة من أشجار منفردة، لا يرى مبرمج الرسوميات سوى الملايين من المضلّعات التي عليه أن يكدّسها بطريقة ما على المعالج الرسومي في كل جزء من ستين جزءاً من الثانية.
نحن نتحدث عن آلاف الأشجار، لكل منها هندسة تفصيلية تحتوي آلاف المضلّعات. فحتى لو امتلكت ما يكفي من الذاكرة لوصف تلك الغابة، فإن هذه البيانات، من أجل عرضها، لا بدّ أن تعبر الناقل من وحدة المعالجة المركزية إلى المعالج الرسومي.
ولكل شجرة مجموعة من البتات المرتبطة بها:
- شبكة من المضلّعات تعرّف شكل الجذع والأغصان والأوراق.
- خامات (textures) لللحاء وللأوراق.
- موقعها واتجاهها في الغابة.
- معاملات ضبط مثل الحجم والدرجة اللونية، حتى تبدو كل شجرة مختلفة.
لو أردت أن ترسم ذلك في الشيفرة، لكان لديك شيء كهذا:
^code heavy-tree
هذه كمية كبيرة من البيانات، مع أن الشبكة والخامات كبيرتان بدرجة خاصة. فغابة كاملة من هذه الكائنات أكثر مما يمكن رميه على المعالج الرسومي في إطار واحد (frame). ولحسن الحظ، هناك حيلة عريقة في التعامل مع هذا الأمر.
الملاحظة المفتاحية هي أنه، رغم وجود ربما آلاف الأشجار في الغابة، فإنها تبدو متشابهة في الغالب. ومن المرجّح أن تستخدم جميعها نفس الشبكة ونفس الخامات. أي أن معظم الحقول في هذه الكائنات متطابقة بين جميع تلك النسخ.
يمكننا نمذجة ذلك صراحةً عبر تقسيم الكائن إلى نصفين. أولاً، نستخرج البيانات التي تتشاركها جميع الأشجار مشتركاً وننقلها إلى صنف منفصل:
^code tree-model
لا تحتاج اللعبة سوى نسخة واحدة من هذه، إذ لا سبب لتخزين الشبكة والخامات نفسها في
الذاكرة ألف مرة.
ثمّ كل نسخة من شجرة في العالم لها مرجع إلى TreeModel المشترك. وما يبقى في
Tree هو الحالة الخاصة بنسخة بعينها:
^code split-tree
يمكنك تصوّرها هكذا:
كل هذا جيد لتخزين الأشياء في الذاكرة الرئيسية، لكنه لا يساعد في عملية العرض. فقبل أن تصل الغابة إلى الشاشة، لا بدّ لها من أن تشقّ طريقها إلى المعالج الرسومي. ونحتاج إلى التعبير عن هذه المشاركة في الموارد بالطريقة التي تفهمها بطاقة الرسوميات.
ألف نسخة
لتقليل مقدار البيانات التي علينا دفعها إلى المعالج الرسومي، نريد أن نتمكن من
إرسال البيانات المشتركة — وهي TreeModel — مرة واحدة فقط.
ثمّ، على حدة، نرسل البيانات الفريدة لكل نسخة شجرة — موقعها ولونها وحجمها. وأخيراً
نخبر المعالج الرسومي: «استخدم ذلك النموذج الواحد لرسم كل هذه النسخ.»
ولحسن الحظ، فإن واجهات الرسوميات (APIs) وبطاقات الرسوميات اليوم تدعم هذا تماماً. التفاصيل معقّدة وخارج نطاق هذا الكتاب، لكن كلاً من Direct3D وOpenGL يستطيع تنفيذ ما يُسمى العرض بالنسخ.
في كلتا الواجهتين، تقدّم مجموعتين من تدفّقات البيانات. الأولى هي كتلة البيانات المشتركة التي ستُرسم مراراً — الشبكة والخامات في مثالنا الشجري. والثانية هي قائمة النسخ ومعاملاتها، التي ستُستخدم لتنويع تلك الكتلة الأولى من البيانات كلما رُسمت. وباستدعاء رسم واحد، تنمو غابة كاملة.
نمط الوزن الخفيف
الآن وبعد أن صار لدينا مثال ملموس واحد بين أيدينا، أستطيع أن أمشي بك عبر النمط العام. الوزن الخفيف، كما يدلّ اسمه، يأتي إلى الأوان حين تملك كائنات تحتاج أن تكون أخفّ وزناً، عادةً لأن لديك منها عدداً كبيراً جداً.
مع العرض بالنسخ، فالمسألة ليست أنها تستهلك ذاكرة أكثر مما ينبغي، بل أنها تستهلك وقتاً أكثر مما ينبغي لدفع كل شجرة على حدة عبر الناقل إلى المعالج الرسومي، لكن الفكرة الأساسية واحدة.
يحلّ النمط ذلك بفصل بيانات الكائن إلى نوعين. فالنوع الأول من البيانات هو ما ليس خاصاً بنسخة واحدة بعينها من ذلك الكائن، ويمكن مشاركته عبر جميعها. ويسمّيها الرباعية الحالة الجوهرية (intrinsic state)، لكنني أحبّ أن أفكّر فيها على أنها أشياء «لا سياقي». وفي المثال هنا، تلك هي هندسة الشجرة وخاماتها.
وبقية البيانات هي الحالة الخارجية (extrinsic state)، أي ما ينفرد بتلك النسخة. وفي حالتنا هذه، هو موقع كل شجرة وحجمها ولونها. ومثلما في قطعة الشيفرة النموذجية أعلاه، يوفّر هذا النمط الذاكرة عبر مشاركة نسخة واحدة من الحالة الجوهرية في كل موضع يظهر فيه الكائن.
من مما رأيناه حتى الآن، يبدو هذا مجرد مشاركة أساسية للموارد، بالكاد يستحق أن يُسمى نمطاً.
وهذا جزئياً لأننا في هذا المثال يمكننا أن نصل إلى هوية (identity) منفصلة وواضحة
للبيانات المشتركة، وهي TreeModel.
أجد هذا النمط أقل وضوحاً (وهو بالتالي أذكى) حين يُستخدم في حالات لا توجد فيها هوية معرَّفة جيداً للكائن المشترك. في تلك الحالات، يبدو الأمر أشبه بأن كائناً ما يوجد في أماكن متعددة في الوقت ذاته. دعني أر لك مثالاً آخر.
مكان لتجذير الأشجار
الأرض التي تنمو عليها هذه الأشجار تحتاج إلى أن يمثّلها في لعبتنا أيضاً. فقد تكون هناك رقع من العشب والتربة والتلال والبحيرات والأنهار وأي تضاريس أخرى تخطر ببالك. وسنجعل الأرض قائمة على بلاطات: أي أن سطح العالم شبكة ضخمة من بلاطات صغيرة، وكل بلاطة مغطّاة بنوع واحد من التضاريس.
ولكل نوع تضاريس عدد من الخصائص التي تؤثر في اللعب:
- تكلفة حركة تحدّد مدى سرعة قدرة اللاعبين على الحركة عبره.
- راية تدلّ على ما إذا كان تضاريس مائياً يمكن عبوره بالقوارب.
- خامة تُستخدم لعرضه.
ولأننا مبرمجو ألعاب مهووسون بالكفاءة، فلا وجود لأي طريقة نخزّن بها كل تلك الحالة في كل بلاطة في العالم. وبدلاً من ذلك، الأسلوب الشائع هو استخدام تعداد (enum) لأنواع التضاريس:
وبدلاً من ذلك، الأسلوب الشائع هو استخدام تعداد (enum) لأنواع التضاريس: وبدلاً من ذلك، الأسلوب الشائع هو استخدام تعداد (enum) لأنواع التضاريس:
^code terrain-enum
ثمّ يحتفظ العالم بشبكة ضخمة من هذه:
^code enum-world
ولكي تحصل فعلاً على البيانات المفيدة عن بلاطة، نفعل شيئاً كهذا:
^code enum-data
لقد فهمت الفكرة. هذا يعمل، لكنني أجده قبيحاً. فأنا أفكّر في تكلفة الحركة والبلل على أنهما بيانات عن التضاريس، لكن هنا تكون مدمجةً داخل الشيفرة. والأسوأ أن بيانات نوع تضاريس واحد موزَّعة على مجموعة من الدوال. سيكون رائعاً لو أبقينا كل ذلك مغلّفاً معاً. بعد كل شيء، هذا هو ما صُمّمت الكائنات من أجله.
لو استطعنا الحصول على صنف تضاريس حقيقي، لكان ذلك رائعاً، مثل:
^code terrain-class
لكننا لا نريد أن ندفع ثمن وجود نسخة من ذلك لكل بلاطة في العالم. فإذا نظرت إلى ذلك الصنف، ستلاحظ أن فيه لا شيء خاصّ بـ موضع تلك البلاطة. بعبارات الوزن الخفيف، كل حالة التضاريس «جوهرية» أو «لا سياقية».
ونظراً لذلك، فلا سبب لوجود أكثر من نسخة من كل نوع تضاريس.
فكل بلاطة عشب على الأرض مطابقة لكل بلاطة أخرى. وبدلاً من أن يكون العالم شبكة من
التعدادات أو من كائنات Terrain، سيكون شبكة من مؤشرات إلى كائنات Terrain:
^code world-terrain-pointers
كل بلاطة تستخدم التضاريس نفسها تشير إلى النسخة نفسها من التضاريس.
ولأن نسخ التضاريس تُستخدم في أماكن متعددة، فإن إدارة أعمارها ستكون أعقد قليلاً لو خُصّصت ديناميكياً. بدلاً من ذلك، سنخزّنها مباشرةً داخل العالم:
^code world-terrain
ثمّ يمكننا استخدام تلك لنرسم الأرض هكذا:
^code generate
الآن، بدلاً من دوال على World للوصول إلى خصائص التضاريس، يمكننا كشف كائن Terrain
مباشرةً:
^code get-tile
بهذه الطريقة، لم يعد World مرتبطاً بكل أنواع تفاصيل التضاريس.
وإذا أردت خاصية من خصائص البلاطة، فيمكنك الحصول عليها من ذلك الكائن نفسه:
^code use-get-tile
عدنا إلى واجهة API المريحة الخاصة بالعمل مع كائنات حقيقية، وقد فعلنا ذلك بتكلفة تكاد تكون معدومة — فالمؤشر غالباً لا يكون أكبر من التعداد.
ماذا عن الأداء؟
قلتُ «تكاد» هنا لأن عدّادات أداء عتادي الحاسوب لن تتردّد في السؤال عن كيفية مقارنة هذا باستخدام تعداد. فالإشارة إلى التضاريس عبر مؤشر تعني بحثاً غير مباشر. وللوصول إلى بيانات تضاريس مثل تكلفة الحركة، عليك أولاً أن تتبع المؤشر داخل الشبكة لتجد كائن التضاريس، ثم تجد تكلفة الحركة هناك. وتتبّع المؤشر على هذا النحو قد يسبب إخفاقاً في الذاكرة المؤقتة (cache miss)، وهو ما قد يبطئ الأمور.
وكما هو الحال دائماً، فإن القاعدة الذهبية للتحسين هي أن توصّف أولاً. فعتاد الحاسوب الحديث معقّد جداً بحيث لم يعد الأداء لعبة المنطق الخالص. وفي اختباراتي لهذا الفصل، لم تكن هناك أي عقوبة لاستخدام وزن خفيف بدلاً من تعداد. كانت الأوزان الخفيفة أسرع فعلاً بشكل ملحوظ. لكن ذلك كله يعتمد كلياً على كيفية ترتيب بقية الأشياء في الذاكرة.
وما أكون متيقّناً منه هو أن استخدام كائنات الوزن الخفيف لا ينبغي أن يُستبعد على عجل. فهي تمنحك مزايا الأسلوب الكائني دون كلفة وجود كميات هائلة من الكائنات. وإذا وجدت نفسك تصنع تعداداً وتنفّذ عليه تبديلات كثيرة، ففكّر في هذا النمط بدلاً من ذلك. وإذا كنت قلقاً بشأن الأداء، فعليك على الأقل أن توصّف الأداء أولاً قبل أن تغيّر شيفرتك إلى أسلوب أقل قابلية للصيانة.
انظر أيضاً
-
في مثال البلاطات، أنشأنا نسخة لكل نوع تضاريس على عجل وخزّناها في
World. وقد جعل ذلك العثور على النسخ المشتركة وإعادة استخدامها سهلاً. لكن في كثير من الحالات، لن ترغب في إنشاء كل الأوزان الخفيفة مسبقاً.إن كنت لا تستطيع التنبّؤ بأيها تحتاجه فعلاً، فمن الأفضل إنشاؤها عند الطلب. وللاستفادة من المشاركة، عندما تطلب واحدة، فإنك تبحث أولاً إن كنت قد أنشأت نسخة مطابقة. وإن كان كذلك، فلا عليك سوى إعادة تلك النسخة.
وهذا يعني عادةً أن عليك تغليف الإنشاء خلف واجهة تستطيع البحث أولاً عن كائن موجود. وإخفاء مُنشئ هكذا مثالٌ على نمط دالة المصنع (Factory Method).
-
كي تعيد وزناً خفيفاً أُنشئ سابقاً، عليك تتبّع مجموعة النسخ التي سبق أن أنشأتها. وكما يدلّ الاسم، فإن مجموعة الكائنات (Object Pool) قد تكون مكاناً ملائماً لتخزينها.
-
حين تستخدم نمط الحالة (State)، فإنك غالباً ما تملك كائنات «حالة» لا تحمل أي حقول خاصة بالآلة التي تُستخدم فيها. فهوية الحالة ودوالها تكفي لتكون نافعة. وفي تلك الحالة، يمكنك تطبيق هذا النمط وإعادة استخدام النسخة نفسها من الحالة في عدة آلات حالات في الوقت نفسه دون أي مشكلات.