أمان القيم الفارغة (Null) في لغة Kotlin

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

الأنواع Nullable والأنواع Non-Null

يهدف نظام الأنواع في Kotlin إلى الحدِّ من أخطار القيمة الفارغة null في الشيفرات، إذ إنّ أحد الأخطاء الأكثر شيوعًا في لغات البرمجة -بما فيها لغة Java- هو أنّ محاولة الوصول إلى مرجعيّةٍ تحتوي على القيمة null سيؤدي إلى حدوث استثناءٍ مرجعيّ (reference exception)، ويُدعى هذا الاستثناء في لغة Java باسم NullPointerException أو NPE اختصارًا، أمّا Kotlin فهي تحدُّ من هذا الاستثناء ليقتصر على الحالات الآتية:

  • استدعاءٌ صريحٌ بالشكل: throw NullPointerException()‎
  • استخدام المعامل !! (كما سيُشرح لاحقًا)
  • التعارض في البيانات أثناء التهيئة (initialization) كما في الحالات الآتية:
  • أثناء توافقية التبديل مع لغة Java:
    • محاولة الوصول إلى عنصرٍ من الصنف عبر مرجعيّة null من نوع المنصة (platform type)
    • الاستخدام الخاطئ لقابليّة القيمة الفارغة (nullability) في الأنواع المُعمَّمة (generic types) في التوافقيّة مع لغة Java ؛ كأن تحاول شيفرة Java إضافة قيمة null في النوع MutableList<String>‎ الموجود في Kotlin بدلًا من النوع MutableList<String?>‎ المُخصَّص للتعامل معها.
    • بعض الحالات الأخرى الناتجة عن استخدام شيفرة Java خارجيّة.

ويميّز نظام الأنواع في Kotlin بين المرجعيّة التي يمكن أن تقبل القيمة الفارغة null وتُدعى nullable وتلك التي لا يمكن أن تحتويها وتُدعى non-null، فلا يمكن مثلًا للمتغيِّر من النوع String أن يحتوي على القيمة null كما في الشيفرة:

var a: String = "abc"
a = null // سينتج خطأٌ بالترجمة

وللسماح بذلك يجب التصريح عن المتغيِّر بالنوع String?‎ (أي النوع nullable String)، كما في الشيفرة:

var b: String? = "abc"
b = null // يُسمح بهذا

وعند استدعاء تابعٍ (method) أو الوصول إلى خاصّيّةٍ في المتغيِّر a فمن المؤكَّد عدم حدوث استثناءٍ من النوع NPE (المشروح سابقًا)، وبالتالي فمن الآمن كتابة الشيفرة بالشكل:

val l = a.length

أما عند الوصول إلى نفس الخاصّيّة في المتغيِّر b فسينتج خطأٌ كما في الشيفرة:

val l = b.length // سينتج خطأ لأنه من الممكن أن يكون المتغيِّر بقيمة فارغة

وتتيح Kotlin عدّة طرقٍ للقيام بذلك دون حدوث أيّة أخطاء.

التحقُّق من القيمة الفارغة Null في الشرط

تتلخَّص الطريقة الأولى بالتأكُّد بشكلٍ صريحٍ من أنّ المتغيِّر b لا يحتوي على القيمة null ومعالجة الحالتين بشكلٍ منفصلٍ، كما يلي:

val l = if (b != null) b.length else -1

إذ يتتبَّع المترجِم (compiler) المعلومات الناتجة عن التحقُّق المُجرى، ويَسمح باستدعاء length الموجودة في الشرط if، كما تدعم Kotlin صيغةً أخرى أكثر تعقيدًا كما يلي:

if (b != null && b.length > 0) {
    print("String of length ${b.length}")
} else {
    print("Empty string")
}

وهذا يصلح فقط عندما يكون المتغيِّر b غير متغيّرٍ (immutable)؛ أي قد يكون متغيِّرًا محليًّا لا يتغيّر ما بين عملية التحقُّق والاستخدام أو متحولًا من النوع val له حقلٌ مساعدٌ (backing field) ولا يُسمَح بإعادة تعريف استخدامه (not overridable)، لأنه ما لم يكن كذلك فمن الممكن أن تتغيَّر قيمته لتصبح null من بعد عملية التحقُّق.

