تعلّم البرمجة بلغة كوتلن (64): تفويض الصنف Class Delegation

تعلّم البرمجة بلغة كوتلن (64): تفويض الصنف Class Delegation
أستمع الى المقال

تحدثنا في درسي: التركيب Composition و الوراثة والدوال المُلحقة، كيف يمكن أن نحد من استخدام الوراثة Inheritance في البرامج المكتوبة بلغة البرمجة كوتلن، واستبدالها بمفاهيم أخرى. وفي هذا الدرس، سنتعلم طريقة استخدام مفهوم آخر من مفاهيم تصميم البرمجيات، والذي يجعل من مفهوم التركيب Composition، بنفس قوة الوراثة في إعادة استخدام الشفرة، وهو مفهوم التفويض Delegation.

ما هو التفويض Delegation:

في علم الحاسوب، نمط التفويض Delegation Pattern، هو نمط تصميم للشفرة البرمجية، خاص بلغات البرمجة التي تعتمد نمط البرمجة الكائنية. يسمح لنا التفويض، بتركيب كائن ما، لتحقيق نفس مبدأ إعادة استخدام الشفرة مثل الوراثة. عند استخدام التفويض، يسند الكائن المهمة إلى كائن ثانٍ والذي يعرف بـ (المُفَوَّض delegate).

تفويض الصنف في كوتلن:

عند استخدام مفهوم التركيب عبر تضمين كائن من صنف موجود في صنف جديد، ستتوفر دوال الكائن المُضَمن (الذي تم تضمينه) في الصنف الجديد. ولكن إذا كان هذا الكائن المُضَمن يطبّق واجهة Interface، لن تتوفر الدوال التي تقدمها الواجهة، في الصنف الجديد.

ولفهم هذا الأمر عمليًا، دعونا نلقي نظرة إلى المثال التالي:

interface Controls {
    fun up(speed: Int): String
    fun down(speed: Int): String
    fun left(speed: Int): String
    fun right(speed: Int): String
    fun forward(speed: Int): String
    fun back(speed: Int): String
    fun turboBoost(): String
}

class SpaceShipControls : Controls {
    override fun up(speed: Int) = "up $speed"
    override fun down(speed: Int) = "down $speed"
    override fun left(speed: Int) = "left $speed"
    override fun right(speed: Int) = "right $speed"
    override fun forward(speed: Int) = "forward $speed"
    override fun back(speed: Int) = "back $speed"
    override fun turboBoost() = "turbo boost"
}

لدينا في المثال، واجهة Controls تُمثل وحدات التحكم في لعبةٍِ ما. ثم لدينا صنف SpaceShipControls والذي يطبّق كل الدوال من الواجهة. إذا افترضنا أننا نريد التعديل على بعض الدوال، واستخدام الدوال الأخرى بنفس تطبيقها في الصنف SpaceShipControls، سيكون لدينا خياران: الوراثة أو تفويض الصنف.

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

الخيار الآخر الذي لدينا، هو تفويض الصنف Class Delegation. تفويض الصنف، يقف في المنتصف ما بين الوراثة والتركيب. فهو مثل التركيب نضع كائن من الصنف الموجود مسبقًا (وهو هنا صنف SpaceShipControls) داخل الصنف الجديد، ومن ثم نستدعي دوال الصنف SpaceShipControls عبر هذا الكائن. وفي الوقت ذاته، يتمكن الصنف الجديد من استخدام نفس تطبيقات الصنف SpaceShipControls لدوال الواجهة Controls، تمامًا مثل الوراثة.

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

أنواع تفويض الصنف:

يمكن تطبيق التفويض، عبر طريقتين:

  • تفويض صريح Explicitly Delegation: يمكن تطبيقه في أي لغة برمجة تعتمد نمط البرمجة الكائنية (مثل جافا وبالطبع كوتلن وغيرهما).
  • تفويض ضمني Implicitly Delegation: يجب أن يتوفر له دعم مدمج في اللغة (يتوفر في لغة كوتلن).

