توافقية الأنواع في TypeScript
مقدمة
توافقيّة الأنواع في TypeScript مبنيّةٌ على التحقق من الأنواع الفرعيّة هيكليًّا (structural subtyping). والتحقّق من الأنواع هيكليًّا طريقةٌ للربط بين الأنواع حسب عناصرها فقط لا غير. وهذا مُعاكسٌ للتحقق من الأنواع اسميًّا (nominal typing). انظر الشيفرة التالية:
interface Named {
name: string;
}
class Person {
name: string;
}
let p: Named;
// هذا مسموح به بسبب التحقق من الأنواع هيكليًّا
p = new Person();
في اللغات المعتمدة على التحقق من الأنواع اسميًّا مثل لغة C# أو Java، فالشيفرة المُقابلة ستكون خطأً لأنّ الصنف Person
لا يصِفُ نفسَه صراحةً على أنّه مُطبِّقٌ للواجهة Named
.
صُمِّم نظام التحقق من الأنواع هيكليًّا في TypeScript على أساس طريقة كتابة شيفرة JavaScript المعتادة. ولأنّ JavaScript تستعمل كثيرًا كائنات مجهولة (anonymous objects) مثل تعابير الدوال وقيم الكائنات الحرفيّة (object literals)، فمن الطبيعي تمثيلُ مثلِ هذه العلاقات الموجودة في مكتبات JavaScript بنظام تحقّقٍ من الأنواع هيكليًّا عوضًا عن نظام اسميّ.
ملاحظة حول الاستقامة (Soundness)
يسمح نظام الأنواع الخاص بلغة TypeScript لبعض العمليات التي لا يُمكن معرفتها أثناء الترجمة (compile-time) بأن تكون آمنة. عندما يمتلك نظام أنواع هذه الخاصية، فإنّنا نقول أنّه ليس مُستقيمًا (sound). انتُقيَت الحالات التي تسمح فيها TypeScript بمثل هذه العمليات غير المستقيمة بدقّة وعناية، وسنشرح في هذه الصفحة الأماكن التي تحدث فيها هذه العمليات وسبب ذلك.
بدايةً
القاعدة الأساسية لنظام الأنواع الهيكلي في TypeScript هي أنّ x
متوافق مع y
إذا كان y
يملك نفس العناصر التي يملكها x
. مثلًا:
interface Named {
name: string;
}
let x: Named;
// نوع
// y
// المُستنتَج
// هو
// { name: string; location: string; }
let y = { name: "Alice", location: "Seattle" };
x = y;
للتحقق ممّا إذا كان y
قابلًا لتعيينه للمتغير x
، فسيتحقق المترجِم من كل خاصية من خاصيات x
لإيجاد خاصية مكافئة متوافقة معها في y
. في هذه الحالة، لا بد للمتغيّر y
أن يملك عنصرًا باسم name
نوعُه سلسلة نصية. ولأنّه كذلك، فسيُسمَح بالتعيين.
تُستخدم نفس قاعدة التعيين عند التحقق من القيم المعطاة لمعاملات الدالة عند استدعائها:
function greet(n: Named) {
console.log("Hello, " + n.name);
}
greet(y); // OK
لاحظ أنّ للكائن y
خاصيةً زائدةً باسم location
، لكن هذا لا يُطلق أي خطأ. وذلك لأن عناصر النوع الهدف هي فقط من تُؤخَذ بعين الاعتبار عندما يُتحقَّقُ من التوافقية (عناصر النوع Named
في هذه الحالة).
مُلاحظة: النوع الهدف هو الذي يُعيَّن له (على يسار التّعيين)، والنوع المصدر هو النوع المُعيَّن (على يمين التّعيين).
تتقدّم عملية المُقارنة تعاوديًا (recursively) باستكشاف نوع كل عنصر وكل عنصر فرعي.
مقارنة دالتين
مقارنة الأنواع الأولية (primitive types) وأنواع الكائنات (object types) واضحٌ وبيّنٌ نسبيًّا، لكن التوافقيّة بين الدوال أعقد قليلًا، لنبدأ بمثال مبدئي لدالتين تختلفان فقط في قائمة معاملاتهما:
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // مسموح به
x = y; // خطأ
للتحقّق ممّا إذا كانت الدالة x
قابلةً لتعيينها للمتغيّر y
، ننظر أولًا إلى قائمة المعاملات. يجب على كل معامل في x
أن يملك معاملًا مكافئًا له في y
ذو نوع متوافق. لاحظ أنّ أسماء المعاملات لا تُؤخذ بعين الاعتبار، أنواعها فقط هي ما يهمّ. في هذه الحالة، لدى كل مُعامل من معاملات x
معاملٌ مكافئ متوافق في قائمة معاملات y
، لذا يُسمَح بالتعيين.
التعيين الثاني خطأ، لأن الدالة y
تملك معاملًا مطلوبًا ثانيًا لا يوجد في قائمة معاملات x
، لذا لا يُسمَح بالتعيين.
قد تتساءل لِمَ يُسمَح بالتخلص من المعاملات كما في المثال y = x
. السبب هو أنّ تجاهل معاملات دالة زائدة شائع جدا في JavaScript. مثلًا، توفّر الدالة Array#forEach
ثلاثة معاملات لدالة رد النداء (callback function) وهي: عنصر المصفوفة، وفهرسها، والمصفوفة التي تحتوي على العنصر. لكن رغم ذلك فمن المُفيد جدًّا توفير دالة رد نداء تستخدم فقط المعامل الأول:
let items = [1, 2, 3];
// لا يجب على هذه المعاملات الزائدة أن تكون مطلوبة
items.forEach((item, index, array) => console.log(item));
// أخذ المعامل الأول وتجاهل المعاملات غير المهمّة مسموح به
items.forEach(item => console.log(item));
لننظر الآن إلى كيفيّة التعامل مع الأنواع المُعادة (return types)، خذ المثال التالي الذي يحتوي على دالتين تختلفان فقط في النوع المُعاد الخاص بهما:
let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});
x = y; // مسموح به
// خطأ لأن الدالة
// x()
// تفتقد خاصيةً باسم
// location
y = x;
يقضي نظام الأنواع بوجوب كون النوع المعاد الخاص بالدالة المصدر نوعًا فرعيًا (subtype) من النوع المعاد الهدف.
التبايُن الثنائي لمعاملات الدوال (Function Parameter Bivariance)
عند مقارنة أنواع معاملات الدوال، فإنّ التعيين ينجح إمّا إذا كان المعامل المصدر قابلًا للتعيين للمعامل الهدف، أو عكس ذلك. هذه عملية غير مستقيمة لأن مُستدعيَ دوالٍ (a caller) قد يُعطى دالةً تأخذ نوعًا أكثر تخصّصًا ويستدعي الدالة بنوع أقل تخصّصًا. هذا النوع من الأخطاء نادر عمليًّا، والسماح بهذه العملية يساعد في استعمال العديد من أنماط JavaScript الشائعة. على سبيل المثال:
enum EventType { Mouse, Keyboard }
interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// غير مستقيم، لكنه مفيد وشائع
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));
// بدائل مُستقيمة لكنها غير مرغوبة
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + "," + (<MouseEvent>e).y));
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + "," + e.y)));
// غير مسموح به، تُطبَّق هنا قواعد أمان الأنواع لأن النوعين لا يتوافقان بالكامل
listenEvent(EventType.Mouse, (e: number) => console.log(e));
المعاملات الاختيارية (Optional Parameters) والمعاملات المتبقيّة (Rest Parameters)
عند مقارنة توافقيّة الدوال، فالمعاملات الاختيارية والمطلوبة تبادلية (أي لا فرق بينهما). والمعاملات الاختيارية الزائدة في النوع المصدر لا تكون خطأ، والمعاملات الاختيارية للنوع الهدف دون معاملات مكافئة لها في النوع المصدر لا تكون خطأ كذلك.
عندما يكون لدالة معاملات متبقية، فإنّها تُعامَل كما لو كانت سلسلة لا نهائيّةً من المعاملات الاختيارية.
هذا غير مستقيم من منظور نظام الأنواع، لكن من منظور زمن التنفيذ (runtime) ففكرة المعاملات الاختيارية لا تُطبَّق كثيرًا لأنّ ذلك يُعادل تمرير القيمة undefined
في معظم الحالات.
سبب تقرير هذه العملية غير المستقيمة هو النمط الشائع لدالةٍ تأخذ دالة رد نداءٍ وتستدعيها بعدد من المعاملات التي تكون مُتوقَّعةً عند المُبرمج لكنها تكون غير معروفة على مستوى نظام الأنواع:
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* هنا تُستدعى دالة رد النداء بالمعاملات */
/* 'args' */
}
// غير مستقيم لأن الدالة
// invokeLater
// قد تُوفّر أي عدد من المعاملات
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
// هذا مُبهم لأن المعاملين
// x و y
// مطلوبان في الحقيقة لكن اكتشافهما غير ممكن
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));
الدوال ذات الأحمال الزائدة (overloads)
عندما يكون لدالةٍ أحمالٌ زائدة، فيجب على كل حمل زائدٍ في النوع المصدر أن يُطابَق بتوقيعٍ متوافقٍ على النوع الهدف. هذا يضمن إمكانية أن تكون الدالة الهدف قابلةً للاستدعاء في نفس حالات الدالة المصدر.
الثوابت المتعددة (Enums)
الثوابت المتعددة متوافقة مع الأعداد، والأعداد متوافقة مع الثوابت المتعددة. لكن لا تكون قيمُ الثوابت المتعددة التي تكون من أنواع مختلفة من الثوابت المتعددةِ متوافقةً. مثلًا:
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let status = Status.Ready;
status = Color.Green; // خطأ
الأصناف
تعمل الأصناف بشكل مُشابه لأنواع قيم الكائنات الحرفيّة (object literal types) وبشكل مشابه للواجهات باستثناء واحد: للأصناف نوعٌ ساكن (static type) ونوعُ نسخةٍ (instance type). عند مقارنة كائنين من نوع صنف معيّن، فأعضاء النسخة وحدها هي التي تُقارَن. ولا تُؤثّر العناصر الساكنة والدوال البانيّة (constructors) على التوافقية.
class Animal {
feet: number;
constructor(name: string, numFeet: number) { }
}
class Size {
feet: number;
constructor(numFeet: number) { }
}
let a: Animal;
let s: Size;
a = s; // مسموح به
s = a; // مسموح به
العناصر الخاصّة (private members) والعناصر المحميّة (protected members) في الأصناف
تُؤثّر العناصر الخاصّة والمحميّة في صنفٍ على التوافقية. عندما يُتحقَّقُ من توافقية نسخة (instance) صنف ما، فإذا احتوى النوع الهدف على عنصر خاصّ، فيجب على النوع المصدر أن يحتوي على كذلك على عنصر خاصّ تأصَّل من نفس الصّنف. وهذا ينطبق كذلك على نسخة ذات عنصر محميّ كذلك. يسمح هذا بأن يكون تعيين صنفٍ ما متوافقًا مع الصنف الأب الذي اشتُقَّ منه، لكن لا يكون متوافقًا مع الأصناف الأخرى التي تكون من سلالة وراثة مُغايرةٍ لها نفس الشكل.
الأنواع المُعمَّمة (Generics)
لأنّ TypeScript ذات نظام أنواع هيكلي، فمعاملات الأنواع (type parameters) تُؤثّر فقط على النوع الناتج عندما يُعالَج كجزء من نوعِ عنصرٍ معيّن. مثلًا:
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // مسموح به لأنّ لهما نفسُ الهيكل
في المثال أعلاه، x
وy
متوافقان لأن هيكليهما لا يستخدمان النوع المعطى بطريقة مختلفة. تغيير هذا المثال عبر إضافة عنصر إلى Empty<T>
يُوضّح الفكرة:
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
// خطأ لأنّ
// x و y
// غير متوافقين
x = y;
بهذه الطريقة، يتصرّف نوع معمّم ذو معطيات أنواع مُحدّدة مثلما يتصرّف نوع غير مُعمّم.
بالنسبة للأنواع المعمّمة التي لا تُحدَّد لها أية معطيات أنواع، فيُتحقَّق من التوافقيّة عبر تحديد any
كمُعطًى لجميع معاملات الأنواع التي لم تُحدّد. يُتحقَّق بعد ذلك من توافقيّة الأنواع الناتجة، كما الحال مع الأنواع غير المُعمّمة.
مثلًا:
let identity = function<T>(x: T): T {
// ...
}
let reverse = function<U>(y: U): U {
// ...
}
// مسموح به لأنّ
// (x: any) => any
// يُطابق
// (y: any) => any
identity = reverse;
مواضيع متقدمة
النوع الفرعيّ مُقابلَ التعيين
استعملنا في هذه الصفحة مُصطلح "متوافق (compatible)"، وهو مصطلح غير مُعرَّف في مواصفة اللغة. هناك في لغة TypeScript شكلان من التوافقيّة: النوع الفرعيّ (subtype) والتعيين (assignment)، ويختلفان فقط في أنّ التعيين يُوسِّع توافقية النوع الفرعيّ بقواعد إضافيّة تسمح بالتعيين مِن وإلى النوع any
، وإلى ومِن enum
مع القيم العدديّة المكافئة.
تُستَعمَل أحد طريقتي التوافقية في أماكن مختلفة من اللغة حسب الحالة. وتعمل توافقية الأنواع وفقًا لما تُمليه توافقية التعيين لأسباب عمليّة، وذلك حتى في حالات الجملتين implements
وextends
. للاستزادة انظر مواصفة لغة TypeScript.