Generics Generics
أنواع تأكل أنواعاً
النبذة
هنا يحدث التحوّل الأكبر في تفكيرك: الأنواع تتوقّف عن كونها ثوابت، وتصير قيماً تُمرَّر إلى دوال. Generic هو دالةٌ على مستوى الأنواع: تأخذ نوعاً، تُعيد نوعاً. هذه ليست ميزة جانبية؛ إنها اللحظة التي يتحوّل فيها نظام الأنواع من "جدول تحقّق" إلى لغة برمجة بذاتها. وإن كنت قلقاً من "generics"، فالخبر السار: أنت تعرفها أصلاً من C — هذه void* لكن مع حفظ النوع، وهي قوالب C++ لكن مدمجة في اللغة. سنشتقّها من المشكلة التي تحلّها، لا كصياغةٍ تُحفظ.
اللغز المستفزّ
تريد دالة identity تُعيد ما تستقبله. حاول بما تملك من الإقليم ٢:
typescriptfunction identity(x: unknown): unknown { return x; } const a = identity(5); // نوع a؟ a.toFixed(2); // ؟
a نوعه unknown — فقدتَ المعلومة أن المُدخَل كان رقماً! unknown ابتلع النوع. لو استعملت any بدلها، لمرّ a.toFixed() لكنك أطفأت المُبرهِن (الإقليم ٢). المعضلة:
أريد دالةً تعمل مع أي نوع (مرونة unknown)، لكنها تتذكّر النوع المحدّد الذي مُرّر إليها في كل استدعاء (سلامة الأنواع الثابتة). كيف أربط نوع المُخرَج بنوع المُدخَل، وأنا لا أعرف هذا النوع وقت كتابة الدالة؟
unknown و any كلاهما يفشل: الأول ينسى، الثاني يتعامى. تحتاج شيئاً ثالثاً — متغيّراً يحمل النوع. فكّر: في C، حين أردت دالةً تعمل على أي نوع، استعملت void* وضحّيت بالنوع. ماذا لو استطعت الاحتفاظ به؟
الدرس
Generic = معاملٌ من نوع "نوع"
الحل أن نُدخل متغيّر نوع (type parameter): اسمٌ يقف مكان نوعٍ لا نعرفه بعد، يُملأ عند الاستدعاء.
typescriptfunction identity<T>(x: T): T { return x; } const a = identity(5); // T استُنتج = number؛ a نوعه number a.toFixed(2); // ✅ const b = identity("hi"); // T = string؛ b نوعه string
<T> تُعلن "هذه الدالة مُعاملة بنوعٍ اسمه T". في كل استدعاء، يُستنتَج T من الوسيط (هنا 5 ⟹ T = number)، ويُستبدل في كل مكان. ربطنا المُخرَج بالمُدخَل عبر اسمٍ مشترك T دون أن نعرف قيمته مسبقاً. هذا حرفياً دالةٌ تأخذ نوعاً وتُعيد نوعاً — لكنها تُنفَّذ وقت الترجمة، في عالم الأنواع، ثم تُمحى مع كل شيء آخر (جدار الإقليم ٠ ما زال قائماً: <T> تختفي في الناتج).
النموذج الذهني: فكّر في <T> كـ "معاملٍ" لكنه يعيش في عالم الأنواع. الدالة العادية (x) => ... تأخذ قيمة وتُعيد قيمة. الـ generic تأخذ نوعاً (<T>) ثم قيمةً، وتُعيد نوعاً وقيمة. أنت تبرمج بمستويين متوازيين: مستوى القيم (يعمل وقت التشغيل) ومستوى الأنواع (يُحسب وقت الترجمة). Generics هي حيث يبدأ المستوى الثاني يصير برمجةً حقيقية.
ربطٌ بخبرتك: في C++ هذه template<typename T> تماماً. في C، أقرب ما لديك void* — لكنها تنسى النوع وتجبرك على cast يدوي خطر. Generic تعطيك مرونة void* بلا فقدان النوع وبلا cast: المترجم يتتبّع T لك. في Python، أقرب شيء TypeVar في type hints — نفس الفكرة، نفس الدافع.
القيود (constraints): generic لا يعني "أي شيء بلا شروط"
identity<T> تقبل أي T لأنها لا تلمس بنية x. لكن ماذا لو احتجت أن تفعل شيئاً بـ x؟
typescriptfunction longest<T>(a: T, b: T): T { return a.length > b.length ? a : b; // ❌ T قد لا يكون له length }
المترجم محقّ: T قد يكون number (بلا .length). أنت لا تريد أي نوع، بل أي نوع له طولٌ. تقول هذا بـ extends:
typescriptfunction longest<T extends { length: number }>(a: T, b: T): T { return a.length > b.length ? a : b; } longest("abc", "de"); // ✅ T = string (للنصوص length) longest([1,2], [3]); // ✅ T = number[] longest(1, 2); // ❌ number ليس له length — مرفوض عند الاستدعاء
T extends C تعني — بلغة المجموعات التي تعرفها — "T يجب أن يكون نوعاً فرعياً من C"، أي مجموعة T ⊆ مجموعة C. إنها تحدّ نطاق T المسموح. لاحظ الجمال: extends هنا ليست وراثة (كما في Java)، بل شرط احتواء مجموعات — نفس ⊆ من الإقليم ١، يطفو الآن لمستوى الأنواع. هذه الكلمة extends ستعود في الإقليم ٨ بمعنى "اختبار ⊆"، وهو المعنى نفسه بالضبط.
لماذا generic أقوى من overloading أو الاتحاد
قد تسأل: لماذا لا أكتب longest(a: string | number[], ...)؟ لأن ذلك يفقد الربط: الاتحاد يسمح بخلط longest("a", [1]) ويُرجع string | number[] ضبابياً. أمّا <T extends ...> فيفرض أن المُدخَلين نفس النوع ويُعيد ذلك النوع بعينه. Generic لا يعطيك مرونةً فقط، بل يحفظ العلاقات بين المدخلات والمخرجات — وهذا جوهر قيمته. إنه يبرمج بالأنواع، لا يكتفي بقبولها.
Generics على الأنواع نفسها (لا الدوال فقط)
أيّ نوعٍ يمكن أن يكون قالباً، لا الدوال وحدها:
typescripttype Box<T> = { value: T }; type Pair<A, B> = { first: A; second: B }; type Maybe<T> = T | null; const b: Box<number> = { value: 5 }; const p: Pair<string, number> = { first: "id", second: 1 };
Box دالةٌ على مستوى الأنواع: أعطها number تُعِد { value: number }. هكذا بُنيت كل البنى المعروفة: Array<T> (نعم، T[] مجرّد اختصار لـ Array<T>)، Promise<T> (وعدٌ بقيمة T لاحقاً)، Map<K, V>, Record<K, V>. كلها قوالب تأخذ أنواعاً. حين ترى Promise<User>، اقرأها: "طبّقت دالة النوع Promise على User".
الاستنتاج (inference): المترجم يملأ T عنك
لاحظ أنك نادراً ما تكتب identity<number>(5) صراحةً؛ تكتب identity(5) والمترجم يستنتج T = number من الوسيط. هذا الاستنتاج محرّك ذكي يحلّ "ما النوع الذي لو وُضع مكان T لاتّسقت كل القيود؟". أحياناً يفشل أو يوسّع أكثر مما تريد، فتتدخّل صراحةً بـ <...>. اربطه بـ auto و template argument deduction في C++: نفس الدور — اشتقاق النوع من السياق.
القيمة الافتراضية وتعدّد المعاملات
typescripttype Container<T = string> = { items: T[] }; // افتراضي إن لم يُمرَّر type Dict<V> = Record<string, V>;
كلّها امتدادٌ مباشر للفكرة: معاملات نوعٍ متعدّدة، بعضها بقيمٍ افتراضية، تماماً كمعاملات الدوال العادية. النظام منسجمٌ مع نفسه — ولهذا يستحقّ أن يُتعلَّم بالاشتقاق لا بالحفظ: متى أمسكت "Generic = دالة على الأنواع"، استنتجت البقية.
اللغز / التمرين
(أ) اكتب firstElement<T>(arr: T[]): T | undefined تُعيد أول عنصر (أو undefined للمصفوفة الفارغة). تنبّأ بنوع firstElement([1,2,3]) و firstElement(["a"]) قبل التجربة. لماذا T | undefined لا T فقط؟ (اربطه بالعدسة: ما القيم الممكنة فعلاً للعائد؟)
(ب) — القيود. اكتب prop<T, K extends keyof T>(obj: T, key: K): T[K] تُعيد قيمة خاصيةٍ بأمان. (ستلتقي keyof و T[K] لأول مرّة — keyof T = اتحاد مفاتيح T كأنواعٍ حرفية، وT[K] = نوع الخاصية عند المفتاح K.) جرّبها على كائن، ولاحظ أن prop(user, "naem") يُرفض وقت الترجمة. اشرح: لماذا K extends keyof T يمنع المفاتيح الخاطئة، وكيف يربط هذا بـ "مجموعة المفاتيح المسموحة"؟
(ج) — لماذا الربط مهمّ. اكتب نسختين من دالة swap تأخذ زوجاً وتعكسه: نسخة بـ (p: [unknown, unknown]) ونسخة بـ <A, B>(p: [A, B]): [B, A]. مرّر [1, "x"] لكلٍّ، وحاول استعمال نتيجة كلٍّ (مثلاً result[0].toFixed()). أيّهما حفظ الأنواع؟ اكتب بجملة لماذا unknown "نسي" بينما الـ generic "تذكّر"، رابطاً ذلك بمعضلة المقدّمة.
(د) — الأعمق: generic كقالبٍ يلغي تكرار الإقليم ٦. ارجع لأنواع الإقليم ٦ (AnimalHandler, DogHandler, GetAnimal...). أعد كتابتها كقوالب generic عامة: Handler<T> = (x: T) => void و Getter<T> = () => T. الآن السؤال العميق: هل Handler<Dog> يُسنَد لـ Handler<Animal>؟ وهل Getter<Dog> يُسنَد لـ Getter<Animal>؟ تنبّأ بالاتجاه لكلٍّ مستعيناً بتباين الإقليم ٦ (أين يُستهلَك T وأين يُنتَج؟)، ثم تحقّق. اكتب: كيف ورِث القالب تباين الموضع الذي يظهر فيه T؟ (هذا يكشف أن التباين ليس صفة المصفوفات وحدها، بل صفة كل موضعٍ يظهر فيه معامل النوع — تمهيدٌ مباشر للإقليم ٨.)
الخلاصة — وصلٌ في الشجرة
حوّلنا الأنواع من ثوابت إلى دوال:
- Generic = دالة على مستوى الأنواع: تأخذ نوعاً (
<T>)، تُعيد نوعاً، تُحسب وقت الترجمة وتُمحى. تحلّ معضلة "مرنة لكن تتذكّر" التي عجز عنهاanyوunknown. T extends C= قيد احتواء مجموعات (T ⊆ C) — نفس⊆من الإقليم ١، لا وراثة بمعنى Java.- generics تحفظ العلاقات بين المدخلات والمخرجات، وهذا تفوّقها على الاتحاد والـ overloading.
- أي نوعٍ يكون قالباً (
Box<T>,Array<T>,Promise<T>)؛ والاستنتاج يملأTعنك غالباً. - ربطٌ بخبرتك:
void*/templates في C/C++، وTypeVarفي Python — نفس الدافع، بحفظ النوع.
ربطنا للوراء: استعملنا ⊆ (١)، والاتحاد/null (٢،٣)، والتباين (٦) في اللغز الأخير. ربطنا للأمام: لغز (د) كشف أن معاملات النوع تحمل تبايناً بحسب موضعها — ونحتاج أدواتٍ لـ الحساب على هذه المعاملات: أن نتفرّع حسب نوع T، ونفكّكه، ونبني منه أنواعاً جديدة. تلك هي البرمجة على مستوى الأنواع.
بذرة غموض: عرفنا أن <T> معاملٌ يُمرَّر. لكن داخل القالب، نستعمل T كما هو فقط. ماذا لو استطعنا أن نسأل عنه: "إن كان T نصاً، أعطني هذا، وإلا ذاك"؟ ماذا لو فكّكناه: "إن كان T على شكل Array<X>، استخرج X لي"؟ ماذا لو مررنا على كل مفاتيحه وبنينا نوعاً جديداً؟ عندها لا نكون نكتب أنواعاً — نكون نبرمج بها: شروط، تفكيك، حلقات، بل وعودية. نظام الأنواع لغة كاملة، وأنت على وشك أن تكتب فيها برنامجك الأول. للإقليم ٨.
التالي: 08-type-level-programming.md