الأنواع المُعمَّمة في TypeScript

من موسوعة حسوب
اذهب إلى التنقل اذهب إلى البحث


مقدمة

بناء مكونات ذات واجهات برمجية متناسقة وثابتة من أسس هندسة البرمجيات، وجعل هذه المكونات قابلةً لإعادة الاستعمال من أحد أكبر أسُسِها كذلك. المكونات التي يُمكنها العمل مع بيانات اليوم كما بيانات المُستقبل ستُعطي إمكانيات مرنةً جدًّا لبناء نظم برمجيات كبيرة.

الأنواع المُعمّمة (generics) من الأدوات الرئيسية التي تُساعد على بناء مكوّنات قابلة لإعادة الاستعمال في لغات مثل C#‎ وJava، إذ تُعطي القُدرة على إنشاء مكوّنات يُمكن لها العمل مع عدّة أنواعٍ من البيانات عوضًا عن نوع واحد فقط. ما يسمح للمستخدمين بالاعتماد على هذه المكونات واستعمال الأنواع الخاصة بهم.

أبسط مثال للأنواع المعممة

كبداية، لننظر إلى أبسط مثال في عالم الأنواع المُعمّمة: دالة الهويّة (the identity function). دالة الهوية هي دالة تُعيد ما يُمرَّر لها من قيم. ويُمكن تشبيهها بالأمر ‎echo‎.

دون استعمال الأنواع المُعمّمة، سيتوجب علينا إمّا إعطاء دالة الهوية نوعًا مُعيّنًا:

function identity(arg: number): number {
    return arg;
}

أو يُمكن وصف دالة الهوية بالنوع ‎any‎:

function identity(arg: any): any {
    return arg;
}

رغم أنّ استعمال النوع ‎any‎ مُعمّم لأنه سيجعل الدالة تقبل أي نوع كيفما كان لقيمة ‎arg‎، إلّا أننّا نفقد معلومات النوع الذي تُعيده الدالة. إن مرّرنا عددًا مثلًا، فالمعلومة الوحيدة التي سنملكها هي أن الدالة ستُعيد أي نوع كيفما كان.

عوضًا عمّا سبق، نحتاج إلى طريقة لالتقاط نوع قيمة المُعامل المُمرّرة بطريقة يُمكننا بها معرفة ما ستُعيده الدالة. سنستعمل هنا مُتغيّر نوع (type variable)، وهو متغيّر خاص يعمل على الأنواع عوضًا عن القيم:

function identity<T>(arg: T): T {
    return arg;
}

أضفنا الآن مُتغيّر نوعٍ باسم ‎T‎ إلى دالة الهوية. يسمح لنا هذا المتغير T بالتقاط النوع الذي يُمرّره المُستخدم (النوع ‎number‎ مثلًا)، وبذلك سنتمكّن من استعمال هذه المعلومات بعد ذلك. نستعمل هنا المتغيّر ‎T‎ مُجدّدًا كنوعٍ مُعاد. يُمكننا الآن ملاحظة أنّ نفس النوع يُستخدَم مع المُعامل والنوع المعاد على حد سواء. يسمح هذا بتمرير معلومات النوع من جانب مدخَل الدالة ليخرج من الجانب الآخر.

نقول بأن هذه النسخة من الدالة ‎identity‎ مُعمّمة (generic)، لأنها تعمل مع عدّة أنواع. وعلى النّقيض من استعمال النوع ‎any‎، فهذه الدالة تعمل بنفس دقّة عمل الدالة ‎identity‎ التي تستعمل الأعداد لمُعاملها ونوعها المُعاد، وذلك لأنّها لا تفقد أية معلومات. بعد كتابة دالة الهوية المُعمّمة، يُمكننا الآن استدعاؤها بإحدى طريقتين. الطريقة الأولى تكون بتمرير جميع المُعاملات -بما في ذلك مُعامل النوع- إلى الدالة:

// نوع المتغيّر سيكون النوعَ
// 'string'
let output = identity<string>("myString");

هنا نضبط بصراحةٍ قيمة المُعامل ‎T‎ لتكون النوعَ ‎string‎ كأحد القيم المُمرّرة إلى استدعاء الدالة، مُشيرين إلى مُعامل النوع بإحاطتها بقوسي الزاوية ‎‎<>‎‎ عوضًا عن ‎‎()‎‎.

