تعلّم البرمجة بلغة كوتلن (66): الأصناف المغلقة Sealed Classes

تعلّم البرمجة بلغة كوتلن (66): الأصناف المغلقة Sealed Classes
أستمع الى المقال

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

أمّا في هذا الدرس، سنتعرف على صنف هو في الأصل صنف مجرّد، يحررنا من قيود الـ enum، وفي ذات الوقت يمكننا من التحكم في عدد الأصناف الفرعية التي ترث منه، خلافًا للأصناف العادية والمجرّدة. وهو الصنف المغلق Sealed Class.

ما هو الصنف المغلق Sealed Class:

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

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

الفرق بين الوراثة والأصناف المُغلقة:

وبما أن الصنف المُغلق هو صنف مجرّد، لذا عمليًا هو مفتوح للوراثة منه ضمنيًا. إذًا ما هو الفرق بينه وبين الأصناف العادية المفتوحة أو المجرّدة التي يمكن الوراثة منها؟ لفهم ذلك، فلنلقي نظرة على الشفرة التالية:

open class Transport

data class Train(
    val line: String
): Transport()

data class Bus(
    val number: Int,
    val passengers: Int
): Transport()

fun travel(transport: Transport) =
    when (transport) {
        is Train -> "Train line: ${transport.line}"
        is Bus -> "Bus number: ${transport.number}: " +
                   "size: ${transport.passengers}"
        else -> "$transport is something else!"
    }

لدينا الصنف Transport مفتوح open للوراثة منه. ثم لدينا صنفي بيانات مُشتقين منه وهما: Train و Bus. وأخيرًا دالة travel والتي بها معامل من النوع Transport. داخل الدالة، لدينا عملية تحويل نوع لأسفل Downcasting عبر كتلة when والكلمة المفتاحية is.

حينما نرسل للدالة travel أي نوع من النوعين المُشتقين Train أو Bus، ستقوم أولًا بعملية التحويل لأعلى، ليصبح نوعيهما Transport. ثم في كتلة when وعبر الكلمة is، ستجري عملية أخرى، وهي التحويل لأسفل للمعامل. فإذا كان الكائن الذي تحويله لأعلى ليناسب نوع المعامل transport، هو أساسًا Train، سيتم طباعة الجملة النصية:

Train line: ${transport.line}

بالطبع سيحل محل استدعاء الخاصيّة transport.line بقيمتها عند إنشاء كائن من Train في دالة ()main. نفس الأمر سيحدث، إذا كان النوع هو Bus، أي سيتم طباعة الجملة الخاصة به في when. وأيضًا استبدال الاستداعاءات transport.number و transport.passengers، حسب قيمهما عند إنشاء كائن من Bus في ()main.

ويكون وجود الشرط الفرعي else في كتلة when إجباريًا في هذه الحالة. لأنه إذا لم يكن الكائن الذي تم تحويله لأعلى متواجد في شروط when الفرعية، حينها سيتم طباعة:

$transport is something else!

سيحل محل المتغير transport في الجملة النصية، اسم الكائن المُرسل للدالة.

في دالة ()main، سنُنشئ قائمة List من النوع Transport، تحتوي على عناصر من الكائنات المُشتقة منه. وهي في الشفرة أعلاه، كائنين فقط:

fun main() {

    val listOfTransport = listOf<Transport>(
        Train("S1"),
        Bus(30, 48)
    )
}

استخدام مرجع الدالة ذات المستوى الأعلى:

ولأن هذه قائمة تحتوي على عدة كائنات، كيف يمكننا إرسالها لدالة travel التي تستقبل معامل واحد من النوع Transport، وليس قائمة؟ لفعل ذلك سنستعين بدوال البرمجة الوظيفية المتوافرة بكثرة في مكتبة كوتلن القياسية. ومن ضمنها دالة ()map، والتي ستعيد قائمة جديدة حسب الشرط المُعطى لها. ودالة ()forEachIndexed، والتي تدور على كل عناصر القائمة، وتوفر لنا فهرس index العنصر وقيمته:

fun main() {

    val listOfTransport = listOf<Transport>(
        Train("S1"),
        Bus(30, 48)
    )
    listOfTransport.map(::travel).forEachIndexed { index, transport ->
        var i = index
        println(
            "${++i}: $transport"
        )
    }
}

هنا نستخدم دالة ()map على القائمة listOfTransport، ونرسل لها الشرط مرجع دالة ()travel. (شرحنا هذه الطريقة في درس مراجع أعضاء الصنف). ستنفذ دالة ()map دالة ()travel، وتعيد قائمة تحتوي الجمل النصية من كتلة when. ثم نستخدم دالة ()forEachIndexed لتدور على عناصر القائمة المُعادة من دالة ()map، ومن ثم طباعتها واحدة تلو الأخرى، عبر دالة الطباعة ()println.

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

1: Train line: S1
2: Bus number: 30: size: 48