إذًا، كيف يتم تطبيق كلا من النوعين؟ هذا ما سنعرفه نطريًا وعمليًا، في الفقرتين التاليتين.

تفويض صريح Explicitly Delegation:

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

class ExplicitControls : Controls {
    private val newControls = SpaceShipControls() // تركيب
    // Delegation by hand تفويض يدوي
    override fun up(speed: Int) = newControls.up(speed)
    override fun down(speed: Int) = newControls.down(speed)
    override fun left(speed: Int) = newControls.left(speed)
    override fun right(speed: Int) = newControls.right(speed)
    override fun forward(speed: Int) = newControls.forward(speed)
    override fun back(speed: Int) = newControls.back(speed)
    // Modified implementation تم تعديل هذه الدالة ووضع تطبيق مختلف
    override fun turboBoost(): String = newControls.turboBoost() + ": applying the maximum speed!"
}

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

وهذا ما فعلناه في المثال، وضعنا تطبيق مختلف لدالة ()turboBoost، عن تطبيقها في صنف SpaceShipControls. أمّا باقي الدوال، فوّضنا الصنف SpaceShipControls بأن يقوم بتطبيقها، نيابةً عن الصنف الجديد ExplicitControls. وفي هذه الحالة، يمكننا وصف الصنف SpaceShipControls بالمصطلح  (المُفَوَّض delegate).

استدعاء الدوال عبر كائن من صنف ExplicitControls:

ويمكن الآن أن نستخدم صنفنا الجديد، كما يلي:

fun main() {

    val controls = ExplicitControls()

    println(
        """
            ${controls.up(62)}
            ${controls.down(63)}
            ${controls.forward(58)}
            ${controls.back(64)}
            ${controls.turboBoost()}
        """.trimIndent()
    )
}

داخل دالة ()main حيث يبدأ البرنامج عمله، نُنشئ كائن من صنفنا الجديد ExplicitControls ونسميه controls. ثم عبر هذا الكائن، نستدعي الدوال من الصنف ExplicitControls والتي أعاد تطبيقها من الواجهة Controls. لطباعة نتيجة التنفيذ، نستخدم دالة الطباعة وبداخلها طريقة القوالب النصية في كوتلن.

عند تنفيذ الشفرة، سيكون الناتج في تبويب Run في برنامج IntelliJ:

كما نرى في نتيجة الطباعة، أنه في أول 4 دوال، تم طباعة النصوص التي يوفرها تطبيق الصنف SpaceShipControls لهذه الدوال. أمّا الدالة الأخيرة ()turboBoost، فتم إضافة التطبيق الخاص بالصنف الجديد ExplicitControls إلى تطبيق الصنف SpaceShipControls لنفس الدالة.

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

لهذا السبب، نجد العديد من لغات البرمجة الكائنية التوجه (ومنها كوتلن)، توفر دعم مدمج مع اللغة (ضمني) لمفهوم التفويض.

تفويض ضمني Implicitly Delegation:

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

وسيكون أبسط مثال لهذا الأمر، هو كالتالي:

interface Example
class A : Example

لدينا واجهة Example وصنف موجود مسبقًا اسمه A يطبّق الواجهة Example. الآن عند إنشاء صنف جديد، وليكن اسمه B، ونريد الاستفادة من الدوال في الواجهة وأيضًا بعض أو كل تطبيقات هذه الدوال في الصنف A، نستخدم التفويض كما فعلنا سابقًا. ولكن هذه المرة، لن نكتب كل شيء، بل سنترك مترجم كوتلن أن يقوم بهذه المهمة نيابةً عنا، باستخدام الكلمة المفتاحية by:

class B(val a: A) : Example by a

نضع الصنف A كخاصيّة في الصنف B، أي نستخدم مفهوم التركيب. وكما نعرف من درس الباني، يمكننا في كوتلن أن نضع الخاصيّات في باني الصنف الأساسي، الذي تُمثله هنا أقواس الصنف B. ثم بعد اسم الواجهة Example، نكتب by ثم الكائن المُنشأ من الصنف A، وهو هنا اسمه a.

