التحقُّق من الأنواع (Type Check) والتحويل بينها (Casting) في لغة Kotlin

من موسوعة حسوب

المعاملين is و ‎!is

تدعم لغة Kotlin ميّزة التحقُّق من توافق الكائن مع أحد الأنواع أثناء التنفيذ، وذلك بالاعتماد على المُعامِل is أو صيغته المنفيّة ‎!is كما في الشيفرة:

if (obj is String) {
    print(obj.length)
}

if (obj !is String) { // !(obj is String) مكافئ للصيغة
    print("Not a String")
}
else {
    print(obj.length)
}

التحويلات الذكيّة (Smart Casts)

لا حاجة في كثيرٍ من الأحيان لجعل التحويل صريحًا (explicit) في لغة Kotlin لأنّ المترجم (compiler) يتتبَّع عمليات التحقُّق (عبر المعامل is) والتحويل الصريح للقيم الثابتة (immutable) ويُحوِّلها تلقائيًا وبشكلٍ آمنٍ عندما تدعو الحاجة لذلك، كما في الشيفرة:

fun demo(x: Any) {
    if (x is String) {
        print(x.length) // x سيُحوّل المتحول
                        // String تلقائيًا إلى النوع
    }
}

حيث يُعدُّ التحويل "آمنًا" إن كان التحقُّق المنفيّ يؤدي إلى أمر الرجوع return مثل:

    if (x !is String) return
    print(x.length) // x سيُحوّل المتحول
                    // String تلقائيًا إلى النوع

أو في الجانب الأيمن من المعامِلين && أو || كما في الشيفرة:

    // سيُحوّل المتحول تلقائيًا إلى نوع السلسلة النصيّة 
    // لوقوعه على الجانب الأيمن من المعامل || في الشرط 
    if (x !is String || x.length == 0) return

    // سيُحوّل المتحول تلقائيًا إلى نوع السلسلة النصيّة 
    // لوقوعه على الجانب الأيمن من المعامل && في الشرط 
    if (x is String && x.length > 0) {
        print(x.length) // x is automatically cast to String
    }

وبالإمكان استخدام مثل هذه التحويلات الذكيّة في تعابير when وحلقات while أيضًا كما في الشيفرة:

when (x) {
    is Int -> print(x + 1)
    is String -> print(x.length + 1)
    is IntArray -> print(x.sum())
}

ولا تُجرَى التحويلات الذكيّة عندما يكون المترجِم (compiler) غير قادرٍ على ضمان عدم تغيُّر المتحوِّل بين عملية التحقُّق والاستخدام، وبتفصيلٍ أكثر؛ تُجرَى التحويلات الذكيّة في الحالات الآتية:

  • المتحوّلات المحليّة (local) من النوع val: دائمًا باستثناء الخاصّيّات المُعمَّمة المحليّة (local delegated properties).
  • الخاصّيّات (properties) من النوع val: فقط إن كانت الخاصّيّات خاصةً (private) أو داخليةً (internal) أو أُجري التحقُّق منها في نفس الوحدة (module) التي تحتوي على تصريح الخاصّيّة، ولا تتمّ التحويلات في حالة الخاصّيّات المفتوحة (open) أو تلك التي يكون فيها getter مُخصَّص.
  • المتحولات المحليّة من النوع var: فقط إن لم يتغيّر المتحوّل ما بين التحقُّق والاستخدام ولم يخضغ لتعبير lambda يحوِّل من قيمته أو لم يكن خاصّيّةً مُعمَّمةً محليّةً.
  • الخاصّيّات من النوع var: أبدًا، وذلك لأنّ المتحول قد يتغيّر في أيِّ لحظةٍ ممكنةٍ وعبر أيّ شيفرة أخرى.

معامل التحويل غير الآمن

سينتُج عن استخدام معامل التحويل استثناءٌ (exception) إن لم يكن التحويل متاحًا، وهذا ما ندعوه بالتحويل غير الآمن (unsafe) ويُعتمَد في Kotlin على المعامل as بالصيغة الداخليّة (infix) كما في الشيفرة:

val x: String = y as String

إذ لا يُمكن تحويل القيمة الفارغة null إلى النوع String لأنّه ليس نوعًا nullable، وبالتالي فإن كانت قيمة المتحول y هي null سينتُج استثناء. ولتحقيق التماثل مع صيغة لغة Java يجب أن يكون النوع nullable في الجانب الأيمن من التحويل كما في الشيفرة:

val x: String? = y as String?

معامل التحويل الآمن (Nullable)

يُستخدَم معامل التحويل الآمن as?‎ (والذي يعيد القيمة null عند حدوث أيِّ خللٍ) لمنع حدوث الاستثناء (exception) السابق، مثل:

val x: String? = y as? String

وعلى الرغم من أنّ الجانب الأيمن من المعامِل as?‎ هو من النوع String (الذي لا يقبل null) فقد تكون نتيجة التحويل الإجماليّة null (أي أنّها nullable).

إزالة الأنواع (Type Erasure) والتحقُّق من الأنواع المُعمَّمة (Generic Type)

تضمن لغة Kotlin أمان الأنواع (type-safety) -بما فيها الأنواع المُعمَّمة (generics)- أثناء الترجمة (compilation) فقط، أما أثناء التنفيذ (runtime) فقد لا تحتوي بعض الكائنات من الأنواع المُعمَّمة على معلوماتٍ عن متحوّلات أنواعها الفعليّة، فيُزال النوع List<Foo>‎ مثلًا ليشمله النوع List<*>‎، إذ لا توجد طريقةٌ للتحقُّق من انتماء الكائن إلى نوعٍ مُعمَّمٍ (بمتحولات الأنواع) أثناء التنفيذ.

