الأنواع المُعمَّمة (Generics) في لغة Kotlin
قد تحتوي الأصناف (classes) -كما هو الحال في لغة Java- على متحولاتٍ للأنواع (type parameters) مثل:
class Box<T>(t: T) {
var value = t
}
ولإنشاء كائنٍ (instance) من هذا الصنف يجب تحديد النوع كما في الشيفرة الآتية:
val box: Box<Int> = Box<Int>(1)
أمّا إن كان بالإمكان معرفة (infer) المتحولات إمّا من خلال متحولات الباني (constructor arguments) أو بأيّ وسيلةٍ أخرى فيمكن حينئذٍ حذف نوع المتحولات، مثل:
val box = Box(1) // القيمة 1 لها نوع الأعداد الصحيحة
// وبالتالي فإن المترجم سيحدد تلقائيًا النوع
// Box<Int>
التغيُّر (Variance)
إن نظام الأنواع (types system) واحدٌ من أكثر الأمور صعوبةً في لغة Java إذ تعتمد على الأنواع المُوسَّعة (wildcard)، ولا تدعم لغة Kotlin هذه الأنواع بل تعتمد بدلًا من ذلك: التغيُّر في موقع التصريح (declaration-site variance) وتوقُّع الأنواع (type projection).
ولكن لو تساءلنا: ما الذي دفع Java إلى الاعتماد على هذه الأنواع الموسَّعة؟ والجواب يكمُن بزيادة مرونة واجهات API ؛ إذ إنّ الأنواع المُعمَّمة في لغة Java ثابتة (invariant) وبالتالي لا يُعدُّ النوع List<String>
نوعًا فرعيًا (subtype) من النوع List<Object>
فإذا كانت القوائم غير ثابتة (not invariant) فلن تتميّز بشيء عن المصفوفات، وسينتج استثناء (exception) عند محاولة تنفيذ الشيفرة الآتية:
// Java شيفرة
List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // السبب الرئيسي بالخطأ البرمجي هو هذه التعليمة ولا يُسمح بمثل هذا الإسناد
objs.add(1); // هنا توضع القيمة الصحيحة 1 في قائمة من السلاسل النصية
String s = strs.get(0); // استثناء من نوعClassCastException
// لا يمكن تحويل قيمة العدد الصحيح إلى سلسلة نصيّة
وبالتالي فإن Java لا تسمح بمثل هذه التعليمات لضمان الأمان أثناء التنفيذ (run-time safety)، وقد يسبِّب ذلك بعضَ النتائج، فبفرض أنّ للتابع addAll()
من الواجهة Collection
مثلًا الترويسة:
// Java
interface Collection<E> ... {
void addAll(Collection<E> items);
}
فلن نستطيع القيام بما في الشيفرة الآتية على الرغم من أنها آمنة تمامًا:
// Java شيفرة
void copyAll(Collection<Object> to, Collection<String> from) {
to.addAll(from); // لن يُترجم بالتصريح السابق للدالة addAll
// لأن النوع Collection<String>
// ليس نوعًا فرعيًا من Collection<Object>
}
لذلك تُفضَّل القوائم (lists) في Java على المصفوفات (arrays) وبالتالي فإن ترويسة التابع addAll()
في Java تكون بالشكل:
// Java شيفرة
interface Collection<E> ... {
void addAll(Collection<? extends E> items);
}
إن متحول النوع المُوسَّع ? extends E
يدلُّ بأنّ هذا التابع (method) يقبل مجموعةً من الكائنات من E
أو أي نوعٍ فرعيّ (subtype) منه وليس فقط النوع E
بحدّ ذاته، وبالتالي تُمكن قراءة قيم النوع E
من العناصر (عناصر هذه المجموعة هي كائنات من الصنف الفرعي من E
)، ولكن من غير الممكن الكتابة فيها لأنّه لا يمكن تحديد أيٍّ من الكائنات تتناسب مع ذلك النوع الفرعي غير المعروف من E
، وبالمقابل؛ يُعدُّ النوع Collection<String>
نوعًا فرعيًّا من Collection<? extends Object>
وبالتالي فإنّ التوسعة باستخدام extend
(توسعة علوية [upper]) تجعل النوع متغايرًا (covariant).
والسبيل لفهم آلية العمل بمثل هذه الحالات بسيط؛ إن كان بإلإمكان الحصول فقط على العناصر من مجموعة (collection) فإنه من السهل استخدام مجموعةٍ من السلاسل النصية (String
) وقراءة كائنات منها، وبالمقابل إن كان بإلإمكان -فقط- وضع العناصر في المجموعة فإنه من الممكن إنشاء مجموعة من الكائنات (Object
) ووضع السلاسل النصية (String
) فيها، لأن النوع List<? super String>
هو نوع أعلى للنوع List<Object>
والذي يُدعى (contravariance)، إذ يمكن استدعاء التوابع التي تقبل النوع String
كمتحول في List<? super String>
فقط (أي يمكن استدعاء add(String)
أو set(int, String)
)، أما عند استدعاء أيّ تابعٍ يعيد النوع T
في List<T>
فلن تحصل على النوع String
بل النوع Object
.
وتُسمَّى الكائنات المُتاحة للقراءة فقط بالمُنتِجات (Producers) وتلك المتاحة للكتابة بالمستهلكات (Consumers)، وبتعبيرٍ أفضل: Producer-Extends, Consumer-Super.
لاحظ أنّه عند استخدام الكائن المنتِج وليكن List<? extends Foo>
فلا يمكن استدعاء التابعadd()
أو set()
عبر هذا الكائن، ولكن ذلك لا يعني أنّ هذا الكائن ثابتٌ فلا يوجد ما يمنع استدعاء clear()
لإزالة كلِّ العناصر من القائمة، وذلك لأن الدالة clear()
لا تتطلَّب وجود أيّة متحولاتٍ (parameters)، والأمر الوحيد الذي تضمنه الأنواع المُوسَّعة (wildcards) هو الأمان في النوع (type safety) أما الثبات (immutability) فهو أمرٌ آخر مختلف.
التغيُّر في موقع التصريح (Declaration-site Variance)
بفرض وجود الواجهة المُعمَّمة (generic interface) Source<T>
غير المحتوية على أيّ تابعٍ (method) له متحولاتٌ من النوع T
وإنما تحتوي فقط على توابعَ تعيد النوع T
، بالشكل:
// Java شيفرة
interface Source<T> {
T nextT();
}
فمن الآمن حينئذٍ تخزين مرجعيّةٍ (reference) لكائنٍ (instance) من Source<String>
في متحولٍ (variable) من النوع Source<Object>
حيث لا يوجد أيّ تابعٍ مستهلكٍ (consumer)، ولكن Java لا تسمح بذلك، كما في الشيفرة الآتية:
// Java شيفرة
void demo(Source<String> strs) {
Source<Object> objects = strs; // تعليمة غير مسموحة في Java
// ...
}
وللتغلُّب على تلك المشكلة يجب التصريح عن الكائنات من النوع Source<? extends Object>
، وهذا لا يبدو منطقيًا لأنه لا زال بالإمكان استدعاءُ كلُّ التوابع -نفسَها- كما في السابق، وبالتالي لا طائل من إضافة نوعٍ مُعقَّدٍ كهذا، ولكنّها الطريقة التي يعمل بها المُترجِم (compiler)!
أمّا لغة Kotlin فتدعم طريقةً أفضل تُدعَى "التغيُّر في موقع التصريح (declaration-site variance)" وذلك من خلال إضافة المُحدَّد (modifier) out
لمتحوِّل النوع T
من Source
وذلك لضمان كونه نوعًا مُعادًا فقط (أيّ مُنتَجًا [produced]) من عناصر Source<T>
لا مستهلَكًا (consumed)، ويتمِّ ذلك بالشكل:
interface Source<out T> {
fun nextT(): T
}
fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // هذا مسموح لوجود الكلمة المفتاحيّة out
// ...
}
القاعدة العامّة: إن عُرِّفَ متحول النوع T
من الصنف C
بالمُحدِّد out
فيُسمَح له أن يكون نوعًا "صادرًا" فقط عن عناصر C
، وبالمقابل من الآمن أن يكون C<Base>
نوعًا أعلى (supertype) من C<Derived>
، وبصياغةٍ أخرى: يُعدُّ الصنف C
متغايرًا (covariant) في المتحول T
أو إن T
متحولُ نوعٍ متغايرٍ، وخلاصة القول: يكون C
منتِجًا للنوع T
لا مستهلِكًا له.
ويأتي دعم لغة Kotlin للتغيُّر في موقع التصريح (declaration-site variance) عبر المُحدِّد out
على عكس ما في لغة Java ، والذي هو التغيُّر في استخدام التصريح (use-site variance) حيث تجعل توسعةُ الأنواع (wildcards) الأنواعَ متغايرةً (covariant).
كما أنّ Kotlin تدعم المُحدِّد in
مما يجعل متحول النوع contravariant، أي أنّه من المُمكن أن يكون مستهلَكًا لا منتَجًا، ويُعدُّ النوع Comparable
خير مثالٍ عنه كما في الشيفرة الآتية:
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // القيمة 1.0 لها النوع Double
// وهو نوع فرعيّ من النوع Number
// وبالتالي يمكن إسناد المتحول x
// إلى متحول آخر من النوع Comparable<Double>
val y: Comparable<Double> = x // شيفرة صحيحة
}
وقد استُخدِم المُحدِّدان in
و out
في لغة C# لبعض الوقت.