TYPE ⊆ SET منهج TypeScriptمن الجذور tsc --noEmit ✓
الإقليم ٦06

الدوال والتباين Variance

الإقليم الذي يخدع حدْسك


النبذة

هذا أصعب إقليم نظرياً، وأكثره مكافأةً. السؤال يبدو بريئاً: متى تكون دالةٌ ما "نوعاً فرعياً" من دالةٍ أخرى؟ أي متى يصحّ تمرير f حيث يُتوقَّع نوع دالة آخر؟ الإجابة تقلب حدْسك: المعاملات (parameters) تتبع بالمقلوب. ومن هنا نشتقّ — لا نحفظ — لماذا Dog[] المُسنَدة حيث Animal[] ثقبٌ في السلامة، ولماذا قبلت TypeScript هذا الثقب بكامل وعيها. حين تُتقن هذا الإقليم، تكون قد فهمت العمود الفقري لأنظمة الأنواع كلها، لا TypeScript وحدها.


اللغز المستفزّ

دالتان، وسؤال إسناد:

typescript
type Animal = { name: string }; type Dog = { name: string; breed: string }; type AnimalHandler = (a: Animal) => void; const handleDog = (d: Dog) => console.log(d.breed); const h: AnimalHandler = handleDog; // ؟ هل يُقبل؟

حدْسك يقول: "Dog نوعٌ فرعي من Animal (شكلٌ أغنى، الإقليم ٥). فدالةٌ تعالج Dog يجب أن تُقبل حيث تُطلب دالةٌ تعالج Animal، صح؟" خطأ. وهذا الخطأ بالذات — المقنع جداً — هو ما يجعل المعاملات تتبع اتجاهاً معكوساً. لماذا؟

ثم اللغز الأكبر، خلفه:

typescript
const animals: Animal[] = [{ name: "cat" }]; const dogs: Dog[] = [{ name: "rex", breed: "lab" }]; const ref: Animal[] = dogs; // TypeScript تقبل هذا ref.push({ name: "intruder" }); // وهذا أيضاً dogs[1].breed; // 💥 undefined وقت التشغيل — دسسنا "حيواناً" بلا breed في مصفوفة كلاب

كل سطر يمرّ من المترجم. النتيجة كارثة وقت التشغيل. لماذا تسمح لغةٌ سلامتها مقدّسة بهذا؟ ليست غفلة — إنه قرار. اكتشف لماذا.


الدرس

استبدالية ليسكوف، من المبادئ الأولى

السؤال الجوهري: متى أستطيع وضع قيمةٍ من نوع S حيث يُتوقَّع نوع T؟ الجواب من الإقليم ١: حين S ⊆ T (مجموعة S تحقّق كل ما يضمنه T). هذا مبدأ الاستبدالية: S تصلح بدل T إن كانت تفي بكل عقد T دون مفاجآت.

طبّقه على الدوال. عقد AnimalHandler = (a: Animal) => void يَعِد المُستهلِك بأمرين:

  1. "تستطيع أن تناديني بأي Animal." (عقدٌ على ما تَقبله الدالة.)
  2. "لن أعيد لك شيئاً تعتمد عليه." (عقدٌ على ما تُرجعه — هنا void.)

لكي تَصلُح handleDog بدل AnimalHandler، يجب أن تفي بهذين الوعدين تجاه أي مُستهلِكٍ يتوقّع AnimalHandler.

المعاملات: contravariance (التباين العكسي)

ركّز على الوعد الأول. المُستهلِك يظنّ أنه يستطيع تمرير أي Animal — قطّة، عصفور، أي شيء له name. لكن handleDog تطلب Dog، وتلمس d.breed. فلو مرّر المُستهلِك قطّةً (Animal بلا breed)، لقرأت handleDog خاصية breed غير موجودة. انهار العقد.

إذاً handleDog لا تصلح بدل AnimalHandler، ويجب أن يرفضها المترجم. اشتقّ القاعدة العامة:

ملاحظة

دالةٌ تصلح بدل أخرى فقط إذا كانت تقبل معاملاتٍ بنفس الاتساع أو أوسع. أي: معامل البديل يجب أن يكون superset (نوعاً أعمّ) من معامل المتوقَّع. هذا عكس اتجاه المعتاد — ولذا يُسمّى contravariance.

العكس هو الصحيح: دالةٌ تقبل Animal (أو حتى unknown) تصلح بدل واحدةٍ تتوقّع Dog، لأنها تتعامل مع أي كلبٍ دون أن تطلب أكثر مما لدى الكلب:

typescript
type DogHandler = (d: Dog) => void; const handleAnyAnimal = (a: Animal) => console.log(a.name); const dh: DogHandler = handleAnyAnimal; // ✅ تقبل Animal، فتقبل أي Dog من باب أولى