وبالتالي فإن المُترجِم (compiler) يمنع عمليات التحقُّق بالمعامل is أثناء التنفيذ بسبب إزالة الأنواع (type erasure) مثل: ints is List<Int>‎ أو list is T ، ولكن من الممكن التحقُّق من الكائنات المخالفة للأنواع ذات الإسقاط الواسع (star-projected type) كما في الشيفرة:

if (something is List<*>) {
    something.forEach { println(it) } // `Any?` ستُجعَل العناصر من النوع
}

وبشكلٍ مماثلٍ؛ وبما أنه تمّ التحقُّق الستاتيكيّ لمتحولات الأنواع (type arguments) للكائن (instance) أثناء الترجمة فيمكن كذلك التحقُّق بالمعامل is أو القيام بأيّ تحويلٍ يحتوي على الجزء غير المُعمَّم (non-generic part) من هذا النوع، حيث ستُحذف الأقواس <> كما في الشيفرة:

fun handleStrings(list: List<String>) {
    if (list is ArrayList) {
        // سيُحوَّل الكائن list
        // إلى النوع `ArrayList<String>`
    }
}

ويُسمَح باستخدام نفس صيغة التعامل مع متحوّلات النوع المحذوفة في حالة التحويلات التي لا تأخذ متحولات النوع بالحسبان، مثل: list as ArrayList. وفي حالة الدوال السطريّة (inline functions) بمتحولات نوعٍ reified (والمُضمَّنة [inlined] في كلّ موقع استدعاءٍ للدالة) فيُتاح التحقُّق من متحولات النوع بالشكل: arg is T ، أما إن كان arg كائنًا (instance) من النوع المُعمَّم ذاته فستُزال كذلك متحولات النوع فيه، مثل:

inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair<A, B>? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}

val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3)

val stringToSomething = somePair.asPairOf<String, Any>()
val stringToInt = somePair.asPairOf<String, Int>()
val stringToList = somePair.asPairOf<String, List<*>>()
val stringToStringList = somePair.asPairOf<String, List<String>>() // Breaks type safety!

التحويلات غير المتحقَّق منها (Unchecked Casts)

إنّ مفهوم إزالة الأنواع (type erasure) في Kotlin يمنع -كما ذكرنا آنفًا- عمليات التحقُّق من متحولات النوع الفعليّ لكائنات الأنواع المُعمَّمة (generic type instances) أثناء التنفيذ (runtime)، وقد لا ترتبط الأنواع المُعمَّمة فيما بينها بما يكفي المترجمَ لضمان أمان الأنواع، وعلى الرغم من ذلك تشتمل (imply) بعض البرامج عالية المستوى (high-level) على أمان الأنواع، مثل:

fun readDictionary(file: File): Map<String, *> = file.inputStream().use { 
    TODO("Read a mapping of strings to arbitrary elements.")
}
// عملية حفظ map
// بقيم صحيحة في ذلك الملف
val intsFile = File("ints.dictionary")

// تحذير: عملية تحويل غير مُتحقَّق منها 
// `Map<String, *>` to `Map<String, Int>`
val intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>

إذ يُصدِر المترجم تحذيرًا عند التحويل في السطر الأخير من الشيفرة حيث لا يمكن التحقُّق منه كليَّا أثناء التنفيذ وليس من المؤكَّد أن تكون القيم في كائن map من النوع Int.

ولتجنب ذلك النوع من التحويلات تُعاد هيكلة البرنامج؛ فبالإمكان إضافة الواجهتين: DictionaryReader<T>‎ و DictionaryWriter<T>‎ في البرنامج السابق واللتان تحتويان على تعريف استخدامٍ (implementation) آمنٍ لأنواع مختلفةٍ، ومن الممكن أيضًا إضافة تجريدات (abstractions) لنقل التحويل غير المتحقَّق منه (unchecked cast) من الشيفرة المستدعِية (calling cod) إلى تعريف الاستخدام (implementation)، ومن المفيد كذلك استخدام التغيُّر المُعمَّم (generic variance).

أمّا في حالة الدوال المُعمَّمة (generic functions) فإنّ استخدام متحولات الأنواع من النوع reified يجعل التحويل مثل: arg as T محقَّقًا (checked) ما لم يكن لنوع arg متحولات النوع الخاصّة به والتي ستُزال.

وللتجاوز عن تحذير التحويل غير المتحقَّق منه تُضاف الحاشية (annontation) بالصيغة ‎@Suppress("UNCHECKED_CAST")‎ للتعليمة أو التصريح الذي يحدث فيه ذلك التحذير، كما في الشيفرة:

inline fun <reified T> List<*>.asListOfType(): List<T>? =
    if (all { it is T })
        @Suppress("UNCHECKED_CAST")
        this as List<T> else
        null

إذ تحتفظ أنواع المصفوفات (array types) في بيئة JVM (مثل Array<Foo>‎) بمعلوماتٍ عن النوع المُزال (erased) لعناصرها، ويُتحقَّق جزئيًا من عملية التحويل إلى نوع المصفوفة حيث ستبقى قابلية احتواء القيمة الفارغة (nullability) ومتحولات النوع الفعلي للعناصر مُزالةً (erasaed)، فسينجح مثلًا التحويل foo as Array<List<String>?>‎ إن كانت foo  مصفوفةً تحمل أيًا من List<*>‎ سواءً أكانت nullable أم لا.

مصادر