أنماط برمجة الألعاب › ملعب الأصناف الفرعية (subclass sandbox) 9 / 26 السابق التالي

صندوق الصنف الفرعي

^title Subclass Sandbox ^section Behavioral Patterns

النية

عرّف السلوك في صنف فرعي باستخدام مجموعة عمليات توفّرها صنفه الأساس.

الدافع

حلم كل طفل بأن يكون بطلاً خارقاً، لكن الأشعة الكونية نادرة هنا على الأرض. والألعاب التي تدعك تتظاهر بأنك بطل خارق هي أقرب تقدير ممكن. ولأن مصممي ألعابنا لم يتعلّموا قط أن يقولوا «لا»، فإن لعبة البطل الخارق لدينا تهدف إلى أن تضمّ عشرات إن لم تكن مئات من القوة الخارقة المختلفة التي يمكن للأبطال الاختيار منها.

خطتنا أن يكون لدينا صنف أساس Superpower. ثم يكون لدينا صنف مشتق يطبّق كل قوة خارقة. وسنقسّم وثيقة التصميم بين فريقنا من المبرمجين ونبدأ البرمجة. وعندما ننتهي، سيكون لدينا مئة صنف قوة خارقة.

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

لنقل إننا أطلقنا سرح فريقنا وبدأنا يكتبون أصناف القوى الخارقة. فماذا سيحدث؟

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

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

  • حين تحتاج تلك الأنظمة الخارجية إلى التغيير، فالرهان غالباً على أن تتعطّل شيفرة قوة خارقة عشوائية ما. فبمجرد أن تصبح لدينا أصناف قوى خارقة مختلفة تقترن بأنفسها بأجزاء شتّى من محرّك اللعبة، يصبح من المحتّم أن تؤثر التغييرات على تلك الأنظمة في أصناف القوى. وهذا ليس ممتعاً، لأن مبرمجي الرسوميات والصوت وواجهة المستخدم على الأرجح لا يريدون أن يكونوا أيضاً مبرمجي أنظمة لعب.

  • من الصعب تعريف ثوابت (invariants) تطيعها كل القوى الخارقة. لنقل إننا نريد التأكد من أن كل صوت تشغّله قوتنا يُدرج في الطابور ويُمنح الأولوية بشكل صحيح. ولا توجد طريقة سهلة للقيام بذلك إذا كان مئة صنفنا كلها تستدعي محرّك الصوت مباشرةً بذاتها.

ما نريده هو أن نمنح كل واحد من مبرمجي أنظمة اللعب الذين ينفّذون قوة خارقة مجموعة من الأوّليات يستطيع اللعب بها. تريد أن تشغّل قوتك صوتاً؟ إليك دالة playSound(). تريد جزيئات؟ إليك spawnParticles(). وسنحرص على أن تغطّي هذه العمليات كل ما تحتاج إليه، كي لا تحتاج إلى #include ترويسات عشوائية وتتشمّر طريقك بين باقي قاعدة الشيفرة.

ونفّذ ذلك بجعل هذه العمليات دوال محميّة في صنف Superpower الأساسي. فوضعها في الصنف الأساس يمنح كل صنف قوة مشتق وصولاً مباشراً وسهلاً إلى الدوال. وجعلها محميّة (على الأرجح غير افتراضية) يوصل أنها موجودة تحديداً ليتم استدعاؤها من الأصناف الفرعية.

ومجرّد أن تتوفّر لنا هذه الأدوات، نحتاج إلى موضع نستخدمها فيه. ولذلك سنعرّف طريقة صندوق (sandbox method)، وهي دالة محميّة مجرّدة يجب على الأصناف الفرعية أن تطبّقها. ومعها، فإنك لتنفيذ نوع جديد من القوة:

  1. أنشئ صنفاً جديداً يرث من Superpower.

  2. تجاوز activate()، وهي دالة الصندوق.

  3. نفّذ جسمها باستدعاء الدوال المحميّة التي يوفّرها Superpower.

