تعلّم البرمجة بلغة كوتلن (65): تحويل النوع للأسفل Downcasting

تعلّم البرمجة بلغة كوتلن (65): تحويل النوع للأسفل Downcasting
أستمع الى المقال

عند إجراء عملية تحويل نوع من الصنف المُشتق Derived Class إلى الصنف الأعلى Based Class، يتم أثناء وقت تشغيل البرنامج Runtime، استدعاء الدوال المتواجدة في الصنف الأعلى، وتجاهل الدوال في الصنف المُشتق. هذا يُعرف بـ مبدأ استبدالية أو قابلية استبدال Liskov كما شرحنا في درس تحويل النوع للأعلى upcasting.

ولكن على الرغم أن التحويل للأعلى هو الأشهر والأكثر استخدامًا، هناك حالات نحتاج فيها، أن نفعل العكس، فيما يعرف بـ تحويل النوع للأسفل Downcasting.

تحويل النوع لأسفل Downcasting:

تحدث عملية التحويل للأسفل، في وقت التشغيل Runtime (أثناء عمل البرنامج)، ويسمى أيضًا تحديد النوع في وقت التشغيل (run-time type identification (RTTI. وهي عملية عكس عملية التحويل للأعلى، أي أن نحوّل النوع الأعلى، إلى الصنف المُشتق منه. 

الصورة التالية توضح الفرق بين التحويلين:

upcasting-vs-downcasting-1

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

مثال عملي:

كمثال، إذا كان لدينا الشفرة التالية:

interface Base {
    fun function()
}

class Derived1 : Base {
    override fun function() = println("From Derived1 override function")
    fun memberFunction1() = println("From Derived1 member function")

}

class Derived2 : Base {
    override fun function() = println("From Derived2 override function")
    fun memberFunction2() = println("From Derived2 member function")
}

لدينا واجهة Interface اسمها Base بها دالة مُجرّدة واحدة اسمها function. ثم لدينا صنفين: Derived1 و Derived2 يطبّقان هذه الواجهة. وبالتالي، لابد أن يطبّقا الدالة المُجرّدة من الواجهة، باستخدام الكلمة override. ولكن بالإضافة لهذه الدالة، لدى كل صنف منهما دوال عضوة هما: ()memberFunction1 و ()memberFunction2 على التوالي.

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

fun main() {
    val b1: Base = Derived1()
    b1.function()
    b1.memberFunction1() // هذا السطر سينتج خطأ

    val b2: Base = Derived2()
    b2.function()
    b2.memberFunction2() // هذا السطر سينتج خطأ
}

كتبنا صراحةً النوع Base، كنوع بيانات للمتغيرين b1 و b2. وعند إسناد كائن من الصنف المُشتق لهذين المتغيرين، سيتم تحويل كلًا منهما، إلى النوع الأعلى Base، أي ستحدث عملية upcasting. لذلك، لا يمكن استدعاء دوال من الأصناف المُشتقة (غير تلك التي تم عمل تطبيق override لها)، باستخدام متغير من النوع الأعلى.

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

التحويلات الذكية Smart Casts:

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

fun main() {
    val b1: Base = Derived1()
    b1.function()
    if (b1 is Derived1)
        b1.memberFunction1()

    val b2: Base = Derived2()
    b2.function()
    if (b2 is Derived2)
        b2.memberFunction2()
}

نتمكن من استدعاء الدالة الخاصة بالصنف المُشتق، وضعنا كتلة if، والتي بها شرط سيحول الكائنين الذين يشير إليهما متغيرا b1 و b2، إلى النوع المُشتق مرة أخرى. والآن في مجال كتلة if، سيتم اعتبار b1 كائن من النوع Derived1، والمتغير b2 كائن من النوع Derived2. ولكن هذا فقط في مجال كتلة if.

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

From Derived1 override function
From Derived1 member function
From Derived2 override function
From Derived2 member function

التحويلات الذكية وكتلة when:

تكون التحويلات الذكية للأسفل، مفيدة أكثر عند استخدامها في كتلة when. كمثال، إذا كان لدينا واجهة لتمثيل المخلوقات Creatures، ثم لدينا عدد من المخلوقات التي لديها وظائف مختلف، تطبّق هذه الواجهة:

interface Creature

class Human : Creature {
    fun greeting() = println("Hello, I'm a Human!")
}

class Dog : Creature {
    fun bark() = println("hu ha woof!")
}

الصنفان Human و Dog يطبّقان الواجهة Creature. الواجهة ليس لديها دوال، ولكن لدى الصنفان دوال خاصة بهما. لذلك، عند وجود عملية تحويل للأعلى لأي من الصنفين، لن نستطيع استدعاء الدوال الخاصة بهما:

fun main() {
    val human: Creature = Human()
    human.greeting() // هذا السطر سينتج خطأ
}

لأنه حدثت عملية تحويل للأعلى، أي تم تحويل الكائن Human إلى Creature، ثم بعد ذلك تم إسناده للمتغير human، لا يمكن استخدام هذا المتغير لاستدعاء الدالة الخاصة بـ Human.

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

fun checkCreature(c: Creature) =
    when (c) {
        is Human -> c.greeting()
        is Dog -> c.bark()
        else -> println("Something else")
    }

وضعنا كتلة when في دالة منفصلة ()checkCreature. لدى الدالة، معامل من النوع Creature، يمكنها أن تستقبل به أي نوع من الأنواع التي تشتق من Creature، وهي بذلك تحقق مبدأ تعدد الأشكال. داخل الدالة، تفحص كتلة when، النوع الذي يأتي عبر المعامل c، عبر تحويله للأسفل للنوع المُشتق قبل حدوث عملية التحويل للأعلى في دالة ()main، ثم تنفذ الشرط الفرعي الخاص به. أمّا إذا كان الكائن الفرعي غير متواجد في شروط when الفرعية، حينها سيتم تنفيذ الشرط الفرعي else.

يمكننا الآن إرسال المتغير human كقيمة لمعامل ()checkCreature، وسيتم استدعاء الدالة الخاصة به:

fun main() {
    val human: Creature = Human()
     checkCreature(human)
}

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

Hello, I'm a Human!

وعند إرسال كائن تم تحويله من Dog:

fun main() {
    val dog: Creature = Dog()
    checkCreature(dog)
}

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

hu ha woof!

أمّا عند إرسال أي صنف آخر يطبّق من Creature كالتالي:

fun main() {
    class Cat : Creature
    checkCreature(Cat())
}

الصنف Cat هو صنف محلي Local Class (سنتحدث عنه في درسين قادمين) يطبّق الواجهة Creature. ولكن عند إرساله لدالة ()checkCreature، ستكون نتيجة التنفيذ هي:

Something else

أعادت كتلة when القيمة “Something else”، لأن الصنف Cat لا يتواجد في شروطها الفرعية.

مرجع (متغير) قابل للتغيير:

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

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

class SmartCast(private var c: Creature) {
    fun checkCreature() = when (c) {
            is Human -> c.greeting()
            is Dog -> c.bark()
            else -> println("Something else")
        }
}

هذه المرة، نضع الكائن من الصنف الأعلى Creature، كخاصيّة في الصنف SmartCast. بداخل الصنف، لدينا نفس الدالة السابقة، مع حذف معاملها، لأن قيمة c التي تعتمد عليها كتلة when، ستأتي عبر معامل الصنف SmartCast. نلاحظ أن الخاصيّة في الصنف هي من النوع var.

يمكننا أن نُنشئ كائن من SmartCast وإرسال كائن مُشتق تم تحويله للأعلى كما فعلنا سابقًا، ثم استدعاء دالة ()checkCreature عبر كائن SmartCast:

fun main() {
    val human: Creature = Human()
    SmartCast(human).checkCreature()
}

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

Smart cast to ‘Human’ is impossible, because ‘c’ is a mutable property that could have been changed by this time

الرسالة تقول: التحويل الذكي للصنف ‘Human’ مستحيل، لأن الخاصيّة ‘c’، هي خاصيّة قابلة لتغيير قيمتها، والتي قد تكون تغيرت بالفعل عند هذه المرحلة. 

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

معالجة الخطأ:

عند حدوث ذلك، لدينا حلّين: أمّا أن نغير var إلى val أو نعلن عن متغير آخر من النوع val داخل كتلة when، ثم نسند إليه قيمة الخاصيّة c:

class SmartCast(private var c: Creature) {
    fun checkCreature() = when (val c = c) {
            is Human -> c.greeting()
            is Dog -> c.bark()
            else -> println("Something else")
        }
}

في هذه الحالة، داخل كتلة when سيتم التعامل مع المتغير c الذي بين أقواسها. هذا الأمر سيجنبنا الخطأ، ولكن كما نرى، يجعل من الشفرة صعبة القراءة ولا ينصح به. فمن الأفضل جعل الخاصيّة c في الصنف SmartCast من النوع val، إلا إذا كنا فعليًا نحتاجها أن تكون من النوع var، حينها نستخدم الحل الآخر.

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

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

يمكننا عبر هذه الكلمة، إجبار مترجم كوتلن على قبول التحويل كما هو، ولكن يجب أن نكون متأكدين من هذا لن ينتج استثناء. كمثال، إذا كان لدينا الدالة التالية:

fun unsafeCast(c: Creature) = (c as Human).greeting()

الدالة ()unsafeCast لديها معامل من الصنف الأعلى، يستقبل كائن من أي نوع مُشتق منه. ثم في جسم الدالة، نحن نؤكد لمترجم كوتلن، أن الكائن الذي سيأتي كقيمة عبر معامل الدالة، سيكون من النوع المُشتق Human وليس أي كائن مُشتق آخر. ولفعل ذلك، وضعنا السطر التالي بين قوسين:

c as Human

كأننا نقول للمترجم، تجاهل كل المحاذير وتعامل مع قيمة c كـ كائن من النوع Human. في هذه الحالة لن يحتج المترجم، بل سنتمكن من استدعاء دالة ()greeting المتواجدة في الصنف Human أيضًا. 

وهناك طريقة أخرى يمكننا استخدامها للقيام بنفس الأمر، وهي كالتالي:

fun unsafeCast(c: Creature) {
    c as Human
    c.greeting()
}

وفي هذه الحالة أيضًا، سيتم التعامل مع المعامل c داخل مجال دالة ()unsafeCast، كأنه من النوع Human.

أي الطريقتين استخدمنا، يمكننا استدعاء دالة ()unsafeCast في دالة ()main، ومن ثم إرسال كائن Human تم تحويله إلى النوع الأعلى Creature إلى معاملها:

fun main() {
    val human: Creature = Human()
    unsafeCast(human)
}

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

Hello, I'm a Human!

أمّا عندما يتم إرسال كائن Creature كان في الأساس نوع مُشتق آخر وليس Human قبل تحويله إلى الأعلى، مثل Dog:

fun main() {
    val dog: Creature = Dog()
    unsafeCast(dog)
}

عندها يتحطم البرنامج ويقذف الاستثناء: ClassCastException، ورسالة الخطأ التالية:

class Dog cannot be cast to class Human

ما فعلناه هنا، يعرف بـ التحويل الغير آمن unsafe cast. يمكن استخدام الكلمة as بطريقة آمنة نتجنب فيها تحطم البرنامج بفعل الخطأ الاستثنائي أعلاه. وذلك بوضع علامة استفهام بعد الكلمة ?as.

التحويل الآمن Safe Cast:

عندما نضع علامة الاستفهام بعد كلمة as كالتالي:

c as? Human

هذا يعني أن نتيجة التعبير أعلاه، قد تكون null. لذلك، يجب أن نستخدم عامل الاستدعاء الآمن .? قبل استدعاء دالة ()greeting:

(c as? Human)?.greeting()

لتصبح شفرة دالة ()unsafeCast بعد إجراء التغييرات، كالتالي:

fun unsafeCast(c: Creature) = (c as? Human)?.greeting()

الآن عند إرسال كائن Creature تم تحويله من Human، سيتم استدعاء دالة ()greeting وتنفيذها. أمّا إذا كان الكائن شيء آخر مثل Dog، عندها ستكون نتيجة التعبير هي null. وبما أن النتيجة null، يمكننا تخصيص رسالة عبر استخدام العامل Elvis Operator:

fun unsafeCast(c: Creature) = (c as? Human)?.greeting() ?: "Not a Human"

إذا لم يكن الكائن الذي يتم إرساله كقيمة لمعامل دالة ()unsafeCast تم تحويله أساسًا من Human، تعيد الدالة النص “Not a Human”.

فحص الأنواع في القوائم:

يمكننا البحث عن كائن معين في قائمة List تحوي العديد من الكائنات Creature:

fun main() {
    val groupOfCreature = listOf<Creature>(
        Human(),
        Dog(),
        Cat()
    )

    val human = groupOfCreature.find { it is Human } as Human?

    println(
        human?.greeting() ?: "There is no Human in the list"
    )
}

قائمة groupOfCreature تحتوي على العديد من الكائنات المُشتقة من النوع Creature. ثم عبر دالة ()find ذات المرتبة الأعلى من مكتبة كوتلن القياسية، نحاول إيجاد كائن Human عبر استخدام is. بعد ذلك نستخدم التحويل عبر as إلى Human. وبالطبع نضع علامة الاستفهام لأن ()find قد لا تجد كائن Human في القائمة، بالتالي ستعيد القيمة null.

ثم في دالة الطباعة، نستخدم الاستدعاء الآمن لاستدعاء دالة ()greeting. وإذا كانت نتيجة التعبير كاملًا تساوي null، سيتم تنفيذ التعبير يمين العامل Elvis وهو طباعة النص “There is no Human in the list”.

استخدام دالة ()filterIsInstance:

طريقة أخرى للبحث في قائمة groupOfCreature، هي باستخدام دالة ()filterIsInstance:

fun main() {
    val groupOfCreature = listOf<Creature>(
        Human(),
        Dog(),
        Cat()
    )

    val human = groupOfCreature.filterIsInstance<Human>()

    println(human.size)
}

ستعيد الدالة، قائمة جديدة تحتوي على الكائنات التي تطابق ما هو موضوع بين قوسي زاويتها، وهو هنا Human. هذه القائمة الجديدة، سيتم إسنادها للمتغير human. ولأن هناك كائن Human واحد فقط في القائمة، سيكون حجم size القائمة الجديدة، هو 1. لذا ستكون نتيجة التعبير: human.size هي العدد 1.

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

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