الطريقة الثانية هي الأشهر ربّما، وتكون باستعمال استنتاج مُعامل النوع (type argument inference)، أي أنّنا نُريد من المُترجم (compiler) ضبط قيمة ‎T‎ لكي تكون مبنية على نوع المُعامل المُمرّر تلقائيًّا:

// نوع المتغيّر سيكون النوعَ
// 'string'
let output = identity("myString");

لاحظ أنّنا لم نحتج إلى تمرير النوع صراحةً باستعمال أقواس الزاوية ‎(<>)‎؛ وقد نظر المُترجم فقط إلى القيمة ‎"myString"‎ فضبط قيمة ‎T‎ لتُمثّل نوعها. ورغم أن استنتاج معامل النوع أداة مُفيدة لإبقاء الشيفرة قصيرة وأكثر قابليّة للقراءة، إلا أنّك قد تحتاج إلى تمرير قيم لمعاملات الأنواع كما فعلنا في المثال السابق إن فشل المُترجم في استنتاج النّوع، ويُمكن لذلك أن يحدث في أمثلة أكثر تعقيدًا.

العمل مع متغيرات أنواع معممة (Generic Type Variables)

عند العمل مع الأنواع المُعمَّمة، يُمكن أن تُلاحظ بأنّك عندما تُنشئ دوالًا مثل الدالة ‎identity‎، فالمُترجم سيقضي بوجوب استعمال أي مُعاملات ذات أنواع مُعمّمة بشكل صحيح داخل جسم الدّالة. أي أنّك تتعامل مع هذه المُعاملات على أنّها قد تكون من أي نوع كيفما كان.

لنأخذ مثلًا الدالة ‎identity‎ من المثال السّابق:

function identity<T>(arg: T): T {
    return arg;
}

لكن ماذا لو أردنا طباعة طول (length) قيمة المُعامل ‎arg‎ على الشّاشة عند كل استدعاء؟ يُمكن أن تُفكّر في فعل ذلك بالطريقة التّالية:

function loggingIdentity<T>(arg: T): T {
    // خطأ، النوع
    // T
    // لا يملك تابعًا باسم
    // .length
    console.log(arg.length);
    return arg;
}

عندما نفعل هذا فسيُطلق المُترجم خطأ يُخبرنا بأنّنا نستخدم العنصر ‎.length‎ من ‎arg‎، لكنّنا لم نُحدّد في أي مكان بأنّ المُعامل ‎arg‎ يمتلك هذا العنصر. تذكّر بأنّ مُتغيّرات الأنواع هذه تُستخدم لجميع الأنواع كيفما كانت، لذا يُمكن لأحد أن يستدعي هذه الدالة بتمرير عدد (‎number‎)، والذي لا يملك عنصرًا باسم ‎.length‎.

لنفرض أنّنا نُريد لهذه الدالة أن تعمل مع مصفوفات من النوع ‎T‎ عوضًا عن النوع ‎T‎ مُباشرة. ولأنّنا نعمل مع المصفوفات، فالعنصر ‎.length‎ سيكون مُتاحًا. يُمكننا وصف هذا كما نُنشئ المصفوفات من الأنواع الأخرى:

function loggingIdentity<T>(arg: T[]): T[] {
    console.log(arg.length);  // لا أخطاء بعد الآن لأنّ المصفوفات تمتلك هذا العنصر
    return arg;
}

يُمكن وصف نوع الدالة ‎loggingIdentity‎ كما يلي: "تأخذ الدالة ‎loggingIdentity‎‎ مُعامل نوعٍ اسمُه ‎T‎، ومعاملًا باسم ‎arg‎ يكون مصفوفةً عناصرُها من النّوع ‎T‎، وتُعيد مصفوفةً عناصرُها من النوع ‎T‎". إن مرّرنا لها مصفوفةَ أعدادٍ فسنحصل على مصفوفة أعدادٍ كمُخرَج، وسيُضبَط ‎T‎ ليكون النوعَ ‎number‎. يسمح لنا هذا باستعمال متغيّر النوع المُعمّم ‎T‎ كجزء من الأنواع التي نتعامل معها عوضًا عن كامل النوع، ما يُعطينا مرونةً أكثر.

يُمكننا كتابة المثال أعلاه بطريقة بديلة كما يلي:

function loggingIdentity<T>(arg: Array<T>): Array<T> {
    console.log(arg.length);  // لا أخطاء بعد الآن لأنّ المصفوفات تمتلك هذا العنصر
    return arg;
}