هذان هما الكائنان اللذان يتواجدان في القائمة المُخزنة في متغير listOfTransport. تم أخذ قيمهما وطباعة الجمل النصيّة التي تناسبهما، لأنه لديهما شروط فرعية في كتلة when. ولكن، ماذا إذا كان لدينا صنف آخر اسمه Tram، ما الذي سيتم طباعته من كتلة when؟ في هذه الحالة، لن يكون يكون هناك خطأ، لأن when ستنفذ مباشرةً الشرط الفرعي else.

فمثلَا، إذا كان هذا هو شكل الصنف الجديد Tram، وهو صنف بيانات أيضًا ويرث من Transport:

data class Tram(
    val number: Int
) : Transport()

وعند إضافة كائن منه في قائمة listOfTransport في دالة ()main:

val listOfTransport = listOf<Transport>(
    Train("S1"),        
    Bus(30, 48),       
    Tram(37)    
)

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

1: Train line: S1
2: Bus number: 30: size: 48
3: Tram(number=37) is something else!

نتيجة طباعة الكائن Tram لا تبدو مناسبة أليس كذلك؟ الآن لنفترض أن كتل when هذه منتشرة في كل مكان في شفرتنا. ستكون هناك مشكلة عويصة في إيجاد هذه الكتل وصيانة وإصلاح وتعديل أخطاءها.

الكلمة المفتاحية sealed:

لحسن الحظ، لدى كوتلن طريقة أخرى للقيام بنفس الأمر، باستخدام الكلمة المفتاحية sealed. عند استخدام هذه الكلمة مع الصنف الأعلى، وهو هنا Transport، سيكون معروفًا لدى المترجم، ما هي الأصناف المُشتقة من هذا الصنف، في فترة الترجمة. لذا، عند استخدام كتلة when، وكان هناك صنف مُشتق غير متواجد في شروطها الفرعية، سيجبرنا المترجم على اضافة شرط else، أو اضافة شرط لهذا الصنف.

نلاحظ هنا أن شرط اضافة else كشرط فرعي في when، أصبح اختياريًا. وهذا لأن الصنف Transport أصبح مختومًا sealed أي مغلقًا لأصناف مُشتقة محددة مسبقًا.  وللاستفادة من ذلك، يمكننا حذف الشرط الفرعي else، والاعتماد على برنامج IntelliJ، لإضافة الشروط الفرعية المتبقية، كما يظهر في الصورة المتحركة التالية:

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

الآن يمكن حذف كلمة TODO التي وضعها IntelliJ ونكتب الجملة النصية التي نريدها:

fun travel(transport: Transport) =
    when (transport) {
        is Train -> "Train line: ${transport.line}"
        is Bus -> "Bus number: ${transport.number} " +
                   "size: ${transport.passengers}"
        is Tram -> "Tram number: ${transport.number}"
    }

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

1: Train line: s1
2: Bus number: 30: size: 48
3: Tram number: 37

الفرق بين الأصناف المُغلقة والـ Enum:

يستخدم الصنف Enum في الغالب، لتخزين كائنات ثابتة القيمة، محددة مسبقًا، مرتبطة ببعضها البعض. يتشابه الصنف المُغلق Sealed Class مع الصنف Enum في هذا الأمر. ويكون الفرق بينهما، أن كائنات صنف Enum، يتوفر منها كائن واحد فقط، ولديهم كلهم نفس الحالة state (الحالة هي الخاصيّات). في المقابل، يمكن إنشاء عدة كائنات من الأصناف التي ترث من الصنف المُغلق، وكل كائن لديه حالة مختلفة عن الآخر.

ولفهم الفرق عمليًا، يمكننا إعادة كتابة المثال السابق عبر استخدام صنف enum:

fun main() {

    val listOfTransport = listOf<Transport>(
        Transport.Train,
        Transport.Bus,
        Transport.Tram
    )

    listOfTransport.map(::travel).forEachIndexed { index, transport ->
        var i = index
        println(
            "${++i}: $transport"
        )
    }
}

enum class Transport(val number: Int = 1, val line: String = "", val passengers: Int = 1) {
    Train(line = "s1"),
    Bus(number = 30, passengers = 48),
    Tram(number = 37)
}
fun travel(transport: Transport) =
    when (transport) {
        Transport.Train -> "Train line: ${transport.line}"
        Transport.Bus -> "Bus number: ${transport.number}: " +
                "size: ${transport.passengers}"
        Transport.Tram -> "Tram number: ${transport.number}"
    }

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

1: Train line: s1
2: Bus number: 30: size: 48
3: Tram number: 37

