الأنواع المُعمَّمة (Generics) في لغة Kotlin

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

معاملات الأنواع (Type Parameters)

قد تحتوي الأصناف (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);
}

إن وسيط (argument) النوع المُوسَّع ‎? 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 معاملُ نوعٍ covariant، وخلاصة القول: يكون 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#‎ لبعض الوقت.

الأنواع المُسقَطة (Type Projection)

التغيُّر في موقع الاستخدام (Use-site Variance): الأنواع المُسقَطة (Type Projection)

من السهل التصريحُ عن معامل النوع T كنوع out وتجنُّب المشاكل الناتجة عن الأصناف الفرعيّة في مكان استخدامها، ولكن تقتصر بعض الأصناف على النوع T كنوعٍ مُعادٍ، كما هو الحال في المصفوفات (Array) بالشيفرة الآتية:

class Array<T>(val size: Int) {
    fun get(index: Int): T { /* ... */ }
    fun set(index: Int, value: T) { /* ... */ }
}

فلا يُمكن أن يكون هذا الصنف من النوع covariant ولا contravariant (المشروحين سابقًا في هذه الصفحة) بالنسبة لمعامل النوع T، وهذا بالمقابل لا يمنح مرونةً في البرمجة، فإن كانت الدالة الآتية:

fun copy(from: Array<Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices)
        to[i] = from[i]
}

يتَّضِح من الشيفرة السابقة أنّ هذه الدالة تنسخ العناصر من مصفوفةٍ لأخرى، ولدى تجربتها واقعيًا:

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" } 
copy(ints, any) // سينتج خطأ لأن من المتوقع أن تكون الأنواع
                // (Array<Any>, Array<Any>)

ونعود للمشكلة الشائعة هنا؛ وهي أنّ Array<T>‎ ثابتة (invariant) في T وبالتالي فليس أيٌّ من النوعين Array<Int>‎ و Array<Any>‎ صنفًا فرعيًا (subtype) للآخر، ولكن لِمَ؟ لأنّ عملية النسخ قد تجلب بعض المتاعب البرمجية مثل محاولة كتابة سلسلة نصيّة (string) في المصفوفة from ، وبالتالي إذا مُرِّرت مصفوفة أعدادٍ صحيحةٍ Int هناك فقد ينتُج استثناء ClassCastException فيما بعد. ولذلك يجب ضمان أمرٍ واحدٍ وهو أنّ دالة copy()‎ لا تحتمِل حدوث أيّ مشكلة ويجب إذن منعها من الكتابة في from كما في الشيفرة الآتية:

fun copy(from: Array<out Any>, to: Array<Any>) {
 // ...
}

وما يحدث هنا يُسمّى بالأنواع المُسقَطة (type projection) بحيث يمكن القول بأن from مصفوفةٌ لكنها مُسقَطةٌ محدودةٌ إذ يمكن استدعاء تلك التوابع (methods) التي تعيد معامل النوع T فقط، مما يعني أنّه بالإمكان الاستفادة من ()get فقط، وهذه طريقة Kotlin في تغيُّر موقع الاستخدام (use-site variance) وهي تُماثل Array<? extends Object>‎ في Java ولكن بطريقة أبسط. كما ويُتاح إسقاطُ نوعٍ ما باستخدام المعامل in كما في الشيفرة الآتية:

fun fill(dest: Array<in String>, value: String) {
    // ...
}

إذ توافق الشيفرةُ Array<in String>‎ الشيفرةَ Array<? super String>‎ في Java، أي يمكن تمريرُ مصفوفةٍ من CharSequence أو مصفوفةٍ من Object للدالة fill()‎.

الإسقاط الواسع (Star-projection)

قد لا تتوافر -في بعض الأحيان- معلوماتٌ كافيةٌ عن وسيط النوع (type argument) ولا بُدَّ من استخدامه بشكل آمنٍ، يتمُّ ذلك عن طريق تعريف إسقاطٍ من النوع المُعمَّم (generic) بحيث يكون كل كائنٍ (instance) فعليٍّ من هذا النوع المُعمَّم نوعًا فرعيًا (subtype) من هذا الإسقاط.

وتوفِّر Kotlin صيغة الإسقاط الواسع لكلِّ من:

  • Foo<out T : TUpper>‎ إذ يكون T نوعًا covariant عبر الحدِّ العلوي TUpper وتكون الصيغة Foo<*>‎ مكافئةً للصيغة Foo<out TUpper>‎ مما يعني أنّه عندما يكون النوع T غير معروفٍ فمن الآمن قراءةُ قيم TUpper من Foo<*>‎.
  • Foo<in T>‎ إذ يكون T من النوع contravariant وتكون الصيغة Foo<*>‎ مكافئةً للصيغة Foo<in Nothing>‎ ممّا يعني أنه عندما يكون النوع T غير معروفٍ فلا وجود لما يُكتب بطريقة آمنة في Foo<*>‎.
  • Foo<T : TUpper>‎ إذ يكون T نوعًا ثابتًا (invariant) عبر الحدِّ العلويTUpper وتكون الصيغة Foo<*>‎ مكافئةً للصيغة Foo<out TUpper>‎ لقراءة القيم ومكافئةً للصيغة Foo<in Nothing>‎ لكتابتها.