يمكننا الآن حل مشكلة الشيفرة المكرّرة بجعل تلك العمليات المقدَّمة في أعلى مستوى ممكن. فحين نرى شيفرة مكرّرة بين كثير من الأصناف الفرعية، يمكننا دائماً أن نرفعها إلى Superpower كعملية جديدة يمكنها جميعاً استخدامها.

لقد عالجنا مشكلة الاقتران عبر تقييد الاقتران بموضع واحد. فـSuperpower نفسه سيصبح مقترناً بأنظمة اللعبة المختلفة، لكن مئة أصنافنا المشتقة لن تكون كذلك. وبدلاً من ذلك، فإنها مقترنة فقط بصنفها الأساس. وحين يتغيّر أحد تلك الأنظمة، فقد يلزم تعديل Superpower، لكن لا ينبغي أن تُلمس عشرات الأصناف الفرعية.

يؤدي هذا النمط إلى بنية يكون فيها تسلسل الوراثة لديك ليس عميقاً، لكن هناك الكثير من الأصناف المتدلّية من Superpower. ولأن لدينا صنف واحد له كثير من الأصناف الفرعية المباشرة، فلنا نقطة ارتكاز في قاعدة شيفرتنا. والوقت والجهد الذي نضعه في Superpower يمكن أن يفيد مجموعة واسعة من الأصناف في اللعبة.

النمط

يعرّف صنف الأساس طريقة صندوق (sandbox method) مجرّدة وعدة عمليات مقدَّمة (provided operations). وجعلها محميّة يوضّح أنها مخصّصة لاستخدام الأصناف المشتقة. ويطبّق كل صنف فرعي صندوقي (sandboxed subclass) طريقة الصندوق باستخدام العمليات المقدَّمة.

متى تستخدمه

نمط صندوق الصنف الفرعي (Subclass Sandbox) هو نمط بسيط جداً وشائع يختبئ في كثير من قواعد الشيفرة، حتى خارج الألعاب. فإذا كان لديك دالة محميّة غير افتراضية مُلقاة في مكان ما، فأنت على الأرجح تستخدم بالفعل شيء من هذا النوع. وصندوق الصنف الفرعي ملائم حين:

  • لديك صنف أساس لديه عدد من الأصناف المشتقة.

  • الصنف الأساس قادر على توفير كل العمليات التي قد يحتاج الصنف المشتق إلى تنفيذها.

  • هناك تداخل سلوكي بين الأصناف الفرعية وتريد أن تجعل مشاركة الشيفرة بينها أسهل.

  • تريد تقليل الاقتران بين تلك الأصناف المشتقة وبقية البرنامج.

ضع في اعتبارك

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

ولأن الأصناف الفرعية تمرّ عبر صنفها الأساس للوصول إلى بقية اللعبة، فإن الصنف الأساس ينتهي إلى أن يكون مقترناً بكل نظام يحتاج أي صنف مشتق أن يتحدث إليه. وبالطبع فإن الأصناف الفرعية مرتبطة بدورها ارتباطاً وثيقاً بصنفها الأساس. وتلك شبكة الاقتران العنكبوتية تجعل تغيير الصنف الأساس صعباً جداً دون كسر شيء ما، فأنت تصطدم بـمشكلة صنف الأساس الهشّ.

الجهة الأخرى للعملة هي أن معظم اقترانك قد دُفع إلى الصنف الأساس، فأصبحت الأصناف المشتقة مفصولة أنظف بكثير عن بقية العالم. ومثالياً، سيكون معظم سلوكك في تلك الأصناف الفرعية. وهذا يعني أن جزءاً كبيراً من قاعدة شيفرتك معزول وأسهل صيانة.

ومع ذلك، إن وجدت هذا النمط يحوّل صنفك الأساس إلى وعاء ضخم من شيفرة المهلبية، ففكّر في إخراج بعض العمليات المقدَّمة إلى أصناف منفصلة يمكن للصنف الأساس أن يوزّع عليها المسؤولية. ويمكن لنمط المكوّن (Component) أن يساعد هنا.

شيفرة العيّنة