قد تكون هذه الطريقة مألوفة لك إن كنت تستعمل أحد لغات البرمجة الأخرى التي تستعمل الأنواع المعمّمة بطريقة مُشابهة. سنُغطّي في القسم التالي كيفية إنشاء أنواع مُعمّمة خاصّة بك مثل ‎‎Array‎<‎T‎>‎‎.

الأنواع المُعمّمة (Generic Types)

أنشأنا في ما تقدّم من أقسامٍ دوالَ هوية مُعمّمة تعمل مع عدّة أنواع. سنتعرّف في هذا القسم على أنواع الدوال ذاتها وكيفية إنشاء واجهات مُعمّمة (generic interfaces).

نوع الدوال المُعمَّمة يكون مثل أنواع الدوال غير المُعمَّمة، مع تقديم معاملات الأنواع أولًا بشكل مُشابه للتصريح عن الدوال:

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: <T>(arg: T) => T = identity;

كان يُمكن لنا كذلك استعمال اسم مُغاير لمعامل النوع المُعمّم في النوع، وذلك سليم ما دام عدد متغيّرات الأنواع وكيفية استعمالها متناسقين:

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: <U>(arg: U) => U = identity;

يُمكننا كذلك كتابة النوع المُعمّم كتوقيع استدعاء (call signature) لنوع قيمة كائن حرفيّة (object literal type):

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: {<T>(arg: T): T} = identity;

ما يُؤدّي بنا إلى كتابة أول واجهة مُعمّمة (generic interface) خاصّة بنا. لنأخذ قيمة الكائن الحرفية في المثال أعلاه ولننقلها إلى واجهة كما يلي:

interface GenericIdentityFn {
    <T>(arg: T): T;
}

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: GenericIdentityFn = identity;

قد نرغب في مثال مُشابه بنقل المعامل المُعمّم ليكون مُعاملًا لِكَامل الواجهة. هذا يسمح لنا بمعرفة النوع أو الأنواع التي تكون أكثر تعميمًا على الأخرى (مثلًا، النوع ‎‎Dictionary‎<‎string>‎‎ عوضًا عن ‎Dictionary‎ فقط). هذا يجعل معامل النوع مرئيًّا لجميع العناصر الأخرى من الواجهة.

interface GenericIdentityFn<T> {
    (arg: T): T;
}

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

لاحظ بأنّ المثال قد تغيّر، فعوضًا عن وصف دالة معمّمة، أصبح لدينا الآن توقيع دالة غير مُعمّم (non-generic function signature) يُعدّ جزءًا من نوع مُعمّم. عندما نستعمل ‎GenericIdentityFn‎، فإنّنا سنحتاج الآن إلى تحديد معامل النوع كما يجب (نُمرّر هنا النوع ‎number‎)، ما يُحدّد ما سيستخدمه توقيع الاستدعاء (call signature) الضمني. فَهْمُ متى يجب تمرير معامل النوع مباشرة على توقيع الاستدعاء ومتى يجب وضعه على الواجهة سيُساعد على وصف أي الأجزاء من النوع يجب عليها أن تكون مُعمّمة.

إضافة إلى الواجهات المُعمّمة، يُمكننا كذلك إنشاء أصناف مُعمّمة. لاحظ أنّه لا يُمكن إنشاء أسماء مجالات (namespaces) أو ثوابت متعدّدة (enums) مُعمّمة.

الأصناف المعممة (Generic Classes)

للأصناف المعمّمة شكلٌ مشابه للواجهات المعمّمة. تمتلك الأصناف المعمّمة قائمة معاملات أنواع مُعمّمة (‎generic type parameter list) تكون بين قوسي زاوية (‎<>‎) بعد اسم الصنف:

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

هذا استخدام حرفي للصنف ‎GenericNumber‎، إذ أنّ اسمه يُشير إلى عدد مُعمّم (Generic Number)، لكنّك قد تُلاحظ بأنّه كان بإمكاننا استعمال نوع آخر غير النّوع ‎number‎. إذ كان يُمكننا مثلًا استخدام النوع ‎string‎ أو حتى كائنات أكثر تعقيدًا:

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function(x, y) { return x + y; };

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

وكما الواجهات، فاستخدام معامل النوع مع الصنف نفسه يسمح لنا بالتأكّد من أن جميع خاصيات الصنف تعمل مع نفس النّوع.