ولدى وجود عدّة معاملات للأنواع لنفس النوع المُعمَّم (generic) فيمكن حينئذٍ أن تُسقَط بشكلٍ مستقِّلٍ، فإن كان النوع مثلًا مُصرَّحًا بالشكل: interface Function<in T, out U>‎ تكون الإسقاطات الواسعة حينها بالشكل:

  • Function<*, String>‎ والتي تعني Function<in Nothing, String>
  • Function<Int, *>‎ والتي تعني Function<Int, out Any?>‎
  • Function<*, *>‎ والتي تعني Function<in Nothing, out Any?>

وهي تُماثِل بذلك الأنواع الخامّ في Java ولكنها آمنة (safe).

الدوال المُعمَّمة (Generic Functions)

قد يكون للدوال أيضًا معاملات للأنواع تُكتب قبل اسم الدالة بالشكل:

fun <T> singletonList(item: T): List<T> {
    // ...
}

fun <T> T.basicToString() : String {  // دالة إضافية extension
    // ...
}

ولاستدعاء الدالة المُعمَّمة يجب تحديد معاملات النوع في الاستدعاء بعد اسم الدالة بالشكل:

val l = singletonList<Int>(1)

وقد يُستغنى عنها إن كان بالإمكان تحديدها من السياق، كما في الشيفرة الصحيحة الآتية:

val l = singletonList(1)

قيود الأنواع المُعمَّمة (Generic Constraints)

تحدُّ هذه القيود من خيارات مجموعة الأنواع المُتاحة كبديلٍ لمعامل النوع الحاليّ.

قيد الحدود العليا (Upper Bounds)

وهو من أشهر القيود التي توافق الكلمة المفتاحيّة extend في لغة Java كما في الشيفرة:

fun <T : Comparable<T>> sort(list: List<T>) {
    // ...
}

إذ يكون الحدُّ الأعلى هو النوع المُحدَّد بعد النقطتين الرأسيّتين (:)، ويمكن استبدال الأنواع الفرعيّة فقط من Comparable<T>‎ بمعامل النوع T مثل:

sort(listOf(1, 2, 3)) // تعليمة صحيحة لأن نوع الأعداد الصحيحة Int
                      // هو نوع فرعي من Comparable<Int>

sort(listOf(HashMap<Int, String>())) // استخدام خاطئ لأنّ
                                     // HashMap<Int, String>
                                     // ليست نوعًا فرعيًا من
                                     // Comparable<HashMap<Int, String>>

ويكون النوع Any?‎ الحدَّ العلويَّ الافتراضيّ ما لم يُحدَّد غيره، ولا يُسمَح بالتقييد بأكثر من حدٍّ واحدٍ، إذ تُستخدَم عبارة whereعندما يتطلَّب الأمر وجودَ أكثر من حدٍّ علويٍّ واحدٍ، مثل:

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
    where T : CharSequence,
          T : Comparable<T> {
    return list.filter { it > threshold }.map { it.toString() }
}

إزالة الأنواع (Type Erasure)

تجري عمليات التحقُّق بهدف ضمان سلامة الأنواع (والتي تجريها لغة Kotlin على استخدام تصريحات الأنواع المُعمَّمة [generics]) أثناء عملية الترجمة (compilation) فقط، أما أثناء التنفيذ (runtime) فإن الكائنات (instances) من الأنواع المُعمَّمة لا تحتوي على أيّة معلوماتٍ تدلُّ على معاملات أنواعها الفعليّة، ويُصطلح على أنها قد أُزيلت (erased)؛ لذلك فإن الكائنات من النوع Foo<Bar>‎ والنوع Foo<Baz?>‎ مثلًا تُزال لتصبح فقط من Foo<*>‎.

وبالتالي فإنّه ما من طريقةٍ عامّةٍ للتحقُّق فيما إذا أُنشِئ الكائن من النوع المُعمَّم أثناء التنفيذ، ويَمنع المُترجِم عمليات التحقُّق (باستخدام is) هذه، ولا يمكن التحقق كذلك -أثناء التنفيذ- من عمليات التحويل إلى الأنواع المعمَّمة بمعاملات أنواع حقيقيّة (concrete) (مثل foo as List<String>‎)، وتُستخدَم هذه التحويلات (غير المُتحقَّق منها) عندما يكون أمان النوع (type safety) موجودًا ضمنيًا في منطق البرنامج عالي المستوى (high-level) ولا يمكن للمترجم معرفته (infer)، إذ سيُظهِر المترجم تحذيرًا بمثل هذه التحويلات (غير المُتحقَّق منها) لأن عمليات التحقُّق أثناء التنفيذ تشمل الأجزاءَ غير المُعمَّمة فقط (ما يكافِئ foo as List<*>‎).

وتتمُّ أثناء الترجمة (لا التنفيذ) عملياتُ التحقُّق من معاملات الأنواع لاستدعاءات الدوال، إذ ليس بالإمكان استخدام معامل النوع للتحقُّق من النوع داخل بنية الدالة (function body)، كما ولا يُتحقَّق من تحويلات الأنواع (typecasts) لمعاملات الأنواع (مثل foo as T)، أمّا معاملات النوع reified في الدوال المباشرة فتُستبدَل ليحِلّ محلها معامل النوع الفعليّ في بنية الدالة (function body) عند نقطة الاستدعاء، ويُمكن إذن استخدامُها بعمليات التحقُّق والتحويل بين الأنواع (casts) وبنفس القيود على كائنات (instances) الأنواع المُعمَّمة كما وُصِف سابقًا.

مصادر