الدوال في TypeScript
مقدمة
تُعدّ الدوال أحد أساسات أي تطبيق مكتوب بلغة JavaScript. إذ تُستخدم لبناء طبقات تجريد (layers of abstraction)، ولبناء مكونات تعمل كالأصناف، ولإخفاء المعلومات (information hiding)، وتعمل كوحداتٍ (modules) كذلك. ورغم أن الأصناف ومجالات الأسماء والوحدات موجودة في TypeScript، إلّا أنّ الدوال لا تزال تلعب الدور الرئيسيّ في وصف كيفيّة القيام بالأمور. وتُضيف TypeScript كذلك بعض المزايا الجديدة لدوال JavaScript الاعتياديّة لتسهيل مهمّة العمل معها.
الدوال
يُمكن بدايةً إنشاء الدوال في لغة TypeScript كما في لغة JavaScript، ويُمكن إنشاء الدوال إمّا مُسمَّاةً (named function) أو مجهولة الاسم (anonymous function). يسمح هذا باختيار أكثر طريقة ملائمة لك في تطبيقك، سواء أَكُنتَ تبني عدّة دوال في واجهة برمجيّة (API) أو دالة ذات استعمال واحد لتمريرها إلى دالة أخرى (اسم الدالة لا يهم في هذه الحالة).
كتذكير بسيط، إليك كلا الطريقتين في لغة JavaScript:
// دالة مُسمّاة
function add(x, y) {
return x + y;
}
// دالة مجهولة الاسم
let myAdd = function(x, y) { return x + y; };
يُمكن -كما في JavaScript- للدوال أن تُشير إلى متغيرات موجودة خارج جسم الدالة. عند القيام بهذه العملية فإنّنا نقول أنّنا "نلتقط (capture)" هذه المتغيرات. ورغم أن فهم آلية عمل الالتقاط وسلبياته خارج عن أهداف هذا الدليل، إلا أن فهم كيفية عمله مهم لمبرمجي JavaScript وTypeScript.
let z = 100;
function addToZ(x, y) {
return x + y + z;
}
أنواع الدوال
إضافة الأنواع إلى الدوال
لنضف الأنواع إلى المثالين السابقين:
function add(x: number, y: number): number {
return x + y;
}
let myAdd = function(x: number, y: number): number { return x + y; };
يُمكننا إضافة الأنواع لكل معامل ثمّ إلى الدالة نفسها لإضافة النوع المُعاد (return type). يُمكن للغة TypeScript استنتاج النوع المُعاد عبر النظر إلى جُمل return
، لذا يُمكن الاستغناء عنه في الكثير من الأحيان.
كتابة نوع الدالة
بعد إضافة أنواع المعاملات ونوع القيمة المعادة إلى الدالة، لنكتب كامل نوع الدالة عبر إلقاء نظرة على كل جزء من أجزاء نوع الدالة:
let myAdd: (x: number, y: number) => number =
function(x: number, y: number): number { return x + y; };
لأنواع الدوال نفس الجزأين: نوع المعاملات ونوع القيمة المعادة. يكون كلا الجزأين مطلوبًا عند كتابة كامل نوع الدالة. نكتب أنواع المعاملات كما نفعل مع قوائم المعاملات، وذلك بإعطاء كل معامل اسمًا ونوعًا. هذا الاسم يُساعد في مقروئية (readability) الشيفرة فقط ولا يلزم أن تكون الأسماء متطابقة، إذ كان يُمكن أن نكتب الشيفرة أعلاه كما يلي:
let myAdd: (baseValue: number, increment: number) => number =
function(x: number, y: number): number { return x + y; };
ما دامت أنواع المعاملات مرتّبة بشكل صحيح، فهذا يعني بأنه نوع صالح للدالة بغض النظر عن أسماء المعاملات في نوع الدالة.
الجزء الثاني هو النوع المعاد. نُوضّح جزء النوع المعاد عبر استخدام السهم =>
للفصل بين المعاملات والنوع المعاد. وهذا الجزء -كما سبق ذكره- مطلوب في نوع الدالة، لذا إن لم يكن للدالة قيمة معادة، فاستعمل النوع void
عوضًا عن تركه.
لاحظ كذلك بأن المعاملات والنوع المعاد هي وحدها من يُكوِّن نوع الدالة. والمتغيرات الملتقطَة لا تكون في النوع. لذا فالمتغيّرات الملتقطَة تعدّ جزءًا من "الحالة المخفية (hidden state)" لأي دالة ولا تكون جزءًا من واجهتها البرمجيّة.
استنتاج الأنواع (type inference)
يُمكن لمترجم (compiler) لغة TypeScript استنتاج النوع إن حدّدت النوع على جانب دون آخر:
// يعلم المترجم نوع الدالة بالكامل ولو لم يُحدَّد على الجهة اليُسرى
let myAdd = function(x: number, y: number): number { return x + y; };
// هنا كذلك، يُعرَف نوع الدالة الكامل من الجانب الأيسر للتصريح
let myAdd: (baseValue: number, increment: number) => number =
function(x, y) { return x + y; };
يُسمّى هذا بإضافة الأنواع سياقيًّا (contextual typing)، وهو شكل من أشكال استنتاج الأنواع، ويُساعد على تقليل المجهود الذي تتطلبه إضافة الأنواع للبرامج.
المعاملات الافتراضية والاختيارية
يكون تمرير قيم لجميع معاملات الدالة مطلوبًا ليعمل استدعاؤها بشكل صحيح في TypeScript. لكن هذا لا يعني أنه لا يمكن تمرير القيمة null
أو undefined
، بل يعني أنّ المترجم سيتحقق من أنّ المستخدم قد مرّر قيمة لكل معامل من معاملات الدالة. ويعتبر المترجم كذلك أن هذه المعاملات هي المعاملات الوحيدة التي ستُمرّر إلى الدالة. أو بعبارة أخرى، يجب على عدد القيم الممرّرة للدالة أن يوافق عدد المعاملات التي تتوقعها الدالة.
function buildName(firstName: string, lastName: string) {
return firstName + " " + lastName;
}
let result1 = buildName("Bob"); // خطأ، عدد لا يكفي من المعاملات
let result2 = buildName("Bob", "Adams", "Sr."); // خطأ عدد المعاملات أكثر مما هو متوقع
let result3 = buildName("Bob", "Adams"); // عدد المعاملات جيد
في لغة JavaScript تكون جميع المعاملات اختياريّة، ويُمكن للمستخدمين ترك بعضها حسب ما يُناسب. وتكون قيمتُها القيمةَ undefined
عند تركها وعدم تمريرها. يُمكننا الحصول على هذه الميزة عبر إضافة المحرف ?
لآخر المعاملات التي نُريد لها أن تكون اختيارية. لنقل مثلًا بأنّنا نريد أن يكون المعامل lastName
في المثال أعلاه اختياريًّا:
function buildName(firstName: string, lastName?: string) {
if (lastName)
return firstName + " " + lastName;
else
return firstName;
}
let result1 = buildName("Bob"); // يعمل بشكل صحيح
let result2 = buildName("Bob", "Adams", "Sr."); // خطأ، عدد المعاملات أكثر مما هو متوقع
let result3 = buildName("Bob", "Adams"); // عدد المعاملات جيد
يجب على المعاملات الاختيارية أن تلحقَ دائمًا بالمعاملات المطلوبة وتكونَ بعدها. فلو أردنا جعل المعامل firstName
اختياريًّا عوضًا عن المعامل lastName
لاحتجنا إلى تغيير ترتيب المعاملات في الدالة بوضع المعامل firstName
في آخر قائمة المعاملات.
يُمكننا في لغة TypeScript أيضًا ضبط قيمة سيحملها المعامل إن لم يُمرّر المُستخدم قيمة مُغايرة، أو إن مرّر المُستخدم القيمة undefined
لها. وتُسمى بالمعاملات المُهيأة افتراضيًّا (default-initialized parameters). لنأخذ المثال السابق ولنُعطِ للمعامل lastName
القيمة الافتراضية "Smith"
.
function buildName(firstName: string, lastName = "Smith") {
return firstName + " " + lastName;
}
let result1 = buildName("Bob"); // "Bob Smith"
let result2 = buildName("Bob", undefined); // "Bob Smith"
let result3 = buildName("Bob", "Adams", "Sr."); // خطأ عدد المعاملات أكثر مما هو متوقع
let result4 = buildName("Bob", "Adams"); // عدد المعاملات جيد
تعدّ المعاملات المُهيأة افتراضيًّا التي تكون بعد جميع المعاملات المطلوبةِ معاملاتٍ اختياريّة، و يُمكن ترك تمرير قيمة لها عند استدعاء الدالة كما المعاملات الاختيارية. هذا يعني بأن المعاملات الاختيارية والمعاملات الافتراضية التي تكون في مؤخرة القائمة تتشارك في أنواعها. لذا فكل من الدالة
function buildName(firstName: string, lastName?: string) {
// ...
}
والدالة
function buildName(firstName: string, lastName = "Smith") {
// ...
}
يتشاركان في نفس النوع (firstName: string, lastName?: string) => string
. بحيث تختفي القيمة الافتراضية للمعامل lastName
في النوع، مُبقيةً على حقيقة أنّ المعاملَ معاملٌ اختياريّ.
وعلى النقيض من المعاملات الاختيارية العادية، فالمعاملات المهيأة افتراضيا لا تحتاج إلى أن تكون بعد المعاملات المطلوبة. إن كان معامل مهيأ افتراضيا قبل معامل مطلوب، فسيحتاج المستخدمون إلى تمرير القيمة undefined
بصراحة للحصول على القيمة الافتراضية. يُمكن مثلًا كتابة المثال السابق بقيمة افتراضيّة للمعامل firstName
فقط:
function buildName(firstName = "Will", lastName: string) {
return firstName + " " + lastName;
}
let result1 = buildName("Bob"); // خطأ، عدد لا يكفي من المعاملات
let result2 = buildName("Bob", "Adams", "Sr."); // خطأ عدد المعاملات أكثر مما هو متوقع
let result3 = buildName("Bob", "Adams"); // "Bob Adams"
let result4 = buildName(undefined, "Adams"); // "Will Adams"
المعاملات المتبقية (Rest Parameters)
المعاملات المطلوبة، والاختيارية، والافتراضية كلها تشترك في ميزة واحدة: وهي أنها تكون معاملًا واحدًا فقط في كل مرّة. قد ترغب أحيانًا بالعمل مع عدة معاملات كمجموعة واحدة، أو قد لا تدري عدد المعاملات التي ستأخذها الدالة. يُمكنك العمل مع المعاملات الممرّرة مباشرةً عبر المتغيّر arguments
الذي يكون ظاهرًا داخل جسم كل دالة.
يُمكنك في TypeScript جمع هذه المعاملات في متغير واحد:
function buildName(firstName: string, ...restOfName: string[]) {
return firstName + " " + restOfName.join(" ");
}
let employeeName = buildName("Joseph", "Samuel", "Lucas", "MacKinzie");
تُعامَل المعاملات المتبقية كعدد لا محدود من المعاملات الاختيارية. عند تمرير القيم إلى معامل متبقٍ، يُمكنك تمرير أي عدد من القيم ويُمكن عدم تمرير أية قيمة كذلك. سيبني المُترجم (compiler) مصفوفة تحتوي على القيم المُمرّرة، ستُسمّى هذه المصفوفة بالاسم المُعطى بعد النقاط الثلاث (...
)، ما يسمح لك بالوصول إلى هذه القيم داخل الدالة.
تُستَخدم النقاط الثلاث مع المعاملات المتبقية في نوع الدالة كذلك:
function buildName(firstName: string, ...restOfName: string[]) {
return firstName + " " + restOfName.join(" ");
}
let buildNameFun: (fname: string, ...rest: string[]) => string = buildName;
الكلمة المفتاحية this
تعلّم كيفية استخدام this
في JavaScript مرحلة يجب أن يمر منها كل مطور. ويجب على مطوري TypeScript تعلم كيفية استخدام this
وكيفية ملاحظة الاستخدامات غير المناسبة لها. تُنبّه TypeScript إلى الاستخدامات غير المناسبة لها اعتمادًا على بعض التقنيات. إن احتجت إلى تعلّم كيفية استخدام الكلمة المفتاحية this
في JavaScript، فاقرأ هذا المقال الذي يغطي آلية عمل this
جيدًا -انظر كذلك صفحة this
على الموسوعة- لذا سنُغطي هنا الأساسيات فقط.
الكلمة المفتاحية this
والدوال السهمية (arrow functions)
تُمثل الكلمة المفتاحية this
في JavaScript متغيّرًا يُضبط عند استدعاء الدالة. ما يجعله ميزة قوية ومرنة، لكن عليك دائمًا معرفة السياق (context) الذي تُنفَّذ فيه الدالة. وهو أمر محيّر في كثير من الأحيان، خاصة عند إعادة دالة أو تمرير دالة كمُعامل.
لنلق نظرة على مثال بسيط:
let deck = {
suits: ["hearts", "spades", "clubs", "diamonds"],
cards: Array(52),
createCardPicker: function() {
return function() {
let pickedCard = Math.floor(Math.random() * 52);
let pickedSuit = Math.floor(pickedCard / 13);
return {suit: this.suits[pickedSuit], card: pickedCard % 13};
}
}
}
let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();
alert("card: " + pickedCard.card + " of " + pickedCard.suit);
لاحظ أنّ createCardPicker
دالةٌ تُعيد دالةً أخرى. إن حاولنا تنفيذ المثال، فسنحصل على خطأ عوضًا عن صندوق التنبيه المتوقَّع. هذا لأن this
المُستخدَم داخل الدالة المُنشأة من طرف createCardPicker
سيُضبَط لتكون قيمتُه القيمةَ window
عوضًا عن الكائن deck
. وذلك لأننا نستدعي الدالة cardPicker()
بشكل مستقل. إذ أن استدعاءً عالي المستوى لغَيرِ تابعٍ (A top-level non-method syntax call) كهذا سيستخدم window
للمتغيّر this
. (لاحظ أن قيمة this
ستكون undefined
عوضًا عن window
في الوضع الصارم strict
).
يُمكننا حل هذه المشكلة عبر التأكد من أن الدالة مرتبطة بالمتغير this
بشكل صحيح قبل إعادة الدالة لاستخدامها لاحقًا. بهذه الطريقة سيبقى المتغير في الدالة قادرًا على الوصول إلى الكائن deck
الأصلي بغض النظر عن كيفية استخدام الدالة لاحقًا. للقيام بذلك سنُغيّر تعبير الدالة ليستخدم ميزة الدوال السهمية التي جاءت بها النسخة ECMAScript 6. إذ تلتقط الدوال السهمية المتغيّرَ this
في مكان إنشاء الدالة عوضًا عن مكان استدعائها:
let deck = {
suits: ["hearts", "spades", "clubs", "diamonds"],
cards: Array(52),
createCardPicker: function() {
// لاحظ أن السطر أدناه أصبح دالة سهمية، ما يسمح لنا بالتقاط المتغير هنا
return () => {
let pickedCard = Math.floor(Math.random() * 52);
let pickedSuit = Math.floor(pickedCard / 13);
return {suit: this.suits[pickedSuit], card: pickedCard % 13};
}
}
}
let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();
alert("card: " + pickedCard.card + " of " + pickedCard.suit);
ستُنبّهك كذلك TypeScript عند ارتكابك لهذا الخطأ إن مرّرت الراية --noImplicitThis
إلى المترجم. إذ سيُنبّهك إلى أنّ this
الموجود في this.suits[pickedSuit]
من النوع any
.
معاملات this
لا يزال نوعُ this.suits[pickedSuit]
النوعَ any
للأسف. هذا لأن المتغير this
يأتي من تعبير الدالة الموجود داخل قيمة الكائن الحرفيّة (object literal). يُمكن تمرير معامل this
صريحٍ لإصلاح هذا الأمر. معاملات this
هي معاملات مزيّفة تأتي في أول قائمة معاملات الدالة:
function f(this: void) {
// تأكد من أن
// `this`
// غير قابل للاستعمال داخل هذه الدالة
}
لنُضِف بضعة واجهات لمثالنا السابق، سنُضيف النوعين Card
وDeck
لجعل الأنواع أوضح وأكثر قابلية لإعادة الاستعمال:
interface Card {
suit: string;
card: number;
}
interface Deck {
suits: string[];
cards: number[];
createCardPicker(this: Deck): () => Card;
}
let deck: Deck = {
suits: ["hearts", "spades", "clubs", "diamonds"],
cards: Array(52),
// لاحظ أن الدالة الآن تُحدد بصراحة بأن المُستدعى الخاص بها يجب أن يكون من النوع
// Deck
createCardPicker: function(this: Deck) {
return () => {
let pickedCard = Math.floor(Math.random() * 52);
let pickedSuit = Math.floor(pickedCard / 13);
return {suit: this.suits[pickedSuit], card: pickedCard % 13};
}
}
}
let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();
alert("card: " + pickedCard.card + " of " + pickedCard.suit);
تتوقّع TypeScript الآن من createCardPicker
أن تُستدعى على كائن من النوع Deck
. هذا يعني بأن المتغير this
قد أصبح الآن من النوع Deck
، ولم يعد نوعُه النوعَ any
، لذا فالراية --noImplicitThis
لن تنتج أية أخطاء.
معاملات this
في دوال رد النداء (callbacks)
يُمكن أن تحدث أخطاء عند العمل مع this
في دوال رد النداء كذلك، عند تمرير الدوال إلى مكتبة (library) تستدعيها لاحقًا. وسبب ذلك هو أنّ المكتبة التي تستدعي دالة رد النداء الخاصة بك ستستدعيها كدالة عادية، فسيحمل المتغيّر this
القيمة undefined
. يُمكنك استعمال معاملات this
لتجنب أخطاء دوال رد النداء كذلك. أولًا، سيحتاج كاتب المكتبة إلى إضافة حاشية (annotate) إلى نوع دالة رد النداء بالمتغير this
كما يلي:
interface UIElement {
addClickListener(onclick: (this: void, e: Event) => void): void;
}
هنا، التعبير this: void
يعني بأن الدالة addClickListener
تتوقع من onclick
أن تكون دالة لا تتطلب نوعَ this
. ثانيًا، أضف حاشية إلى الشيفرة التي تستدعي الدالة بالمتغير this
كما يلي:
class Handler {
info: string;
onClickBad(this: Handler, e: Event) {
// خطأ، نستعمل
// this
// هنا، لذا فاستدعاء دالة رد النداء هذه سيفشل في زمن التنفيذ
this.info = e.message;
}
}
let h = new Handler();
uiElement.addClickListener(h.onClickBad); // خطأ
بعد إضافة حاشية نوع للمتغير this
، فهذا يقضي صراحةً بأن الدالة onClickBad
يجب لها أن تُستدعى من على نسخة من الصنف Handler
. ستكتشف TypeScript بعد ذلك بأن addClickListener
تتطلب دالة تملك الحاشية this: void
. لإصلاح هذا الخطأ، غيِّر نوع this
كما يلي:
class Handler {
info: string;
onClickGood(this: void, e: Event) {
// لا يُمكن هنا استعمال المتغيّر
// this
// لأنه من النوع
// void
console.log('clicked!');
}
}
let h = new Handler();
uiElement.addClickListener(h.onClickGood);
لأن الدالة onClickGood
تحدّد نوع this
الخاص بها على أنّ نوعَه هو void
، فمن الممكن تمريرها إلى الدالة addClickListener
. لكن هذا يعني طبعًا بأنك لن تستطيع الوصول إلى this.info
. إن أردت كلا الميزتين فسيتوجّب عليك استعمال دالة سهميّة:
class Handler {
info: string;
onClickGood = (e: Event) => { this.info = e.message }
}
هذا يعمل بسبب أن الدوال السهمية لا تلتقط المتغير this
، لذا يُمكنك دائمًا تمريرها إلى ما يتوقّع مُتغيّر this
من النوع void
(أي this: void
). عَيبُ هذه الطريقة أنّ الدالة السهمية تُنشَؤُ لكل كائن من النوع Handler
. أما التوابع فتُنشؤ مرة واحدة فقط وتُربَط بسلسلة prototype الخاصة بالصنف Handler
. وتُشارَكُ بين جميع الكائنات من النوع Handler
.
الأحمال الزائدة (Overloads)
لغة JavaScript ديناميكية إلى حد كبير، ومن الشائع أن تُعيد دالة JavaScript واحدة أنواعًا مُختلفةً من الكائنات حسب شكل المعاملات المُمرّرة لها.
let suits = ["hearts", "spades", "clubs", "diamonds"];
function pickCard(x): any {
// تحقق مما إذا كنا نعمل مع كائن أو مصفوفة
// إن كان الأمر كذلك، فسنختار البطاقة من مجموعة البطاقات
if (typeof x == "object") {
let pickedCard = Math.floor(Math.random() * x.length);
return pickedCard;
}
// إن لم يكن الأمر كذلك، فسندع المستخدم يختار البطاقة
else if (typeof x == "number") {
let pickedSuit = Math.floor(x / 13);
return { suit: suits[pickedSuit], card: x % 13 };
}
}
let myDeck = [{ suit: "diamonds", card: 2 }, { suit: "spades", card: 10 }, { suit: "hearts", card: 4 }];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: " + pickedCard1.card + " of " + pickedCard1.suit);
let pickedCard2 = pickCard(15);
alert("card: " + pickedCard2.card + " of " + pickedCard2.suit);
ستُعيد الدالة pickCard
هنا نوعين من البيانات حسب ما مرّره المستخدم. إن مرَّر كائنًا يُمثل مجموعة البطاقات (deck)، فالدالة ستختار البطاقة. وإن اختار بطاقةً فسنُخبره أيُّها اختار. لكن كيف يُمكن وصف هذا لمُدقّق الأنواع؟
الجواب هو إضافة عدة أنواعِ دوالٍ لنفس الدالة كقائمةٍ من الأحمال الزائدة. هذه القائمة هي ما سيستخدمه المترجم (compiler) للحكم على استدعاءات الدالة. لنُنشئ قائمة أحمالٍ زائدةٍ تصِف ما تقبله الدالة pickCard
وماذا ستُعيد:
let suits = ["hearts", "spades", "clubs", "diamonds"];
function pickCard(x: {suit: string; card: number; }[]): number;
function pickCard(x: number): {suit: string; card: number; };
function pickCard(x): any {
// تحقق مما إذا كنا نعمل مع كائن أو مصفوفة
// إن كان الأمر كذلك، فسنختار البطاقة من مجموعة البطاقات
if (typeof x == "object") {
let pickedCard = Math.floor(Math.random() * x.length);
return pickedCard;
}
// إن لم يكن الأمر كذلك، فسندع المستخدم يختار البطاقة
else if (typeof x == "number") {
let pickedSuit = Math.floor(x / 13);
return { suit: suits[pickedSuit], card: x % 13 };
}
}
let myDeck = [{ suit: "diamonds", card: 2 }, { suit: "spades", card: 10 }, { suit: "hearts", card: 4 }];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: " + pickedCard1.card + " of " + pickedCard1.suit);
let pickedCard2 = pickCard(15);
alert("card: " + pickedCard2.card + " of " + pickedCard2.suit);
بهذا التغيير أصبحت الأحمال الزائدة الآن تُضيف التحقق من الأنواع إلى استدعاءات الدالة pickCard
.
لكي يختار المُترجم التحقق من الأنواع بشكل صحيح، فإنّه يُؤدّي عمليّةً مشابهة لما تقوم به JavaScript الضِّمنية. إذ تنظر إلى قائمة الأحمال الزائدة، ثم تحاول استدعاء الدالة بالمعاملات المعطاة مع الحمل الزائد الأول، إن كان هناك توافق، فستختار هذا الحمل الزائد على أنه الصحيح. لهذا فإنّه من المعتاد ترتيب الأحمال الزائدة من أكثرها تحديدًا وتفصيلًا إلى أقلها دقّةً.
لاحظ بأن القطعة function pickCard(x): any
ليست جزءًا من قائمة الأحمال الزائدة، لذا فللدالة حملان زائدان اثنان فقط: الأول يأخذ كائنًا والآخر يأخذ عددًا. استدعاء pickCard
بأيّ نوع من أنواع المعاملات الأخرى سيُنتج خطأ.