ولأن هذا النمط بسيط إلى هذا الحد، فثمة القليل في شيفرة العيّنة. لكن ذلك لا يعني أنه غير مفيد — فالنمط يتعلق بـالنية، لا بتعقيد تنفيذه.

سنبدأ بصنف Superpower الأساس:

^code 1

دالة activate() هي دالة الصندوق. ولأنها افتراضية ومجرّدة، فيجب على الأصناف الفرعية أن تتجاوزها. وهذا يوضّح لمن ينشئ صنف قوة خارقة أين يجب أن يضع عمله.

أما الدوال المحميّة الأخرى، وهي move() وplaySound() وspawnParticles()، فهي العمليات المقدَّمة. وهذه هي ما سيستدعيه الأصناف الفرعية في تنفيذها لـactivate().

لم ننفّذ العمليات المقدَّمة في هذا المثال، لكن اللعبة الحقيقية ستحوي هناك شيفرة فعلية. فتلك الدوال هي حيث يقترن Superpower بأنظمة أخرى في اللعبة — قد تستدعي move() شيفرة الفيزياء، وسيتحدث playSound() مع محرّك الصوت، وهكذا. وبما أن كل هذا في تنفيذ الصنف الأساس، فإن ذلك يُبقي الاقتران مغلقاً داخل Superpower نفسه.

حسناً، لنُخرج عناكببنا المُشعّة الآن ونصنع قوة. وهذه واحدة منها:

^code 2

تُطلق هذه القوة البطل الخارق في الهواء، وتشغّل صوتاً ملائماً وتثير سحابة صغيرة من الغبار. ولو كانت كل القوى الخارقة بهذه البساطة — مجرد مزيج من صوت وتأثير جزيئات وحركة — لما احتجنا إلى هذا النمط إطلاقاً. وبدلاً من ذلك، كان يمكن أن يحتوي Superpower على تنفيذ activate() مبنيّ مسبقاً يصل إلى حقول معرّف الصوت ونوع الجزيئات والحركة. لكن هذا لا ينجح إلا حين تعمل كل قوة بالمنطق نفسه تقريباً مع اختلافات في البيانات فحسب. فلنُوسّع الأمر قليلاً:

^code 3

هنا أضفنا عدداً من الدوال للحصول على موضع البطل. ويمكن لصنف SkyLaunch الفرعي أن يستخدمها الآن:

^code 4

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

القرارات التصميمية

كما ترى، صندوق الصنف الفرعي نمط «ليّن» إلى حد ما. فهو يصف فكرة أساسية، لكنه لا يملك الكثير من الآليات التفصيلية. وهذا يعني أنك ستتخذ بعض الخيارات المثيرة في كل مرة تطبّقه فيها. وهذه بعض الأسئلة التي ينبغي أن تفكّر فيها.

ما العمليات التي ينبغي توفيرها؟

هذا هو السؤال الأكبر. فهو يؤثّر بعمق في إحساس هذا النمط وفي مدى نجاحه. وعند الطرف الأدنى من الطيف، لا يوفّر الصنف الأساس أي عمليات. كل ما لديه هو دالة صندوق. ولتنفيذها، ستضطر إلى استدعاء أنظمة خارج الصنف الأساس. وإن أخذت هذا الاتجاه، فلا يكون من الإنصاف أن تقول إنك تستخدم هذا النمط.

وعلى الطرف الآخر من الطيف، يوفّر الصنف الأساس كل عملية قد يحتاجها أي صنف فرعي. فالأصناف الفرعية مقترنة فقط بالصنف الأساس ولا تستدعي أي أنظمة خارجية على الإطلاق.

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

وهذا مكسب إن كان لديك حفنة من الأصناف المشتقة التي كانت جميعها مقترنة بنظام خارج ما. فبنقل الاقتران إلى الأعلى في عملية مقدَّمة، فقد ركّزته في موضع واحد: الصنف الأساس. لكن كلما أكثرت من ذلك، صار ذلك الصنف الواحد أكبر وأصعب صيانة.