يمكن قراءة ذلك كالتالي:

الصنف الجديد B يطبّق الواجهة Example عبر by تفويض الصنف A والذي يُمثله الكائن a، للقيام بتطبيق بعض أو كل الدوال التي يتم تطبيقها من الواجهة Example.

وفي هذه الحالة، لن يكون الصنف B مجبرًا على تطبيق الدوال المُجرّدة من الواجهة Example، لأنه فوّض الصنف بهذا التطبيق.

ولاستخدام التفويض بعض الشروط التي يجب أن نُشير إليها:

  • يمكن للصنف الجديد استخدام التفويض عند تطبيق الواجهات Interfaces فقط، ولا يمكنه استخدام التفويض عند الوراثة من صنف عادي مفتوح open أو صنف مجرّد. لأنه حينها سيكون خلط بين الوراثة والتفويض، وهو غير مسموح في كوتلن. فمن ضمن أهداف تفويض الصنف، هو جعل أسلوب التركيب، بنفس قوة الوراثة. للمزيد عن هذا الأمر، توجد إجابة مناسبة على stackoverflow.com.
  • الكائن a يجب أن يكون معامل في باني الصنف.

وبعد أن تعرفنا على طريقة كتابة التفويض الضمني في كوتلن، دعونا نستخدمه في إعادة كتابة المثال السابق.

تعديل مثال SpaceShipControls:

الشفرة التالية، هو كل ما سنكتبه للحصول على نفس النتيجة في فقرة التفويض الصريح السابقة:

class ImplicitControls(
    private val newControls: SpaceShipControls = SpaceShipControls()
) : Controls by newControls {
    override fun turboBoost(): String = 
        newControls.turboBoost() + ": applying the maximum speed!"
}

اخترنا اسم ImplicitControls للصنف الجديد. وداخل أقواس باني الصنف، وضعنا الخاصيّة newControls والتي تحمل القيمة الافتراضية ()SpaceShipControls وهو كائن مُنشأ من الصنف SpaceShipControls. هذا الكائن، سيتم إسناده إلى الخاصيّة newControls. ونضع محدد الوصول private لها، لأننا نريد حمايتها من أن تتم رؤيتها واستخدامها خارج الصنف الجديد، لأنه لاداعي لذلك. وهي نفس الخاصيّة، التي نضعها بعد كلمة by.

وعند تنفيذ الشفرة، ستكون النتيجة هي نفس السابقة تمامًا:

up 62
down 63
forward 58
back 64
turbo boost: applying the maximum speed!

الوراثة المتعددة Multi Inheritance:

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

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

open class ButtonImage(
    private val width: Int,
    private val height: Int
) {
    fun paint() = "painting ButtonImage width: $width, height: $height"
}

open class UserInput {
    fun clicked() = true
}

لدينا في الشفرة، صنف ButtonImage جعلناه مفتوح للوراثة منه، ولديه خاصيّتان للعرض width والطول height. عند إنشاء كائن من هذا الصنف أو الوراثة منه، يجب أن نرسل له قيم لهاتين الخاصيّتين. داخل الصنف لدينا دالة ()paint، والتي مهمتها هي رسم الزر حسب قيم الخاصيّات (طباعة جملة هنا لمحاكاة عملية الرسم). ثم لدينا صنف آخر UserInput هو الآخر مفتوح للوراثة منه، وبه دالة ()clicked والتي ستعيد true إذا تم الضغط على زر الفأرة.

ما نريده هو الجمع بين وظيفة الرسم ومراقبة عملية الضغط التي يقوم بها المستخدم. لفعل ذلك، سننشئ صنف جديد، وليكن اسمه Button، ثم محاولة الوراثة من الصنفين معًا في آنٍ واحد:

class Button(
    private val width: Int, private val height: Int
) : ButtonImage(width, height), UserInput()

ولكن هذا الأمر سينتج الخطأ التالي:

