تعلّم البرمجة بلغة كوتلن (58): الوراثة Inheritance

تعلّم البرمجة بلغة كوتلن (58): الوراثة Inheritance
أستمع الى المقال

على الرغم من أن لغة البرمجة كوتلن لديها نمط وظيفي مميز باعتمادها نمط البرمجة الوظيفية، ولكنها في الأساس كائنية التوجه تعتمد نمط البرمجة الكائنية. ومن ضمن أهم ركائز النمط الكائني، مفهوم الوراثة. لذا في هذا الدرس، سنتعلم ما هو هذا المفهوم، وكيف تم تطبيقه في لغة كوتلن.

ما هي الوراثة Inheritance:

تقوم الأصناف بتخزين البيانات في الخاصيّات Properties وتنفذ الإجراءات عبر الدوال المنتمية لها Member Functions. وعند إنشاء عدة كائنات من الصنف، يحتل كل كائن منها مكانًا فريدًا خاصًا به في ذاكرة الحاسب. لذلك، نتمكن من وضع قيم مختلفة لخاصيّات كل كائن، لأنها تتواجد في أماكن مختلفة في الذاكرة.

وينتمي الكائن أيضًا إلى تصنيف اسمه صنف class. والصنف class هو من يحدد شكل هذا الكائن باستخدام الخاصيّات والدوال المتواجدة به. بالتالي، يكون شكل الكائن، هو نفس شكل الصنف المُستخدم في إنشاء الكائن.

ولكن ماذا لو كنا نريد إنشاء صنف مشابه لصنف موجود ولكن مع بعض الاختلافات؟ هل علينا إنشاء صف جديد من الصفر؟ أم استخدام الصنف الموجود وإضافة التعديلات للصنف الجديد؟

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

جعل الصنف قابل للوراثة منه:

يمكننا إنشاء صنف class في كوتلن كالتالي:

class Animal

يعتبر الصنف Animal مقفل ونهائي final وغير قابل للوراثة افتراضيًا في كوتلن. وهذا خلافًا للغة البرمجة جافا مثلًا، إذ تكون كل الأصناف فيها قابلة لتمديدها extend إلى أصناف أخرى ترث منها. فإذا كنا في جافا نريد أن نغلق صنفٍ ما ونجعله غير قابل للوراثة منه، يمكننا إضافة الكلمة المفتاحية final قبل الكلمة المفتاحية الخاصة بإنشاء الصنف class.

أمّا في كوتلن يحدث العكس، فالأصناف بطبيعتها وعند إنشائها هي مغلقة وتحمل الكلمة final. فمثلًا، الصنف Animal يمكننا كتابته كالتالي:

final class Animal

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

لذلك، إذا كنا نريد فتح الصنف للوراثة منه لاحقًا (أي جعله صنف أب parent class)، يجب أن نضع الكلمة المفتاحية open قبل الكلمة class:

open class Animal

الآن أصبح صنف Animal منفتحًا للقيام بالوراثة منه، وهو بذلك سيكون بمثابة: صنف أب parent class أو صنف أساسي base class أو صنف أعلى superclass، بالنسبة ﻷي صنف يرث منه. يمكننا وصف Animal بأي من هذه المصطلحات الثلاث عند الإشارة إلى العلاقة بينه وبين الأصناف التي ترث منه.

تمديد الصنف:

الصنف Animal هو صنف يمثل الحيوان في برنامجنا. نحن نعرف أن هناك أقسام عديدة لأنواع الحيوانات كل منها ينتمي لقسم معين. كمثال، لدينا الثدييات Mammal والزواحف Reptiles وغيرها. كلا من القسمين، يرثان خصائص الحيوان وينتميان إليه. لذا في شفرتنا يمكننا تمديد الصنف Animal إلى هذين القسمين.

عملية الوراثة من أي صنف مفتوح للوراثة منه، تتم في كوتلن بطريقة مشابهة لتطبيق الواجهة Interface، بكتابة نقطتين متعامدتين (:) بعد اسم الصنف المشتق derived class ثم اسم الصنف الأب. ولكن خلافًا للواجهة يجب أن نضيف أقواس الباني:

class Mammal : Animal()

class Reptiles : Animal()

الآن يمكننا اعتبار أيًا من الصنفين: Mammal أو Reptiles، صنف مشتق derived class أو صنف أبن child class أو صنف فرعي subclass، بالنسبة للصنف Animal. يمكننا استخدام أيًا من المصطلحات الثلاث هذه، لوصف علاقة الصنف المُستمد بالنسبة للصنف المُستمد منه. 

التسلسل الهرمي للعلاقات في الوراثة:

لفهم موضوع العلاقات بين الأصناف والأصناف المُشتقة منها، يمكننا إلقاء نظرة على الرسم البياني التالي:

الصنف الأعلى (الصنف الأب) base class في هذه العلاقة الوراثية، هو الصنف Animal. والأصناف Mammal و Reptiles، تعتبر أصناف مشتقة derived class من الصنف الأب.

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

ويمكن تمثيل العلاقات في الصورة أعلاه، عبر الشفرة التالية:

open class Animal

open class Mammal : Animal()

open class Ape : Mammal()

class Bonobo : Ape()

class Chimpanzee : Ape()

open class Reptiles : Animal()

class Crocodile : Reptiles()

class Snake : Reptiles()

الصنف الأب لكل الأصناف أعلاه، هو الصنف Animal. ومهما بلغ مدى شجرة الإشتقاق للأصناف، سترث كلها من الصنف الأب خصائصه، وتتوفر لها دواله، بالإضافة لخصائص الأصناف التي تتواجد أعلى منها. فمثلًا، صنف Bonobo سيرث من Ape و Mammal و Animal. تمامًا مثل شجرة الوراثة في الحياة الواقعية التي يكون فيها أجداد وآباء وأبناء وأحفاد …إلخ.

كما نرى تعتبر الوراثة آلية قوية تمكننا من القيام بأمور معقدة تتشابه مع ما نعيشه في حياتنا اليومية. ولتتضح مدى قوتها ولنفهمها أكثر، دعونا نقوم بكتابة أمثلة عملية في الفقرات أدناه.

توافر خاصيّات ودوال الصنف الأب:

كمثال، إذا عدّلنا على الصنف Animal كالتالي:

open class Animal {
    private val limbs: Int = 0
    private val weight: Double = 0.0
    private val age: Int = 0

    fun getAnimalInfo() : String {
        return "This animal has $limbs limbs and weighs $weight at age $age"
    }
}

لدينا في الصنف ثلاث خاصيّات: الأطراف limbs و الوزن weight و العُمر age. نلاحظ أن الخاصيّات الثلاث تسبقها الكلمة private، وهذا لحمايتها من أن يتم رؤيتها خارج الصنف اتباعًا لمبدأ التغليف Encapsulation. وأيضًا لعدم وضع الخاصيّات كمعاملات في الباني الأساسي للصنف، ولعدم وجود باني ثانوي نسند فيه قيم لهذه الخاصيّات، كان علينا إسناد قيم افتراضية إليها. ولدينا أيضًا دالة ()getAnimalInfo ووظيفتها هي إعادة معلومات عن أي كائن Animal عبر إعادة نص String به قيم الخاصيّات في رسالة منسقة.

وبما أن الصنف Bonobo يرث من Ape والذي بدوره يرث من Mammal، ثم Mammal نفسه يرث من Animal. إذًا الدالة ()getAnimalInfo ستكون متوفرة لكل هذه الأصناف بما فيها صنف Bonobo بالرغم من أنه لا يرث Animal مباشرةً. وعند إنشاء كائن من الصنف Bonobo، سنتمكن من استخدام الدالة، كأنها متواجدة داخل الصنف:

fun main() {
    val bonobo = Bonobo()
    println(bonobo.getAnimalInfo())
}

نتيجة تنفيذ الشفرة أعلاه، ستكون:

This animal has 0 limbs and weighs 0.0 at age 0

وهو النص المتواجد في الصنف الأعلى Animal. نلاحظ أن القيم الافتراضية هي التي تم طباعتها في النص. لتعديل النص وطباعة رسالة تناسب كائن الـ Bonobo، علينا أن نعيد تعريف هذه الدالة في الصنف باستخدام الكلمة override.

ولكن قبل ذلك، علينا أن نجعل الدالة في الصنف الأب منفتحة open لأن يتم وراثتها وتعديلها:

open fun getAnimalInfo() : String {
    return "This animal has $limbs limbs and weight $weight at age $age"
}

إعادة تعريف Overriding الدوال:

بعد إضافة الكلمة open للدالة في الصنف الأب، أصبح من الممكن إعادة تعريفها في الصنف المُشتق منه Bonobo:

class Bonobo : Ape() {
   override fun getAnimalInfo() : String {
       return "This animal has $limbs limbs and weight $weight at age $age"
   }
}

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

ولكن نلاحظ أن هناك رسالة تظهر لوجود الخاصيّات الخاصة بالصنف الأب:

رسالة الخطأ تخبرنا بأننا لا نستطيع استخدام الخاصيّات لأنها تحمل محدد الوصول private. والحل الذي تقترحه الرسالة هو أن نجعل الخاصيّات في الصنف الأب عامة باستخدام محدد الوصول public.

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

open class Animal {
    protected val limbs: Int = 0
    protected val weight: Double = 0.0
    protected val age: Int = 0

    open fun getAnimalInfo() : String {
        return "This animal has $limbs limbs and weight $weight at age $age"
    }
}

سيضمن محدد الوصول protected أن تتم رؤية الخاصيّات داخل الصنف الأب Animal والأصناف المُشتقة منه فقط. ولكن على الرغم من تعديل رسالة الدالة، وتمكن الصنف Bonobo من الوصول للخاصيّات:

class Bonobo : Ape() {
   override fun getAnimalInfo() : String {
       return "A Bonobo has $limbs limbs, weight $weight kg and typically live $age years!"
   }
}

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

A Bonobo has 0 limbs, weighs 0.0 kg and typically lives 0 years!

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

إعادة تعريف الخاصيّات:

بنفس طريقة الدوال، لنتمكن من إعادة تعريف الخاصيّات، يجب أن تحمل الكلمة open عند الإعلان عنها في الصنف الأب. يجب أن نكتب الكلمة open بعد الكلمة protected ولكن قبل الكلمة val:

open class Animal {
    protected open val limbs: Int = 0
    protected open val weight: Double = 0.0
    protected open val age: Int = 0

    open fun getAnimalInfo() : String {
        return "This animal has $limbs limbs and weight $weight at age $age"
    }
}

الآن يمكننا إعادة تعريف هذه الخاصيّات في الصنف Bonobo:

class Bonobo : Ape() {
    override val limbs: Int = 4
    override val weight: Double = 39.0
    override val age: Int = 40
    override fun getAnimalInfo() : String {
       return "A Bonobo has $limbs limbs, weighs $weight kg and typically lives $age years!"
   }
}

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

A Bonobo has 4 limbs, weighs 39.0 kg and typically lives 40 years!

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

إرسال قيم الخاصيّات:

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

open class Animal(
    protected val limbs: Int,
    protected val weight: Double,
    protected val age: Int
) {
    open fun getAnimalInfo() : String {
        return "This animal has $limbs limbs and weight $weight at age $age"
    }
}

نلاحظ أننا حذفنا الكلمة open من الخاصيّات, وهذا لأننا على كل حال سنكون مجبرين على إرسال قيمها عند إنشاء كائن من Animal. بالتالي، يمكننا أن نرسل القيم التي نريدها ولا نحتاج إعادة تعريفها في الأصناف المشتقة.

الآن عند الوراثة من Animal يجب أن نرسل قيم لخاصيّاته لأنها لا تملك قيم افتراضية:

open class Mammal(limbs: Int, weight: Double, age: Int) : Animal(limbs, weight, age)

لأن الصنف Mammal أصبح مجبرًا أن يرسل قيم لخاصيّات الصنف الأعلى، فهو أيضًا يمكنه أن يحصل على هذه القيم عبر معاملات بانيه الأساسي. نلاحظ أن المتغيرات في باني الصنف Mammal، هي عبارة عن معاملات وليست خاصيّات. فإذا أردناها أن تكون خاصيّات، يجب أن نضيف لها إحدى الكلمتين val أو var. ولكننا هنا لن نحتاج سوى أن تأتي لنا بالقيم والتي سنمررها بدورها لخاصيّات الصنف الأب.

بالتالي، أصبح الصنف الذي يرث من Mammal مجبرًا أيضًا على ارسال قيم لمعاملات بانيه الأساسي:

open class Ape(limbs: Int, weight: Double, age: Int) : Mammal(limbs, weight, age)

وأخيرًا صنف Bonobo يجب أن يفعل تمامًا مثل سابقيه:

class Bonobo(limbs: Int, weight: Double, age: Int) : Ape(limbs, weight, age) {
   override fun getAnimalInfo() : String {
       return "A Bonobo has $limbs limbs, weighs $weight kg and typically lives $age years!"
   }
}

وعند إنشاء كائن من الصنف Bonobo يجب أن نضع القيم الخاصة بمعاملات بانيه الأساسي:

fun main() {

    val bonobo = Bonobo(
        limbs = 4,
        weight = 31.0,
        age = 15
    )

    println(bonobo.getAnimalInfo())
}

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

val bonobo = Bonobo(4, 31.0, 15)

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

A Bonobo has 4 limbs, weighs 31.0 kg and typically lives 15 years!