فأين ينبغي أن ترسم الخط؟ وهذه بعض القواعد الإرشادية:

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

    قد يكون الأمر يستحق ذلك لجعل العملية متسقة مع العمليات المقدَّمة الأخرى، أو قد يكون الأمر أبسط وأنظف أن تترك تلك الأصناف الفرعية الخاصة تستدعي الأنظمة الخارجية مباشرةً.

  • حين تستدعي دالة في ركن آخر من اللعبة، يكون الأمر أقلّ إزعاجاً إن كانت تلك الدالة لا تعدّل أي حالة. فهي لا تزال تخلق اقتراناً، لكنه اقتران «آمن» لأنه لا يستطيع كسر أي شيء في اللعبة.

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

  • إذا كان تنفيذ العملية المقدَّمة لا يفعل سوى تمرير الاستدعاء إلى نظام خارجي ما، فإنه لا يضيف قيمة كبيرة. وفي هذه الحالة، قد يكون الأمر أبسط أن تستدعي الدالة الخارجية مباشرةً.

    لكن حتى التمرير البسيط قد يبقى مفيداً — فتلك الدوال غالباً ما تصل إلى حالة لا يريد الصنف الأساس أن يعرّضها للأصناف الفرعية مباشرةً. فمثلاً، لنقل إن كان Superpower يوفّر هذا:

    ^code 5

    فهو ليس سوى تمرير الاستدعاء إلى حقل soundEngine_ ما في Superpower. لكن الميزة أنه يُبقي ذلك الحقل مغلقاً داخل Superpower بحيث لا تستطيع الأصناف الفرعية التسلّل إليه.

هل ينبغي توفير الدوال مباشرةً، أم عبر كائنات تحتويها؟

التحدّي في هذا النمط هو أنك قد تنتهي إلى عدد مؤلم من الدوال محشورة في صنفك الأساس. ويمكنك تخفيف ذلك بنقل بعض تلك الدوال إلى أصناف أخرى. وعندئذٍ لا تعود العمليات المقدَّمة في الصنف الأساس سوى تُعيد أحد تلك الكائنات.

فمثلاً، لكي تشغّل القوة أصواتاً، يمكننا إضافة هذه مباشرةً إلى Superpower:

^code 6

لكن إذا كان Superpower أصبح أصلاً كبيراً يصعب ضبطه، فقد نرغب في تجنّب ذلك. وبدلاً من ذلك، ننشئ صنف SoundPlayer يكشف تلك الوظيفة:

^code 7

ثم يوفّر Superpower الوصول إليه:

^code 8

وإخراج العمليات المقدَّمة بهذا الشكل إلى أصناف مساعدة يمكن أن يفعل لك أشياء عديدة:

  • إنه يقلّل عدد الدوال في الصنف الأساس. ففي المثال هنا، انتقلنا من ثلاث دوال إلى دالة وصول واحدة فحسب.

  • الشيفرة في صنف المساعد عادةً أسهل في الصيانة. فصنف الأساس الجوهرية مثل Superpower، وعلى أفضل نوايانا، تميل إلى أن تكون صعبة التغيير لأن الكثير يعتمد عليها. وبنقل الوظيفة إلى صنف ثانوي أقل اقتراناً، نجعل تلك الشيفرة أسهل في التعديل دون كسر أي شيء.

  • إنه يخفض الاقتران بين الصنف الأساس والأنظمة الأخرى. حين كانت playSound() دالة على Superpower مباشرةً، كان صنفنا الأساس مرتبطاً مباشرةً بـSoundId وبأي شيفرة صوتية كان التنفيذ يستدعيها. أما نقل ذلك إلى SoundPlayer فيقلّص اقتران Superpower إلى صنف SoundPlayer الواحد، الذي يغلّف بعد ذلك كل اعتماداته الأخرى.

كيف يحصل الصنف الأساس على الحالة التي يحتاجها؟

