أمان القيم الفارغة (Null) في لغة Kotlin
الأنواع Nullable والأنواع Non-Null
يهدف نظام الأنواع في Kotlin إلى الحدِّ من أخطار القيمة الفارغة null
في الشيفرات، إذ إنّ أحد الأخطاء الأكثر شيوعًا في لغات البرمجة -بما فيها لغة Java- هو أنّ محاولة الوصول إلى مرجعيّةٍ تحتوي على القيمة null
سيؤدي إلى حدوث استثناءٍ مرجعيّ (reference exception)، ويُدعى هذا الاستثناء في لغة Java باسم NullPointerException
أو NPE اختصارًا، أمّا Kotlin فهي تحدُّ من هذا الاستثناء ليقتصر على الحالات الآتية:
- استدعاءٌ صريحٌ بالشكل:
throw NullPointerException()
- استخدام المعامل
!!
(كما سيُشرح لاحقًا) - التعارض في البيانات أثناء التهيئة (initialization) كما في الحالات الآتية:
- تمرير الكلمة المفتاحيّة
this
غير المُهيَّأة (uninitialized) والمُتاحة في الباني (constructor) لاستخدامها في مكانٍ آخر - استدعاء الباني في صنفٍ أعلى (superclass) لعنصرٍ مفتوحٍ (open) يكون تعريف استخدامه (implementation) في الصنف المشتقّ (derived class) بحالةٍ غير مُهيَّأة (uninitialized)
- تمرير الكلمة المفتاحيّة
- أثناء توافقية التبديل مع لغة 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()