هكذا نكون قد استخدمنا قيم خاصة بالصنف Bonobo ثم مرّرناها لخاصيّات الصنف Animal. لذلك تمكنا في الدالة المُعاد تعريفها ()getAnimalInfo من استخدام خاصيّات الصنف Animal في النص الذي تعيده. لأن الصنف Bonobo لا يملك خاصيّات يمكن استخدامها داخله، فقط معاملات بانيه موجودة لإجبار من يُنشئ منه كائن، أن يرسل له قيم لهذه المعاملات. بالطبع، إذا أردنا تحويل هذه المعاملات إلى خاصيّات، نحتاج أن نضيف لها كلمتي val أو var كما قلنا سابقًا.

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

ترتيب تهيئة وتنفيذ الأصناف عند الوراثة:

أثناء إنشاء كائن من الصنف المشتق derived class، يتم أولًا تهيئة الصنف الأساسي base class (يسبق هذا الأمر تنفيذ إرسال قيم لخاصيّات الصنف الأساسي)، قبل تهيئة الصنف المُشتق منه. لمتابعة ومعرفة ترتيب التنفيذ، يمكننا ذلك عبر طباعة جمل نصية ورؤية بأي ترتيب يتم طباعة كل جملة:

fun main() {

    println("1- This will print first because it's in the main fun")
    Derived("ExVar")
}


open class Base(val name: String) {

    init { println("3- Initializing a base class") }

    open val size: Unit =
        println("4- Initializing size in the base class")
}

class Derived(
    name: String
) : Base(name.also { println("2- Argument for the base class: $it") }) {

    init { println("5- Initializing a derived class") }

    override val size = println("6- Initializing size in the derived class")
}

يبدأ البرنامج من دالة ()main وينفذ دالة الطباعة لأنها تتواجد في أول سطر في الدالة الرئيسية، والتي ستطبع الجملة:

1- This will print first because it's in the main fun

ثم سيجد أن هناك إنشاء لكائن من الصنف Derived والذي يجب أن نرسل له قيمة لمعامله من النوع String. عندها سيذهب المترجم لهذا الصنف من أجل تهيئته لإنشاء كائن منه. بعدها سيجد أن الصنف هو صنف مشتق من الصنف Base، لذا سيذهب إلى الصنف الأب أولًا قبل تهيئة الصنف Derived. 

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

2- Argument for the base class: ExVar

بعدها سيذهب إلى الصنف الأب Base ويتم تهيئته حسب ترتيب أعضاء الصنف الذي استعرضناه في الدرس السابق. ستبدأ التهيئة بكتلة init ويتم تنفيذ الشفرة داخلها:

3- Initializing a base class

ثم تنفيذ تهيئة الخاصية size في الصنف الأب:

4- Initializing size in the base class

ثم بعد إنتهاء تنفيذ كل الأسطر في الصنف الأب، أي قد تمت تهيئته، سيعود للصنف المشتق منه لتهيئته. وأول ما سيتم تنفيذه في الصنف المشتق، هو بالطبع كتلة init:

5- Initializing a derived class

وأخيرًا، تهيئة الخاصيّة size الخاصة به:

6- Initializing size in the derived class

وهذا ما تظهره الصورة التالية من برنامج IntelliJ:

استدعاء الصنف الأب في الصنف المشتق:

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

class Bonobo : Ape {
    constructor(limbs: Int, weight: Double, age: Int) : super(limbs, weight, age)
    override fun getAnimalInfo() : String {
       return "A Bonobo has $limbs limbs, weighs $weight kg and typically lives $age years!"
   }
}

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

ويمكن أيضًا استخدام نفس الكلمة لاستدعاء وتنفيذ الدوال في الصنف الأب:

class Bonobo : Ape {
    constructor(limbs: Int, weight: Double, age: Int) : super(limbs, weight, age)
    override fun getAnimalInfo(): String {
        return super.getAnimalInfo() + "\nThis text will be added to the base class text"
    }
}

عند عمل override للدالة ()getAnimalInfo نستدعي داخلها أولًا الدالة في الصنف باستخدام الكلمة super التي تشير إلى الصنف الأب. أيًا ما كان داخل الدالة في الصنف الأب سيتم تنفيذه أولًا، ثم عبر العامل + الذي وضعناه، سيتم دمج النصين معًا. وعند إنشاء كائن من الصنف Bonobo و استدعاء الدالة:

fun main() {
    val bonobo = Bonobo(4, 39.0, 40)
    println(bonobo.getAnimalInfo())
}

ستكون نتيجة الطباعة هي:

This animal has 4 limbs and weight 39.0 at age 40
This text will be added to the base class text

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

الخلاصة:

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

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

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

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