الإضافات (Extensions) في لغة Kotlin
الإضافات (Extensions)
توفِّر لغة Kotlin -كما هو الحال في لغات البرمجة مثل C# و Gosu- إمكانيّة الإضافة على الأصناف (classes) بوظائف جديدةٍ دون اللجوء إلى الوراثة (inheritance) منها أو استخدام أيّ أنماطٍ تصميميّةٍ مثل Secorator، وذلك من خلال تصريحات خاصّة تُدعى الإضافات (extensions)، إذ تدعم لغة Kotlin الدوال الإضافيّة (extension functions) والخاصّيّات الإضافيّة (extension properties).
الدوال الإضافيّة (Extension Functions)
لتعريف دالةٍ إضافيّةٍ يجب أن يُسبَق اسمها بنوع المستقبِل (receiver type) أي النوع الذي ستتمّ الإضافة عليه، ففي الشيفرة الآتية تُضاف الدالةُ swap
إلى MutableList<Int>
بالشكل:
fun MutableList<Int>.swap(index1: Int, index2: Int) {
val tmp = this[index1] // يُعبَّر عن القائمة بالكلمة المفتاحيّة this
this[index1] = this[index2]
this[index2] = tmp
}
إذ تعبِّر الكلمة المفتاحية this
داخل الدالة الإضافيّة عن الكائن المستقبِل لها (والمتوضِع ما قبل النقطة .
)، وبعد الإضافة يمكن استدعاؤها من أي MutableList<Int>
مثل:
val l = mutableListOf(1, 2, 3)
l.swap(0, 2)
ففي الشيفرة السابقة تعبِّر الكلمة المفتاحيّة this
والموجودة داخل الدالةswap()
عن المتغيِّر l
، ولتعميم تلك الدالة تصبح الشيفرة بالشكل:
fun <T> MutableList<T>.swap(index1: Int, index2: Int) {
val tmp = this[index1] // 'this' تعبر عن القائمة
this[index1] = this[index2]
this[index2] = tmp
}
ويصرَّح عن النوع المُعمَّم (generic) قبل اسم الدالة لتصبح متاحةً في تعبير نوع المستقبِل (receiver type expression). (راجع الدوال المُعمَّمة [generic functions])
الفصل الستاتيكيّ للإضافات (Extensions are resolved statically)
لا تعدِّل الإضافات في الواقع الأصنافَ (classes) التي تُطبَّقُ عليها، إذ لا تُدخِل عناصر جديدةً ضمن الصنف بل تُضيف بعض الدوال القابلة للاستدعاء عبر صيغة النقطة .
في المتغيِّرات من نوع هذا الصنف.
ومن المؤكَّد أنَ الدوال الإضافية منفصلةٌ بشكلٍ ستاتيكيّ أي أنّها ليست افتراضيّة (virtual) من نوع المستقبِل، وهذا يعني أنّ الدالة الإضافيّة تُحدَّد بنوع التعبير (expression) الذي تُستدعى فيه وليس بنوع النتيجة الصادرة عنه أثناء التنفيذ، مثل:
open class C
class D: C()
fun C.foo() = "c"
fun D.foo() = "d"
fun printFoo(c: C) {
println(c.foo())
}
printFoo(D())
ستُظهر هذه الشيفرة الخرج: "c" لأن الدالة الإضافيّة المُستدعاة تعتمد على النوع المُعرَّف فقط للمعامل c
والذي هو الصنف C
.
أما إن احتوى الصنف على دالتين لهما نفس الاسم ونوع المستقبِل وتقبلان نفس الوسائط (arguments) وكانت الأولى منهما دالةً من أصل الصنف (member function) والأخرى دالةً إضافيّةً (extension function) فإن الأولويّة ستكون للدالة الأصليّة الموجودة في الصنف (member function)، كما في الشيفرة الآتية:
class C {
fun foo() { println("member") }
}
fun C.foo() { println("extension") }
إن استدعاء الدالة c.foo()
من أي كائنٍ c
من نوع الصنف C
سيُظهر العبارة "member" وليس "extension".
ولكن يُسمح بالتحميل الزائد (overloading) على الدالة الأصليّة في الصنف (member function) من قِبل الدالة الإضافيّة إن كان لهما نفس الاسم ولكن بتعريفين مختلفين (من ناحية عدد المعاملات أو أنواعها)، مثل:
class C {
fun foo() { println("member") }
}
fun C.foo(i: Int) { println("extension") }
حينها سيُظهِر الاستدعاء c().foo(1)
العبارة "extension".
المستقبِل من النوع Nullable
قد تٌعرَّف الإضافات بنوع المستقبل nullable (أي قد يحتوي النوع على قيمة فارغةٍ [null])، إذ تُستدعَى مثل هذه الإضافات عبر متغيِّر الكائن (object variable) حتى وإن كانت القيمة null، لأنه من الممكن التحقُّق من ذلك باستخدام الشرط: this == null
، وهذا يُفسِّر استدعاء الدالة toString()
في لغة Kotlin دون التحقُّق من القيمة null لأن التحقُّق يحدث في الدالة الإضافيّة بالشكل:
fun Any?.toString(): String {
if (this == null) return "null"
return toString()
}
إذ يُحوَّل النوعُ تلقائيًا في this
بعد عملية التحقق this == null
إلى النوع non-null وبالتالي فإن الدالة toString()
ستُفضي إلى الدالة الموجودة في الصنف Any
.
الخاصّيّات الإضافية (Extension Properties)
تدعم لغة Kotlin الخاصّيّات الإضافيّة أيضًا مثلما تدعم الدوال الإضافيّة (extension functions)، كما هو مُوضَّح في الشيفرة الآتية:
val <T> List<T>.lastIndex: Int
get() = size - 1
ولأن الإضافات لا تُدخِل أيَّ عناصر َجديدةٍ في الأصناف (classes) فليس هناك طريقةٌ فعّالةٌ لإنشاء حقلٍ مساعد (backing field) للخاصيّة الإضافيّة، ولذلك فإنه لا يُسمَح بوجود عمليات التهيئة في الخاصّيّات الإضافيّة، ويُحدَّد السلوك فيها بشكلٍ صريحٍ من خلال getter و setter، أيّ:
val Foo.bar = 1 // هذه التعليمة خاطئة لأنه لا يُسمح بالتهيئة للخاصّيّات الإضافيّة
إضافات الكائنات المرافقة (Companion Object Extensions)
عندما يحتوي الصنف على كائنٍ مرافِقٍ فيمكن حينئذٍ تعريفُ دوال وخاصّيّات إضافيّة لهذا الكائن، مثل:
class MyClass {
companion object { } // سيُدعَى "Companion"
}
fun MyClass.Companion.foo() {
// ...
}
وتٌستدعَى عبر استخدام اسم الصنف كمُحدِّدٍ (identifier) (كما هو الحال في أيّ من عناصر الكائنات المرافقة):
MyClass.foo()
مجالات الإضافات (Scope of Extensions)
تُعرَّف الإضافات في المستوى الأعلى (top-level) غالبًا أيّ بعد التصريح عن الحزمة (package) مباشرةً، كما في الشيفرة الآتية:
package foo.bar
fun Baz.goo() { ... }
ولاستخدام مثل هذه الإضافة خارج الحزمة المُعرَّفة فيها يجب استيرادها (import) لمكان الاستدعاء مثل:
package com.example.usage
import foo.bar.goo // استيراد كل الإضافات بالاسم "goo"
// أو
import foo.bar.* // استيراد كل ما هو موجود في "foo.bar"
fun usage(baz: Baz) {
baz.goo()
}
ولمزيد من المعلومات راجع استيراد الحزم (imports).
التصريح عن الإضافات كعناصر (Declaring Extensions as Members)
تتيح لغة Kotlin إمكانيّة التصريح عن الإضافات المتعلِّقة بأحد الأصناف في صنفٍ آخر غيره، وفي هذه الحالة توجد عدّة مستقبِلاتٍ ضمنيّةٍ (كائناتٍ يمكن الوصول إلى عناصرها دون تقييد [no qualifier])، ويُسمّى الكائن المأخوذ عن الصنف الذي يحتوي التصريحات الإضافيّة بالمستقبِل المنفصل (dispatch receiver) أما الكائن الذي هو من نوع المستقبِل لذلك التابع الإضافيّ (extension method) فيُسمّى المستقبل الإضافيّ (extension receiver)، مثل:
class D {
fun bar() { ... }
}
class C {
fun baz() { ... }
fun D.foo() {
bar() // تستدعي D.bar
baz() // تستدعي C.baz
}
fun caller(d: D) {
d.foo() // تستدعي الدالة الإضافيّة
}
}
وعند حدوث التضارب ما بين عناصر المستقبِل المنفصل (dispatch receiver) والمستقبِل الإضافيّ (extension receiver) تكون الأولويّة للمستقبِل الإضافيّ، أمّا للوصول إلى عناصر المستقبِل المنفصل فتُستخدَم الكلمة المفتاحيّة this
المُقيّدة (qualified this) مثل:
class C {
fun D.foo() {
toString() // استدعاء D.toString()
this@C.toString() // استدعاء C.toString()
}
وإذا عُرِّفت الإضافات بالنوع open فيمكن إعادة تعريفها (overriding) في الأصناف الفرعيّة (subclasses) وهذا يعني أن الفصل في هذه الدوال افتراضيّ (virtual) بالنسبة إلى نوع المستقبِل المنفصل ولكنّه ستاتيكيّ بالنسبة لنوع المستقبِل الإضافيّ، مثل:
open class D {
}
class D1 : D() {
}
open class C {
open fun D.foo() {
println("D.foo in C")
}
open fun D1.foo() {
println("D1.foo in C")
}
fun caller(d: D) {
d.foo() // استدعاء الدالة الإضافيّة
}
}
class C1 : C() {
override fun D.foo() {
println("D.foo in C1")
}
override fun D1.foo() {
println("D1.foo in C1")
}
}
C().caller(D()) // ستطبع العبارة "D.foo in C"
C1().caller(D()) // ستطبع العبارة "D.foo in C1"
// الفصل افتراضيّ بالنسبة للمستقبل المنفصل
C().caller(D1()) // ستطبع العبارة "D.foo in C"
// الفصل ستاتيكيّ بالنسبة للمستقبل الإضافي
الإمكانيّات المساعدة (Motivations)
تُستخدَم في لغة Java العديد من الأصناف التي تُلحق بالتسمية "*Utils" مثل الأصناف FileUtils
و StringUtils
وما يماثلهما، وينتمي الصنف java.util.Collections
إلى نفس النمط، ولكن الناحية السلبيّة عند الاستفادة من هذه الأصناف المُساعدِة (utils) هي أنّ شيفراتها تكون مثل:
// شيفرة Java
Collections.swap(list, Collections.binarySearch(list, Collections.max(otherList)), Collections.max(list));
إذ تعيق أسماء هذه الأصناف عملية البرمجة، وإن لجأ المُبرمِج إلى الاستيراد الستاتيكيّ (static import) سيحصل على الشيفرة بالشكل:
// شيفرة Java
swap(list, binarySearch(list, max(otherList)), max(list));
وتبدو أفضل من سابقتها، ولكن لا وجود لميّزة إكمال الشيفرة (كما نرغب) في بيئة العمل IDE، إذ سيكون من الأفضل لو استطعنا كتابتها بالشكل:
// شيفرة Java
list.swap(list.binarySearch(otherList.max()), list.max());
ولكن من الصعب تعريف الاستخدام (implement) لكافّة التوابع (methods) المُمكنة داخل الصنف List
، وهنا تكمن أهمية الإضافات الموجودة في Kotlin.