لماذا "عكسي"؟ لأن الدالة تستهلك المعامل. كلّما طلبت أقل (نوعاً أعمّ)، صلَحت في أماكن أكثر. الاستهلاك يقلب اتجاه الاحتواء. هذه ليست قاعدةً تُحفظ؛ إنها نتيجةٌ حتميّة لـ "البديل يجب أن يفي بكل ما وَعد به الأصل".

الإرجاع: covariance (التباين الطردي)

الآن الوعد الثاني — القيمة العائدة. هنا الدالة تُنتج، لا تستهلك. المُستهلِك يتوقّع نوع إرجاع T؛ لو أعطته الدالة نوعاً أضيق S ⊆ T، فكل ما يصله عضوٌ في T كما توقّع. سليم. فالإرجاع يتبع بنفس الاتجاهcovariance:

typescript
type GetAnimal = () => Animal; const getDog = (): Dog => ({ name: "rex", breed: "lab" }); const g: GetAnimal = getDog; // ✅ من يتوقّع Animal، يَسعده أن يحصل على Dog (أضيق)
مبدأ

القاعدة الموحّدة، اشتقّها مرّة وتملكها للأبد: ما تُنتجه الدالة يتبع طردياً (covariant)؛ ما تستهلكه يتبعه عكسياً (contravariant). الإنتاج يحافظ على الاتجاه، الاستهلاك يقلبه. كل تباين في كل نظام أنواع — generics، المصفوفات، الـ Promises — حالةٌ من هذا المبدأ الواحد.

الثقب المقصود: لماذا المصفوفات غير سليمة (unsound)

الآن نحصد. المصفوفة T[] تجمع الدورين: تقرأ منها (تُنتج T — covariant) وتكتب فيها (تستهلك T — contravariant). لكي تكون سليمةً تماماً، يجب أن تكون لا متباينة (invariant): Dog[] لا تُسنَد لـ Animal[] ولا العكس.

لكن TypeScript تعامل المصفوفات covariantly: تسمح Dog[] حيث Animal[]. وهذا بالضبط الثقب في لغز المقدّمة: عبر مرجع Animal[]، تستطيع push قطّةً، فتفسد مصفوفة الكلاب، وينفجر القارئ الأصلي لاحقاً. النظام يعرف هذا ويسمح به عمداً.

ليش تختار TypeScript اللاسلامة هنا؟ مقايضةٌ صريحة بين الأمان والإزعاج:

فاختارت TypeScript: اقبل ثقباً صغيراً نادر الإيذاء مقابل ergonomics ضخمة.

ملاحظة

هذا أهم درسٍ تصميمي في المنهج كله: TypeScript ليست نظاماً سليماً (sound)، عن قصد. نظام الأنواع السليم تماماً يرفض كل برنامجٍ خاطئ — لكنه يرفض أيضاً كثيراً من البرامج الصحيحة، ويُتعب مستخدميه. TypeScript تفضّل "مفيدة وعمليّة" على "مثاليّة وسليمة". تعرف ثقوبها، وثّقتها، وتركتها لأن إغلاقها يكلّف أكثر مما يوفّر. حين يُفاجئك ثقبٌ كهذا، لا تظنّه خطأً — اسأل: أي ergonomics اشترَتها اللغة بهذا الثمن؟ (الإقليم ٩ يجمع كل الثقوب.)

bivariance في معاملات الدوال: ثقبٌ آخر، وكيف تُغلقه

ثقبٌ تاريخي مشابه: معاملات الدوال في صيغة method تُعامَل bivariantly (تقبل الاتجاهين)، وهو غير سليم، لكنه يجعل أنماطاً شائعة (كمعالِجات الأحداث) تعمل بسلاسة. العَلَم strictFunctionStypes (ضمن strict) يفرض الـ contravariance الصحيحة على معاملات أنواع الدوال — لكن يستثني صيغة الـ method عمداً، للحفاظ على توافق أنماطٍ شائعة مثل المصفوفات. أي: ثقبٌ معروف، مُوثَّق، مُبقى عن قصد للسبب ذاته (ergonomics).

اربط هذا بالعَلَم نفسه الذي ألزمناك به: strict لا يجعل النظام سليماً تماماً (مستحيل عملياً)، لكنه يُغلق أكبر الثقوب التي يُجدي إغلاقها. ما تبقّى مفتوحاً، تُرك بحسابٍ لا بغفلة.

وأخيراً: لماذا () => void يقبل دالةً تُرجع قيمة

أغلقنا دَيناً من الإقليم ٢. void كنوع إرجاع متوقَّع يعني "لن أنظر للعائد". في لغة التباين: مُستهلِكٌ يتوقّع () => void لا يعتمد على القيمة، فأي دالةٍ تُرجع أي شيء تصلح بدلها — إرجاعها يُتجاهَل. هذا ليس استثناءً عشوائياً بل تطبيقٌ مباشر لـ covariance الإرجاع مع نوعٍ مستهلِكه أعمى عنه. لهذا يعمل arr.forEach(x => arr.push(x)) رغم أن push تُرجع رقماً.