Only one class may appear in a supertype list

لأن هذه وراثة متعددة وهي غير ممكنة في كوتلن كما قلنا سابقًا. إذًا، ما الحل؟ لأنه من المسموح أن يطبّق الصنف Button أكثر من واجهة Interface، سننشئ واجهات، ثم نستخدم التفويض لمحاكاة نفس الأمر الذي ستقوم به الوراثة.

التفويض بدلًا عن الوراثة المتعددة:

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

interface Rectangle {
    fun paint(): String
}

interface Mouse {
    fun clicked(): Boolean
}

class ButtonImage(
    private val width: Int,
    private val height: Int
) : Rectangle {
    override fun paint() = "painting ButtonImage width: $width, height: $height)"
}

class UserInput : Mouse {
    override fun clicked() = true
}

أنشأنا واجهتين: واجهة Rectangle وبها دالة ()paint مُجرّدة والتي سيطبّقها الصنف ButtonImage، وواجهة Mouse وبها دالة ()clicked مُجرّدة والتي سيطبّقها الصنف ButtonImage. وحذفنا كلمة open من الصنفين، لأنهما لن يتم الوراثة منهما لذا لا داعي لجعلهما مفتوحين للوراثة.

الآن سنعيد كتابة الصنف Button، بتطبيق الواجهتين، والحصول على تطبيقات الأصناف للدوال في الوقت ذاته، عبر التفويض:

class Button(
    private val width: Int,
    private val height: Int,
    private val image: ButtonImage = ButtonImage(width, height),
    private val input: UserInput = UserInput()
) : Rectangle by image, Mouse by input

بالإضافة لخاصيّتي الطول والعرض، لدى الصنف Button، خاصيّة image وهي كائن من النوع ButtonImage، وخاصيّة input وهي كائن من النوع UserInput. بعد تطبيق الواجهة Rectangle نفوّض الصنف ButtonImage عبر استخدام الكائن الذي يُمثله، بتطبيق الدالة المُجرّدة ()paint، نيابةً عن الصنف Button. وبعد تطبيق الواجهة Mouse، نفوّض الصنف UserInput عبر استخدام الكائن الذي يُمثله، بتطبيق الدالة المُجرّدة ()clicked، نيابةً عن الصنف Button.

وعند إنشاء كائن من الصنف Button، يمكننا أن نستدعي الدالتين اللتين تم تطبيقهما في الصنفين المفوّضين، كأن الصنف  Button يملكهما أو يطبّقهما:

fun main() {

    val button = Button(16, 4)
    println(
        """
            ${button.paint()}
            ${button.clicked()}
        """.trimIndent()
    )
}

ستكون نتيجة التنفيذ:

painting ButtonImage width: 16, height: 4
true

بعد أن يستقبل الصنف Button قيم الخاصيّتين width و height، سيمررهما للصنف المفوّض ButtonImage، والذي طبّق الدالة ()paint وأعاد النتيجة للصنف Button. أمّا صنف UserInput طبّق دالة ()clicked وأعاد النتيجة إلى Button. ما يظهر في نتيجة الطباعة، هو نتيجة تطبيق الصنفين المفوّضين، وليس الصنف Button الذي ليس به أي تطبيق لأي دالة.

هكذا نكون قد حققنا ما كنا نريده في البداية، وهو الجمع بين وظائف صنفين في صنف واحد. ولكن فعلنا ذلك، دون استخدام الوراثة.

الخلاصة:

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

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

هذا الدرس هو جزء من سلسلة تعليم مبادئ البرمجة بلغة كوتلن. لمُتابعة الدروس منذ البداية ومُشاهدة فهرس المحتويات يمكنك الانتقال إلى الدرس الأول من هنا.

هل أعجبك المحتوى وتريد المزيد منه يصل إلى صندوق بريدك الإلكتروني بشكلٍ دوري؟
انضم إلى قائمة من يقدّرون محتوى إكسڤار واشترك بنشرتنا البريدية.