وهنا نلاحظ عدة أشياء:

  1. أن كل كائنات الصنف Transport يجب أن تستخدم الحالة (الخاصيّات) التي يوفرها الصنف Transport. ولتجنب ذلك، استخدمنا ميزة القيم الافتراضية في كوتلن، بإضافة قيمة افتراضية لكل الخاصيّات. فقط في هذه الحالة، يمكن للكائنات في enum، أن تختار عدم إرسال قيمة لخاصيّات الصنف الذي يحتويها وهو هنا Transport.
  2. نلاحظ أيضًا، أن قيم الخاصيّات ثابتة. فكل كائن يرسل قيمة الخاصيّة التي يريدها، عند الإعلان عنه. وليس من الممكن إنشاء كائن في دالة ()main، وإرسال القيم للكائن. ماذا إذا كنا نريد إنشاء كائنات بقيم مختلفة من نفس الكائن؟ عندها نستخدم الصنف المُغلق، وننشئ العدد الذي نريده من الكائنات، وبحالات مختلفة، من الأصناف المُشتقة منه.
  3. لا يوجد عملية تحويل لأسفل Downcasting في كتلة when. بل إشارة لكائن enum ثابت القيمة. لذلك، لا يسمح باستخدام الكلمة is. ولكن عند استخدام الكلمة is مع الأصناف المُشتقة من صنف مُغلق، نستفيد من ميزة التحويل الذكي Smart Cast، بالتالي، يمكن استدعاء الدوال والخاصيّات التي يوفرها الصنف المُشتق. وهو ما لن يحدث عند استخدام enum.

لذا تعتبر الأصناف المُغلقة، أقل تقييدًا من الصنف enum.

الفرق بين الأصناف المغلقة والمجرّدة:

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

protected لعدم تمكين الأصناف خارج الحزمة التي يتواجد بها الصنف المُغلق، من الوراثة منه وهو الافتراضي، أو private، لعدم تمكين الأصناف خارج الملف الذي يتواجد به، من الوراثة منه. 

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

موقع الأصناف المُشتقة من الصنف المُغلق:

يجب كتابة الأصناف المُشتقة داخل نفس الحزمة التي يتواجد بها الصنف المُغلق. سواء كانت خارج الصنف المغلق، كما في مثال Transport أعلاه، أو داخله كالتالي:

sealed class Transport {
    data class Train(val line: String): Transport()
    data class Bus(val number: Int, val passengers: Int): Transport()
    data class Tram(val number: Int) : Transport()
}

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

val train = Transport.Train("s1")

أو

val listOfTransport = listOf<Transport>(
        Transport.Train("S1"),
        Transport.Bus(30, 48),
        Transport.Tram(37)
    )

وفي كتلة when أيضًا:

fun travel(transport: Transport) =
    when (transport) {
        is Transport.Bus -> "Bus number: ${transport.number}: " +
                "size: ${transport.passengers}"
        is Transport.Train -> "Train line: ${transport.line}"
        is Transport.Tram -> "Tram number: ${transport.number}"
    }

كتابة الصنف المُشتق في ملف آخر داخل الحزمة:

لأن الصنف المُغلق Transport يحمل محدد الوصول protected افتراضيًا، يمكن لصنف تم الإعلان عنه في ملف آخر ولكن داخل نفس الحزمة، الوراثة منه:

ما يظهر في الصورة من برنامج IntelliJ هما ملفين مختلفين يتواجدان في نفس الحزمة. الشرح الكامل للصورة هو كالآتي:

  1. هذين ملفين مختلفين، أحدهما اسمه Main.kt والآخر AnotherFile.kt.
  2. يتواجد الملفين في نفس الحزمة، وهي التي يظهر رابطها في أعلى الملف.
  3. على الرغم من تواجد الصنف المُغلق في ملف Main.kt، تمكن الصنف Plane في ملف AnotherFile.kt من الوراثة منه.
  4. ولوجود صنف مُشتق جديد، ظهر خطأ في كتلة when، للدلالة على أن هناك صنف مُشتق جديد، ولا يوجد له شرط فرعي خاص به في الكتلة. لن يتم ترجمة وتجميع الشفرة، ما لم يتم معالجة هذا الخطأ.

محدد الوصول private:

من غير المهم كتابة protected أو عدم كتابتها، لأنها الافتراضي في الأصناف المُغلقة كما قلنا سابقًا. ولكن إذا أردنا أن نمنع الأصناف في نفس الحزمة من الوراثة من الصنف المُغلق، وتوفيرها فقط للأصناف التي تكون في نفس الملف، حينها نستخدم محدد الوصول private:

sealed class Transport private constructor(){
    data class Train(val line: String): Transport()
    data class Bus(val number: Int, val passengers: Int): Transport()
    data class Tram(val number: Int) : Transport()
}

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

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

الأصناف المُشتقة الغير مباشرة:

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

sealed class Transport private constructor(){
    data class Train(val line: String): Transport()
    data class Bus(val number: Int, val passengers: Int): Transport()
    data class Tram(val number: Int) : Transport()

    open class AirTransport
}

ثم الوراثة منه في صنف Plane في الملف الآخر أو أي مكان مكان آخر داخل المشروع:

data class Plane(val capacity: Int) : Transport.AirTransport()

لأن صنف Plane لا يرث مباشرةً من الصنف Transport، يمكنه الوراثة من الصنف المُشتق AirTransport. لأن محدد الوصول private هو خاص بالصنف المُغلق Transport. أمّا الصنف AirTransport فهو يحمل محدد الوصول الافتراضي public. وإذا غيرنا محدد الوصول للصنف AirTransport إلى private، حينها لن يتمكن الصنف Plane الوراثة منه أيضًا، وسينتج الخطأ:

Cannot access 'AirTransport': it is private in 'Transport'

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

الخلاصة:

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

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

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