اللغز / التمرين

(أ) اشتقّ كل إسناد من مبدأ "البديل يفي بكل وعود الأصل"، لا بالحدْس:

typescript
type Animal = { name: string }; type Dog = { name: string; breed: string }; let f1: (a: Animal) => void; let f2: (d: Dog) => void; f1 = f2; // ؟ أيّ اتجاه؟ f2 = f1; // ؟ let g1: () => Animal; let g2: () => Dog; g1 = g2; // ؟ g2 = g1; // ؟

لكل سطر: مَن المُستهلِك، وما الوعد الذي قد يُكسَر لو سُمح بالإسناد الخاطئ؟

(ب) — أعِد اكتشاف الثقب بيدك. أنشئ Cat/Dog مشتقّين من Animal، ومصفوفة Dog[]. أسنِدها لمرجع Animal[]، ادسّ Cat عبر المرجع، ثم اقرأ خاصيةً خاصة بالكلاب من المصفوفة الأصلية. شغّله بـ Node وشاهد الانفجار. اكتب: في أي سطرٍ بالضبط كذب نظام الأنواع، وأي ergonomics اشترتها TypeScript بقبول تلك الكذبة؟

(ج) — اللغز المعكوس. صمّم نوعَي دالة A و B بحيث: let a: A = b يُقبل، لكن let b: B = a يُرفض، حيث الفرق فقط في نوع معاملٍ واحد (لا في الإرجاع). ثم اصنع زوجاً آخر يكون الفرق فيه فقط في الإرجاع، بنفس اتجاه القبول/الرفض. لاحظ أن المعامل والإرجاع يتطلّبان علاقتين متعاكستين بين الأنواع لتحصل على نفس اتجاه الإسناد — اشرح لماذا بجملة واحدة.

(د) — الأعمق: لماذا لا يمكن أن يكون كلاهما سليماً وعملياً معاً؟ تخيّل أن TypeScript عاملت المصفوفات invariantly (سليم تماماً). اكتب ثلاثة أمثلة من كودٍ صحيح تماماً وقت التشغيل كان النظام السليم سيرفضها بلا داعٍ. ثم احكم: هل كانت المقايضة (قبول ثقبٍ نادر مقابل رفض كودٍ صحيح كثير) قراراً حكيماً؟ لا توجد إجابة "صحيحة" — المطلوب أن تزن المقايضة كما وزنها مصمّمو اللغة، فتفهم أن "السلامة" ليست دائماً الخير الأعلى. (هذا يربط مباشرةً ببذرة الإقليم ٩ عن حتميّة رفض بعض البرامج الصحيحة.)


الخلاصة — وصلٌ في الشجرة

أتقنّا العمود الفقري لأنظمة الأنواع:

  • الاستبدالية: S تصلح بدل T إن وَفَت بكل وعود T. منها يُشتقّ كل تباين.
  • المعاملات contravariant (الاستهلاك يقلب الإرجاع covariant (الإنتاج يحفظه). قاعدةٌ واحدة تحكم الدوال كلها.
  • المصفوفات covariant عمداً = ثقبٌ غير سليم، مقبولٌ مقابل ergonomics. ومعه bivariance المناهج. strict يُغلق ما يُجدي إغلاقه، ويترك الباقي بحساب.
  • TypeScript ليست سليمة عن قصد: تفضّل العملية على المثاليّة، وتوثّق ثقوبها.
  • أغلقنا دَين void: قبول () => void لدالةٍ تُرجع قيمة = covariance إرجاعٍ مع مستهلكٍ أعمى.

ربطنا للوراء: البنيوية (٥) أعطت Dog ⊆ Animal، والعدسة (١) أعطت اتجاه ، وهنا اكتشفنا متى يُقلَب. ربطنا للأمام: التباين سيعود فوراً مع generics، حيث تصير الأنواع نفسها معاملاتٍ تُمرَّر.

بذرة غموض

بذرة غموض: تعاملنا حتى الآن مع أنواعٍ ثابتة. لكن لاحظ التكرار: كتبنا AnimalHandler, DogHandler, GetAnimal, GetDog... كلها نفس البنية بنوعٍ مختلف. ألا يمكن كتابة قالبٍ واحد: "دالةٌ تأخذ X وتُرجع void" لأي X؟ ماذا لو صارت الأنواع نفسها قيماً تُمرَّر لقوالب؟ عندها يصير نظام الأنواع... لغة برمجة. للإقليم ٧.

التالي: 07-generics.md

منهج TypeScript — من الجذور · نظرية مجموعات + مُبرهِن نظريات النوع = مجموعة