غالباً ما سيحتاج صنفك الأساس إلى بعض البيانات التي يريد تغليفها وإخفائها عن أصنافه الفرعية. ففي مثالنا الأول، وفّر صنف Superpower دالة spawnParticles(). فإذا احتاج تنفيذ تلك الدالة إلى كائن نظام جزيئات، فكيف يحصل على واحد؟

  • مرّرها إلى بانِي الصنف الأساس:

    الحل الأبسط هو أن يأخذه الصنف الأساس كوسيط في الباني:

    ^code pass-to-ctor-base

    هذا يضمن بأمان أن لكل قوة خارقة نظام جزيئات في وقت بنائها. لكن لننظر إلى صنف مشتق:

    ^code pass-to-ctor-sub

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

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

  • قم بتهيئة على مرحلتين:

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

    ^code 9

    لاحظ هنا أنه بما أننا لا نمرّر أي شيء إلى باني SkyLaunch، فهو غير مقترن بأي شيء نريد إبقائه خاصاً في Superpower. لكن المشكلة في هذا النهج أنك مضطر إلى التأكد من أنك تتذكّر دائماً استدعاء init(). فإن نسيت يوماً، ستجد قوة واقعة في حالة نصف مُنشأة غامضة ولن تعمل.

    يمكنك إصلاح ذلك بتغليف العملية بأكملها في دالة واحدة، هكذا:

    ^code 10

  • اجعل الحالة ساكنة (static):

    في المثال السابق، كنّا نهيّئ كل نسخة من Superpower بنظام جزيئات. وهذا منطقي حين تحتاج كل قوة حالتها الفريدة الخاصة. لكن لنقل إن كان نظام الجزيئات هو المفرد (Singleton)، وأن كل قوة ستتشارك الحالة نفسها.

    في هذه الحالة، يمكننا جعل الحالة خاصة بالصنف الأساس وأيضاً جعلها ساكنة. وستظل اللعبة مضطرة إلى التأكد من أنها تهيّئ الحالة، لكنها لا تحتاج إلا إلى تهيئة صنف Superpower مرة واحدة للعبة كلها، لا مرة لكل نسخة.

    ^code 11

    لاحظ هنا أن init() وparticles_ كلاهما ساكن. ما دامت اللعبة تستدعي Superpower::init() مرة واحدة في البداية، فيستطيع كل قوة الوصول إلى نظام الجزيئات. وفي الوقت نفسه، يمكن إنشاء نسخ Superpower بحرية عبر استدعاء باني الصنف المشتق المناسب.

    والأفضل من ذلك، الآن وبعد أن أصبح particles_ متغيراً ساكناً، لا نحتاج إلى تخزينه لكل نسخة من Superpower، فقد جعلنا الصنف يستهلك ذاكرة أقل.

  • استخدم مُحدِّد الخدمات:

    الخيار السابق يتطلب أن تتذكّر الشيفرة الخارجية تحديداً أن تدفع الحالة التي يحتاج إليها الصنف الأساس قبل أن يحتاج إليها. وهذا يضع عبء التهيئة على الشيفرة المحيطة. وخيار آخر هو ترك الصنف الأساس يتولّى ذلك بسحب الحالة التي يحتاجها بنفسه. وإحدى طرق ذلك هي استخدام نمط محدّد الخدمات (Service Locator):

    ^code 12

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

انظر أيضاً

  • حين تطبّق نمط دالة التحديث (Update Method)، فغالباً ما تكون دالة التحديث لديك دالة صندوق أيضاً.

  • هذا النمط هو انعكاس للأدوار في نمط طريقة القالب (Template Method). ففي النمطين، تنفّذ دالة باستخدام مجموعة من العمليات الأوّلية. ومع صندوق الصنف الفرعي، تكون الدالة في الصنف المشتق والعمليات الأوّلية في الصنف الأساس. ومع طريقة القالب، تكون الدالة في الصنف الأساس والعمليات الأوّلية يطبّقها الصنف المشتق.

  • يمكنك أيضاً أن ترى في هذا مجرد تنويعة على نمط الواجهة (Facade). فذلك النمط يخفي عدة أنظمة مختلفة خلف واجهة واحدة مبسّطة. ومع صندوق الصنف الفرعي، يعمل الصنف الأساس كواجهة تخفي محرّك اللعبة بأكمله عن الأصناف الفرعية.