وكما وضّحنا سابقًا في صفحة الأصناف، فإنّ لنوع الصنف جانبان: الجانب الساكن (static side) وجانب النّسخة (instance side). الأصناف المعمّمة تكون مُعمّمة فقط على جانب النسخة ولا تكون كذلك على الجانب الساكن، لذا فعند العمل مع الأصناف، فالعناصر الساكنة لا يُمكن لها استخدام معامل نوع الصّنف.

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

قد تُلاحظ في مثالٍ سابق أنّنا قد نحتاج أحيانًا إلى كتابة دالّة معمّمة تعمل على مجموعة من الأنواع حيث تَعرفُ شيئًا عن إمكانيات مجموعة الأنواع هذه. في مثال ‎loggingIdentity‎، أردنا الوصول إلى الخاصيّة ‎.length‎ في الكائن ‎arg‎، لكن المُترجم لم يتمكّن من إثبات أنّ جميع الأنواع لها خاصيّة باسم ‎.length‎، لذا يُنبّهنا إلى عدم قُدرتنا على افتراض ذلك:

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // خطأ، النوع لا يملك الخاصية
    return arg;
}

عوضًا عن العمل مع جميع الأنواع كيفما كانت، نُريد تقييد هذه الدالة لتعمل على الأنواع التي تملك الخاصيّة ‎.length‎ فقط. وما دام النّوع يملك هذا العنصر، فسيُسمَح بذلك، لكن امتلاك هذا العنصر على الأقل يُعدّ متطلّبًا لا بد منه. للقيام بذلك، يجب علينا تحديد هذا المتطلب كقَيدٍ على النوع ‎T‎.

للقيام بهذا سنُنشئُ واجهةً تصِف هذا القيد. الواجهة التالية تملك خاصية وحيدة باسم ‎.length‎ وبعدها نستخدم هذه الواجهةَ والكلمةَ المفتاحيةَ ‎extends‎ للدلالة على هذا القيد:

interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
// نعلم الآن أنّ النوع لديه الخاصية
// .length
// لذا فلا أخطاء بعد الآن
    console.log(arg.length);
    return arg;
}

لن تعمل الدالة المُعمّمة مع جميع الأنواع كيفما كانت بعد هذا لأنّها قد قُيِّدَت:

loggingIdentity(3);  // خطأ، العدد لا يملك الخاصية التي في القيد

وهكذا لن تعمل الدّالة إلا مع القيم التي تُلبّي جميع المُتطلبات حصرًا:

loggingIdentity({length: 10, value: 3});

استعمال القيود المعممة في معاملات الأنواع

يُمكن التصريح عن مُعامل نوعٍ (type parameter) يُقيَّد بمعامل نوع آخر. مثلًا، نريد هنا الحصول على خاصيّة من كائنٍ يُعطى اسمُه. ونُريد التأكّد من أنّنا لا نُحاول الحصول على خاصية لا توجد على الكائن ‎obj‎، لذا سنضع قيدًا بين كلا النّوعين:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // مسموح به

// خطأ، لا يُمكن تعيين قيمة مُعاملٍ من النوع
// 'm'
// إلى
// 'a' | 'b' | 'c' | 'd'
getProperty(x, "m");

استعمال أنواع الأصناف (Class Types) في الأنواع المُعمّمة (Generics)

عند إنشاء مصانع الكائنات (factories) في TypeScript باستعمال الأنواع المُعمّمة، فمن الضروري الإشارة إلى أنواع الأصناف عبر دوالها البانيّة (constructor functions). مثلًا:

function create<T>(c: {new(): T; }): T {
    return new c();
}

يستعمل مثال أعقد خاصية prototype لاستنتاج وتقييد العلاقات بين الدالة البانيّة وجانب النسخة من أنواع الأصناف:

class BeeKeeper {
    hasMask: boolean;
}

class ZooKeeper {
    nametag: string;
}

class Animal {
    numLegs: number;
}

class Bee extends Animal {
    keeper: BeeKeeper;
}

class Lion extends Animal {
    keeper: ZooKeeper;
}

function createInstance<A extends Animal>(c: new () => A): A {
    return new c();
}

createInstance(Lion).keeper.nametag;  // تعبير سليم
createInstance(Bee).keeper.hasMask;   // تعبير سليم

مصادر