الاستدعاء الآمن (Safe Call)

الخيار المتاح الثاني لتجنُّب حدوث خطأٍ ناتجٍ عن القيمة null هو استخدام معامل الاستدعاء الآمن .? بالشكل:

b?.length

والذي يُعيد null إن كان المتغيِّر b يحتوي على القيمة null ويعيد b.length إن لم يكن كذلك، ويكون نوع التعبير Int?‎. يُفيد استخدام الاستدعاء الآمن في حالة السلاسل (chains)؛ فإن كان الموظَّف Bob مثلًا يتبع لقسمٍ (department) ما (وربما لا يتبع)، والذي بدوره يحتوي على موظفٍ آخر باعتباره مديرًا للقسم (head)، وبالتالي فإنه للحصول على اسم رئيس القسم الذي يعمل به الموظَّف Bob (إن وُجد) تكتب الشيفرة:

bob?.department?.head?.name

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

val listWithNulls: List<String?> = listOf("A", null)
for (item in listWithNulls) {
     item?.let { println(it) } // سيطبع A 
                               // ويتجاهل القيمة null
}

كما ويمكن أن يكون الاستدعاء الآمن على الجانب الأيسر من الإسناد، وبالتالي فإن كان أحد المستقبلين (receivers) في السلسلة بالقيمة null فلن تتمّ عملية الإسناد ولن يُحسب التعبير (expression) بالجانب الأيمن أبدًا، مثل:

// لن تُستدعى الدالة إن كانت
// person أو person.department
// بقيمة null
person?.department?.head = managersPool.getManager()

المعامل Elvis‏ (‎?:‎)

عند وجود مرجعيّة nullable ولتكن r فيمكن القول حينئذٍ بأنّه إن لم تكن r بالقيمة null فيمكن استخدامها، وإلّا فيجب استخدام قيمةٍ غير فارغةٍ (non-null) ولتكن x، كما في الشيفرة الآتية:

val l: Int = if (b != null) b.length else -1

ويمكن التعبير عن ذلك بصيغةٍ أخرى عبر استخدام المعامل ‎?:‎ كما في الشيفرة:

val l = b?.length ?: -1

فإن لم يكن التعبير على يسار المعامل ‎?:‎ بالقيمة null فسيُعيده المعامل، أمّا إن كان بالقيمة null فسيُعيد التعبيرَ في الجانب الأيمن، والذي سيُحسَب فقط عندما يكون الجانب الأيسر بالقيمة null. وبما أنّ التعليمتين return و throw تُعدَّان تعابيرًا (expressions) في لغة Kotlin فيمكن استخدامهما على الجانب الأيمن من المعامل ‎?:‎ ، وهذا يساعد عند التحقُّق من وسائط (arguments) الدالة كما في الشيفرة:

fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

المعامل !!

وهو الطريقة الثالثة لتجنُّب الاستثناء NPE (المشروح سابقًا)، حيث يحوِّل المعامل !! أيَّ قيمةٍ إلى النوع non-null وسيرمى (throw) استثناءٌ إن كانت القيمة null، يستخدم المعامل بالصيغة b!!‎ ، وهذا يعيد القيمة غير الفارغة من المتغيِّر b وهي السلسلة النصّيّة String، أو سيرمى الاستثناء NPE إن كان المتغيِّر b بالقيمة null، مثل:

val l = b!!.length

وبالتالي عند الحاجة لوجود الاستثناء NPE فهو متاحٌ.

التحويل الآمن (Safe Cast)

قد تحدث بعض الاستثناءات من النوع ClassCastException إن لم يكن الكائن (object) من النوع الهدف (target type)، ويُستخدَم التحويل الآمن حينئذٍ والذي يُعيد القيمة null إن لم تكن محاولة التحويل ناجحةً، وتكون الصيغة بالشكل:

val aInt: Int? = a as? Int

المجموعات (Collections) من النوع Nullable

إن كانت المجموعة تحتوي على عددٍ من العناصر من النوع nullable فبالإمكان استخدام الدالة filterNotNull لترشيح (filter) العناصر من النوع non-null كما في الشيفرة:

val nullableList: List<Int?> = listOf(1, 2, null, 4)
val intList: List<Int> = nullableList.filterNotNull()

مصادر