الفصل 06
الحياة السرية للكائنات
يتحقق نوع البيانات المجرّد بكتابة نوع خاص من البرامج […] يعرّف النوع من حيث العمليات التي يمكن إجراؤها عليه.
— باربرا ليسكوف، البرمجة بالأنواع البياناتية المجرّدة

قدّم الفصل 4 كائنات JavaScript بوصفها حاويات تحمل بيانات أخرى. وفي ثقافة البرمجة، تُعدّ البرمجة كائنية التوجه مجموعة من التقنيات تستخدم الكائنات مبدأً مركزياً في تنظيم البرامج. ومع أن لا أحد يتفق حقاً على تعريفها الدقيق، فقد شكّلت البرمجة كائنية التوجه تصميم كثير من لغات البرمجة، بما فيها JavaScript. يصف هذا الفصل الطريقة التي يمكن بها تطبيق هذه الأفكار في JavaScript.
الأنواع البياناتية المجرّدة
الفكرة الرئيسية في البرمجة كائنية التوجه هي استخدام الكائنات، أو بالأحرى أنواعاً من الكائنات، بوصفها وحدة تنظيم البرامج. فإعداد برنامج في صورة عدد من أنواع الكائنات المنفصلة انفصالاً صارماً يوفر طريقة للتفكير في بنيته، وبالتالي لفرض نوع من الانضباط يمنع كل شيء من أن يتشابك.
والطريقة لفعل ذلك هي أن تفكر في الكائنات على نحو يشبه تفكيرك في خلاط كهربائي أو غيره من الأجهزة المنزلية. فالأشخاص الذين يصممون الخلاط ويجمّعونه عليهم القيام بعمل متخصص يتطلب علم المواد وفهماً للكهرباء. وهم يغطون كل ذلك بقشرة بلاستيكية ملساء حتى لا يضطر من يريد فقط خفق عجينة الفطائر إلى القلق بشأن كل ذلك — إذ يكفيه أن يفهم المقابض القليلة التي يُشغَّل بها الخلاط.
وبالمثل، فإن نوع البيانات المجرّد، أو صنف الكائنات، برنامج فرعي قد يحتوي شيفرة معقدة اعتباطاً لكنه يكشف مجموعة محدودة من الطرق والخصائص التي يُفترض بمن يعمل معه أن يستخدمها. وهذا يسمح ببناء برامج كبيرة من عدد من أنواع الأجهزة، ويحدّ من درجة تشابك هذه الأجزاء المختلفة بأن يطلب منها ألا تتفاعل مع بعضها إلا بطرق محددة.
إذا ظهرت مشكلة في أحد أصناف الكائنات هذه، فغالباً ما يمكن إصلاحه أو حتى إعادة كتابته بالكامل دون التأثير في بقية البرنامج. والأفضل من ذلك، قد يكون من الممكن استخدام أصناف الكائنات في برامج مختلفة متعددة، مما يغني عن إعادة إنشاء وظائفها من الصفر. يمكنك أن تنظر إلى بنى البيانات المدمجة في JavaScript، مثل المصفوفات والنصوص، بوصفها أنواعاً بياناتية مجرّدة قابلة لإعادة الاستخدام كهذه.
لكل نوع بيانات مجرّد واجهة، هي مجموعة العمليات التي يمكن للشيفرة الخارجية تنفيذها عليه. وأي تفاصيل تتجاوز تلك الواجهة تكون مغلَّفة (encapsulated)، وتُعامل باعتبارها داخلية للنوع ولا تهم بقية البرنامج.
حتى الأشياء الأساسية مثل الأعداد يمكن التفكير فيها كنوع بيانات مجرّد تسمح واجهته بجمعها وضربها ومقارنتها وما إلى ذلك. في الواقع، إن التركيز على الكائنات المفردة بوصفها الوحدة الرئيسية للتنظيم في البرمجة كائنية التوجه الكلاسيكية مؤسف إلى حد ما، لأن قطعاً مفيدة من الوظائف كثيراً ما تتضمن مجموعة من أصناف الكائنات المختلفة تعمل معاً عن كثب.
الطرق
في JavaScript، ليست الطرق سوى خصائص تحمل قيم دوال. هذه طريقة بسيطة:
function speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak};
let hungryRabbit = {type: "hungry", speak};
whiteRabbit.speak("Oh my fur and whiskers");
// → The white rabbit says 'Oh my fur and whiskers'
hungryRabbit.speak("Got any carrots?");
// → The hungry rabbit says 'Got any carrots?'
عادةً ما تحتاج الطريقة إلى فعل شيء بالكائن الذي استُدعيت عليه. وعندما تُستدعى دالة كطريقة — بأن يُبحث عنها كخاصية ثم تُستدعى فوراً، كما في object.method() — فإن الارتباط المسمى this في جسمها يشير تلقائياً إلى الكائن الذي استُدعيت عليه.
يمكنك أن تفكر في this كوسيط إضافي يُمرَّر إلى الدالة بطريقة مختلفة عن الوسائط العادية. وإذا أردت تقديمه صراحةً، فيمكنك استخدام طريقة call الخاصة بالدالة، وهي تأخذ قيمة this كمعطى أول وتتعامل مع المعطيات التالية كوسائط عادية.
speak.call(whiteRabbit, "Hurry");
// → The white rabbit says 'Hurry'
وبما أن لكل دالة ارتباط this خاصاً بها تعتمد قيمته على طريقة استدعائها، فلا يمكنك الإشارة إلى this الخاص بالنطاق الحاوي في دالة عادية معرَّفة بالكلمة function.
أما الدوال السهمية فمختلفة — فهي لا ترتبط بـthis خاص بها، لكنها تستطيع رؤية ارتباط this في النطاق المحيط بها. ولذلك يمكنك فعل شيء كالشيفرة التالية، التي تشير إلى this من داخل دالة محلية:
let finder = {
find(array) {
return array.some(v => v == this.value);
},
value: 5
};
console.log(finder.find([4, 5]));
// → true
خاصية مثل find(array) في تعبير كائن هي طريقة مختصرة لتعريف طريقة. فهي تُنشئ خاصية تسمى find وتعطيها دالة كقيمة.
لو كنت قد كتبت معطى some باستخدام الكلمة function، لما عملت هذه الشيفرة.
النماذج الأولية
إحدى طرق إنشاء نوع كائن أرنب له طريقة speak أن تُنشئ دالة مساعدة يكون نوع الأرنب وسيطها وتُرجع كائناً يحمل ذلك في خاصيته type ودالة speak لدينا في خاصيته speak.
تتشارك جميع الأرانب الطريقة نفسها. ولاسيما في الأنواع ذات الطرق الكثيرة، سيكون جميلاً لو وُجدت طريقة لحفظ طرق النوع في مكان واحد بدلاً من إضافتها إلى كل كائن على حدة.
في JavaScript، النماذج الأولية (prototypes) هي الطريقة لفعل ذلك. يمكن ربط الكائنات بكائنات أخرى لتكتسب سحرياً كل الخصائص التي يملكها الكائن الآخر. والكائنات العادية القديمة المنشأة بصيغة {} مرتبطة بكائن يسمى Object.prototype.
let empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]
يبدو الأمر وكأننا انتزعنا خاصية من كائن فارغ للتو. لكن في الحقيقة، toString طريقة مخزّنة في Object.prototype، أي أنها متاحة في معظم الكائنات.
عندما يتلقى كائن طلباً لخاصية لا يملكها، يُبحث عن الخاصية في نموذجه الأولي. فإن لم يملكها هو الآخر، يُبحث في النموذج الأولي للنموذج الأولي، وهكذا حتى الوصول إلى كائن بلا نموذج أولي (Object.prototype كائن كهذا).
console.log(Object.getPrototypeOf({}) == Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null
كما تتوقع، تُرجع Object.getPrototypeOf النموذج الأولي لكائن.
كثير من الكائنات ليس Object.prototype نموذجها الأولي مباشرةً، بل لها كائن آخر يوفر مجموعة مختلفة من الخصائص الافتراضية. فالدوال تنحدر من Function.prototype والمصفوفات تنحدر من Array.prototype.
console.log(Object.getPrototypeOf(Math.max) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) == Array.prototype);
// → true
وسيكون لكائن النموذج الأولي هذا نموذج أولي خاص به، غالباً Object.prototype، بحيث يوفر بدوره طرقاً مثل toString بصورة غير مباشرة.
يمكنك استخدام Object.create لإنشاء كائن بنموذج أولي محدد.
let protoRabbit = {
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
};
let blackRabbit = Object.create(protoRabbit);
blackRabbit.type = "black";
blackRabbit.speak("I am fear and darkness");
// → The black rabbit says 'I am fear and darkness'
يعمل أرنب «proto» كحاوية للخصائص المشتركة بين جميع الأرانب. أما كائن الأرنب الفردي، مثل الأرنب الأسود، فيحتوي خصائص لا تنطبق إلا عليه — في هذه الحالة نوعه — ويكتسب الخصائص المشتركة من نموذجه الأولي.
الأصناف
يمكن تفسير نظام النماذج الأولية في JavaScript كنظرة أكثر تحرراً إلى حد ما إلى الأنواع البياناتية المجرّدة أو الأصناف. فـالصنف يعرّف شكل نوع من الكائنات — ما الطرق والخصائص التي يملكها. ويسمى كائن كهذا نسخة من الصنف.
النماذج الأولية مفيدة لتعريف الخصائص التي تتشارك فيها جميع نسخ صنف القيمة نفسها. أما الخصائص التي تختلف من نسخة إلى أخرى، مثل خاصية type لدى أرانبنا، فتحتاج إلى تخزين مباشر في الكائنات نفسها.
لإنشاء نسخة من صنف معين، عليك صنع كائن ينحدر من النموذج الأولي الصحيح، لكن عليك أيضاً التأكد من أنه هو نفسه يملك الخصائص التي يُفترض أن تملكها نسخ هذا الصنف. وهذا ما تفعله دالة البناء (constructor).
function makeRabbit(type) {
let rabbit = Object.create(protoRabbit);
rabbit.type = type;
return rabbit;
}
تجعل صيغة الأصناف في JavaScript تعريف هذا النوع من الدوال أسهل، مع كائن نموذج أولي.
class Rabbit {
constructor(type) {
this.type = type;
}
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
}
تبدأ الكلمة class إعلان صنف، يتيح لنا تعريف دالة بناء ومجموعة من الطرق معاً. ويمكن كتابة أي عدد من الطرق داخل قوسي الإعلان المعقوفين. وتأثير هذه الشيفرة تعريف ارتباط يسمى Rabbit، يحمل دالة تشغّل الشيفرة في constructor وتملك خاصية prototype تحمل طريقة speak.
لا يمكن استدعاء هذه الدالة كدالة عادية. فدوال البناء في JavaScript تُستدعى بوضع الكلمة new أمامها. وفعل ذلك يُنشئ كائن نسخة جديداً نموذجه الأولي هو الكائن الموجود في خاصية prototype الخاصة بالدالة، ثم يشغّل الدالة وthis مرتبط بالكائن الجديد، وأخيراً يُرجع الكائن.
let killerRabbit = new Rabbit("killer");
في الواقع، لم تُدخَل class إلا في طبعة 2015 من JavaScript. فأي دالة يمكن استخدامها كدالة بناء، وقبل 2015 كانت طريقة تعريف صنف هي كتابة دالة عادية ثم التلاعب بخاصيتها prototype.
function ArchaicRabbit(type) {
this.type = type;
}
ArchaicRabbit.prototype.speak = function(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
};
let oldSchoolRabbit = new ArchaicRabbit("old school");
ولهذا السبب، تبدأ جميع الدوال غير السهمية بخاصية prototype تحمل كائناً فارغاً.
وبحسب الاصطلاح، تُكتب أسماء دوال البناء بحرف أول كبير حتى يسهل تمييزها عن الدوال الأخرى.
من المهم أن تفهم التمييز بين الطريقة التي يرتبط بها النموذج الأولي بدالة البناء (عبر خاصيتها prototype) والطريقة التي تملك بها الكائنات نموذجاً أولياً (يمكن إيجاده بـObject.getPrototypeOf). النموذج الأولي الفعلي لدالة البناء هو Function.prototype لأن دوال البناء دوال. أما خاصية prototype في دالة البناء فتحمل النموذج الأولي المستخدم للنسخ المنشأة عبرها.
console.log(Object.getPrototypeOf(Rabbit) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf(killerRabbit) ==
Rabbit.prototype);
// → true
تضيف دوال البناء عادةً بعض الخصائص الخاصة بكل نسخة إلى this. ومن الممكن أيضاً إعلان خصائص مباشرة في إعلان الصنف. وخلافاً للطرق، تُضاف هذه الخصائص إلى كائنات النسخ لا إلى النموذج الأولي.
class Particle {
speed = 0;
constructor(position) {
this.position = position;
}
}
ومثل function، يمكن استخدام class في الجمل والتعبيرات معاً. وعند استخدامها كتعبير، لا تعرّف ارتباطاً بل تنتج دالة البناء كقيمة فحسب. ويُسمح لك بحذف اسم الصنف في تعبير الصنف.
let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello
الخصائص الخاصة
من الشائع أن تعرّف الأصناف بعض الخصائص والطرق للاستخدام الداخلي لا تكون جزءاً من واجهتها. وتسمى هذه خصائص خاصة (private)، في مقابل الخصائص العامة (public) التي تكون جزءاً من الواجهة الخارجية للكائن.
لإعلان طريقة خاصة، ضع علامة # أمام اسمها. ولا يمكن استدعاء هذه الطرق إلا من داخل إعلان الصنف class الذي يعرّفها.
class SecretiveObject {
#getSecret() {
return "I ate all the plums";
}
interrogate() {
let shallISayIt = this.#getSecret();
return "never";
}
}
عندما لا يعلن صنف دالة بناء، يحصل تلقائياً على دالة بناء فارغة.
وإذا حاولت استدعاء #getSecret من خارج الصنف، فستحصل على خطأ. فوجودها مخفي تماماً داخل إعلان الصنف.
لاستخدام خصائص النسخة الخاصة، عليك إعلانها. فالخصائص العادية يمكن إنشاؤها بمجرد الإسناد إليها، أما الخصائص الخاصة فيجب إعلانها في إعلان الصنف لتكون متاحة أصلاً.
ينفّذ هذا الصنف جهازاً للحصول على عدد صحيح عشوائي أقل من عدد أقصى معطى. وله خاصية عامة واحدة فقط: getNumber.
class RandomSource {
#max;
constructor(max) {
this.#max = max;
}
getNumber() {
return Math.floor(Math.random() * this.#max);
}
}
تجاوز الخصائص المنحدرة
عندما تضيف خاصية إلى كائن، سواء أكانت موجودة في النموذج الأولي أم لا، تُضاف الخاصية إلى الكائن نفسه. وإن كانت هناك بالفعل خاصية بالاسم نفسه في النموذج الأولي، فلن تؤثر هذه الخاصية في الكائن بعد الآن، لأنها صارت مخفية خلف خاصية الكائن الخاصة به.
Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log((new Rabbit("basic")).teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small
يرسم المخطط التالي الحالة بعد تشغيل هذه الشيفرة. يقع نموذجا Rabbit وObject الأوليان خلف killerRabbit كنوع من الخلفية، حيث يمكن البحث عن الخصائص غير الموجودة في الكائن نفسه.
يمكن أن يكون تجاوز الخصائص الموجودة في نموذج أولي أمراً مفيداً. وكما يوضح مثال أسنان الأرنب، يمكن استخدام التجاوز للتعبير عن خصائص استثنائية في نسخ من صنف كائنات أعم، مع ترك الكائنات غير الاستثنائية تأخذ قيمة قياسية من نموذجها الأولي.
ويُستخدم التجاوز أيضاً لإعطاء النموذجين الأوليين القياسيين للدوال والمصفوفات طريقة toString مختلفة عن النموذج الأولي الأساسي للكائنات.
console.log(Array.prototype.toString ==
Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2
استدعاء toString على مصفوفة يعطي نتيجة مشابهة لاستدعاء .join(",") عليها — فهو يضع فواصل بين القيم في المصفوفة. أما استدعاء Object.prototype.toString مباشرةً على مصفوفة فينتج نصاً مختلفاً. فتلك الدالة لا تعرف شيئاً عن المصفوفات، لذا تضع ببساطة كلمة object واسم النوع بين قوسين مربعين.
console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]
الخرائط
رأينا كلمة map مستخدمة في الفصل السابق للدلالة على عملية تحوّل بنية بيانات بتطبيق دالة على عناصرها. ومن المربك أن الكلمة نفسها تُستخدم في البرمجة لشيء ذي صلة لكنه مختلف تماماً.
الخريطة (map، اسم) بنية بيانات تربط قيماً (المفاتيح) بقيَم أخرى. فقد تريد مثلاً ربط الأسماء بالأعمار. ومن الممكن استخدام الكائنات لذلك.
let ages = {
Boris: 39,
Liang: 22,
Júlia: 62
};
console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true
هنا، أسماء خصائص الكائن أسماء الأشخاص وقيم الخصائص أعمارهم. لكننا بالتأكيد لم نُدرج أي شخص اسمه toString في خريطتنا. ومع ذلك، لأن الكائنات العادية تنحدر من Object.prototype، يبدو الأمر وكأن الخاصية موجودة.
ولهذا السبب، فإن استخدام الكائنات العادية كخرائط خطير. وهناك عدة طرق ممكنة لتجنب هذه المشكلة. أولاً، يمكنك إنشاء كائنات بـلا نموذج أولي. فإذا مررت null إلى Object.create، فلن ينحدر الكائن الناتج من Object.prototype ويمكن استخدامه بأمان كخريطة.
console.log("toString" in Object.create(null));
// → false
يجب أن تكون أسماء خصائص الكائن نصوصاً. وإذا احتجت إلى خريطة يصعب تحويل مفاتيحها إلى نصوص — مثل الكائنات — فلا يمكنك استخدام كائن كخريطتك.
لحسن الحظ، تأتي JavaScript مع صنف يسمى Map مكتوب لهذا الغرض تحديداً. فهو يخزّن ربطاً ويسمح بأي نوع من المفاتيح.
let ages = new Map();
ages.set("Boris", 39);
ages.set("Liang", 22);
ages.set("Júlia", 62);
console.log(`Júlia is ${ages.get("Júlia")}`);
// → Júlia is 62
console.log("Is Jack's age known?", ages.has("Jack"));
// → Is Jack's age known? false
console.log(ages.has("toString"));
// → false
الطرق set وget وhas جزء من واجهة كائن Map. فكتابة بنية بيانات تستطيع تحديث مجموعة كبيرة من القيم والبحث فيها بسرعة ليست سهلة، لكن لا داعي لأن نقلق بشأن ذلك. فقد فعلها شخص آخر من أجلنا، ويمكننا استخدام عمله عبر هذه الواجهة البسيطة.
إذا كان لديك كائن عادي تحتاج إلى معاملته كخريطة لسبب ما، فمن المفيد أن تعرف أن Object.keys تُرجع فقط المفاتيح الخاصة بالكائن، لا تلك الموجودة في النموذج الأولي. وبديلاً عن المعامل in، يمكنك استخدام الدالة Object.hasOwn، التي تتجاهل النموذج الأولي للكائن.
console.log(Object.hasOwn({x: 1}, "x"));
// → true
console.log(Object.hasOwn({x: 1}, "toString"));
// → false
تعدد الأشكال
عندما تستدعي الدالة String (التي تحوّل قيمة إلى نص) على كائن، فإنها ستستدعي طريقة toString على ذلك الكائن محاولةً إنشاء نص ذي معنى منه. ذكرت أن بعض النماذج الأولية القياسية تعرّف نسختها الخاصة من toString لتستطيع إنشاء نص يحمل معلومات أنفع من "[object Object]". ويمكنك أن تفعل ذلك بنفسك أيضاً.
Rabbit.prototype.toString = function() {
return `a ${this.type} rabbit`;
};
console.log(String(killerRabbit));
// → a killer rabbit
هذه حالة بسيطة لفكرة قوية. فعندما تُكتب قطعة شيفرة لتعمل مع كائنات تملك واجهة معينة — في هذه الحالة طريقة toString — يمكن وصل أي نوع من الكائنات يدعم هذه الواجهة بالشيفرة وسيستطيع العمل معها.
تسمى هذه التقنية تعدد الأشكال (polymorphism). ويمكن للشيفرة متعددة الأشكال أن تعمل مع قيم مختلفة الأشكال، ما دامت تدعم الواجهة التي تتوقعها.
ومن الأمثلة على واجهة مستخدمة على نطاق واسع واجهة الكائنات الشبيهة بالمصفوفات، التي تملك خاصية length تحمل عدداً وخصائص مرقّمة لكل عنصر من عناصرها. وتدعم هذه الواجهة كل من المصفوفات والنصوص، وكذلك كائنات أخرى متنوعة سنرى بعضها لاحقاً في الفصول المتعلقة بالمتصفح. ويمكنك استدعاء معظم طرق المصفوفات — مثل Array.prototype.forEach — على أي شيء يوفر هذه الواجهة.
Array.prototype.forEach.call({
length: 2,
0: "A",
1: "B"
}, elt => console.log(elt));
// → A
// → B
الـgetters والـsetters والطرق الساكنة
غالباً ما تحتوي الواجهات على خصائص عادية، لا طرق فحسب. فمثلاً، تملك كائنات Map خاصية size تخبرك بعدد المفاتيح المخزّنة فيها.
ولا يلزم أن يحسب كائن كهذا خاصية كهذه ويخزّنها مباشرةً في النسخة. فحتى الخصائص التي يُوصَل إليها مباشرة قد تخفي استدعاء دالة. وتسمى هذه الطرق getters، وتُعرَّف بكتابة get أمام اسم الطريقة في تعبير كائن أو إعلان صنف.
let varyingSize = {
get size() {
return Math.floor(Math.random() * 100);
}
};
console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49
كلما قرأ أحد من خاصية size في هذا الكائن، تُستدعى الطريقة المرتبطة بها. ويمكنك فعل شيء مشابه عند الكتابة إلى خاصية، باستخدام setter.
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
get fahrenheit() {
return this.celsius * 1.8 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) / 1.8;
}
static fromFahrenheit(value) {
return new Temperature((value - 32) / 1.8);
}
}
let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30
يتيح لك صنف Temperature قراءة درجة الحرارة والكتابة إليها إما بالدرجات المئوية أو بالفهرنهايت، لكنه داخلياً يخزّن الدرجات المئوية فقط ويحوّل تلقائياً من المئوية وإليها في getter وsetter الخاصين بـfahrenheit.
أحياناً تريد إرفاق بعض الخصائص بدالة البناء مباشرةً بدلاً من النموذج الأولي. فلن تستطيع هذه الطرق الوصول إلى نسخة صنف، لكن يمكن استخدامها مثلاً لتوفير طرق إضافية لإنشاء النسخ.
داخل إعلان صنف، تُخزَّن الطرق أو الخصائص التي تسبقها كلمة static على دالة البناء. فمثلاً، يتيح لك صنف Temperature كتابة Temperature.fromFahrenheit(100) لإنشاء درجة حرارة بالفهرنهايت.
let boil = Temperature.fromFahrenheit(212);
console.log(boil.celsius);
// → 100
الرموز
ذكرت في الفصل 4 أن حلقة for/of يمكنها الطواف على عدة أنواع من بنى البيانات. وهذه حالة أخرى من تعدد الأشكال — إذ تتوقع مثل هذه الحلقات أن تكشف بنية البيانات واجهة معينة، وهو ما تفعله المصفوفات والنصوص. ويمكننا أيضاً إضافة هذه الواجهة إلى كائناتنا الخاصة! لكن قبل أن نفعل ذلك، علينا أن نلقي نظرة موجزة على نوع الرمز (symbol).
من الممكن أن تستخدم واجهات متعددة اسم الخاصية نفسه لأشياء مختلفة. فمثلاً، في الكائنات الشبيهة بالمصفوفات، تشير length إلى عدد العناصر في المجموعة. لكن واجهة كائن تصف مسار تنزّه قد تستخدم length لتوفير طول المسار بالأمتار. ولن يكون ممكناً لكائن أن يطابق كلتا الواجهتين.
إن كائناً يحاول أن يكون مساراً وشبيهاً بالمصفوفة (ربما لعدّ نقاط مساره) أمر مستبعد إلى حد ما، وهذا النوع من المشكلات ليس شائعاً كثيراً في الممارسة. لكن لأشياء مثل بروتوكول التكرار، احتاج مصممو اللغة إلى نوع من الخصائص لا يتعارض فعلاً مع أي خصائص أخرى. ولذلك أُضيفت الرموز (symbols) إلى اللغة في 2015.
معظم الخصائص، بما فيها كل ما رأيناه حتى الآن، مسماة بنصوص. لكن من الممكن أيضاً استخدام الرموز كأسماء خصائص. والرموز قيم تُنشأ بالدالة Symbol. وخلافاً للنصوص، تكون الرموز المنشأة حديثاً فريدة — فلا يمكنك إنشاء الرمز نفسه مرتين.
let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(killerRabbit[sym]);
// → 55
النص الذي تمرره إلى Symbol يُدرَج عند تحويله إلى نص، ويمكن أن يسهّل التعرف على الرمز عند عرضه مثلاً في وحدة التحكم. لكن ليس له معنى يتجاوز ذلك — فقد تتشارك رموز متعددة الاسم نفسه.
ولأن الرموز فريدة ويمكن استخدامها كأسماء خصائص في آنٍ واحد، فهي مناسبة لتعريف واجهات يمكنها أن تتعايش بسلام إلى جانب خصائص أخرى، أياً كانت أسماؤها.
const length = Symbol("length");
Array.prototype[length] = 0;
console.log([1, 2].length);
// → 2
console.log([1, 2][length]);
// → 0
من الممكن إدراج خصائص الرموز في تعبيرات الكائنات والأصناف باستخدام قوسين مربعين حول اسم الخاصية. فذلك يجعل التعبير الواقع بين القوسين يُقيَّم لإنتاج اسم الخاصية، على غرار صيغة الوصول إلى الخصائص بالأقواس المربعة.
let myTrip = {
length: 2,
0: "Lankwitz",
1: "Babelsberg",
[length]: 21500
};
console.log(myTrip[length], myTrip.length);
// → 21500 2
واجهة المكرِّر
يُتوقع من الكائن المعطى إلى حلقة for/of أن يكون قابلاً للتكرار (iterable). وهذا يعني أنه يملك طريقة مسماة بالرمز Symbol.iterator (وهي قيمة رمز تعرّفها اللغة، مخزّنة كخاصية للدالة Symbol).
وعند استدعائها، ينبغي لتلك الطريقة أن تُرجع كائناً يوفر واجهة ثانية، هي المكرِّر (iterator). فهذا هو الشيء الذي يكرّر فعلاً. وله طريقة next تُرجع النتيجة التالية. وينبغي أن تكون تلك النتيجة كائناً له خاصية value توفر القيمة التالية إن وُجدت، وخاصية done ينبغي أن تكون true عند عدم وجود مزيد من النتائج وfalse خلاف ذلك.
لاحظ أن أسماء الخصائص next وvalue وdone نصوص عادية، لا رموز. فالرمز الفعلي الوحيد هو Symbol.iterator، الذي يُرجَّح إضافته إلى عدد كبير جداً من الكائنات المختلفة.
يمكننا استخدام هذه الواجهة مباشرةً بأنفسنا.
let okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next());
// → {value: "O", done: false}
console.log(okIterator.next());
// → {value: "K", done: false}
console.log(okIterator.next());
// → {value: undefined, done: true}
لننفّذ بنية بيانات قابلة للتكرار شبيهة بالقائمة المترابطة (linked list) من التمرين في الفصل 4. سنكتب القائمة هذه المرة كصنف.
class List {
constructor(value, rest) {
this.value = value;
this.rest = rest;
}
get length() {
return 1 + (this.rest ? this.rest.length : 0);
}
static fromArray(array) {
let result = null;
for (let i = array.length - 1; i >= 0; i--) {
result = new this(array[i], result);
}
return result;
}
}
لاحظ أن this، في طريقة ساكنة، يشير إلى دالة بناء الصنف، لا إلى نسخة — فلا توجد نسخة في الجوار عند استدعاء طريقة ساكنة.
ينبغي أن يؤدي الطواف على قائمة إلى إرجاع جميع عناصرها من البداية إلى النهاية. وسنكتب صنفاً منفصلاً للمكرِّر.
class ListIterator {
constructor(list) {
this.list = list;
}
next() {
if (this.list == null) {
return {done: true};
}
let value = this.list.value;
this.list = this.list.rest;
return {value, done: false};
}
}
يتتبع الصنف تقدم الطواف عبر القائمة بتحديث خاصيته list للانتقال إلى كائن القائمة التالي كلما أُرجعت قيمة، ويُبلّغ بأنه انتهى عندما تكون تلك القائمة فارغة (null).
لنجهّز صنف List ليكون قابلاً للتكرار. سأستخدم في هذا الكتاب من حين إلى آخر التلاعب بالنموذج الأولي بعد التعريف لإضافة طرق إلى الأصناف، حتى تبقى قطع الشيفرة الفردية صغيرة ومكتفية بذاتها. أما في برنامج عادي، حيث لا حاجة إلى تقسيم الشيفرة إلى قطع صغيرة، فستعلن هذه الطرق مباشرةً في الصنف بدلاً من ذلك.
List.prototype[Symbol.iterator] = function() {
return new ListIterator(this);
};
يمكننا الآن الطواف على قائمة بحلقة for/of.
let list = List.fromArray([1, 2, 3]);
for (let element of list) {
console.log(element);
}
// → 1
// → 2
// → 3
تعمل صيغة ... في ترميز المصفوفات واستدعاءات الدوال بالمثل مع أي كائن قابل للتكرار. فمثلاً، يمكنك استخدام [...value] لإنشاء مصفوفة تحتوي العناصر الموجودة في كائن قابل للتكرار اعتباطي.
console.log([..."PCI"]);
// → ["P", "C", "I"]
الوراثة
تخيّل أننا نحتاج نوع قائمة شبيه كثيراً بصنف List الذي رأيناه سابقاً، لكن لأننا سنطلب طوله طوال الوقت، لا نريد أن يضطر إلى مسح rest في كل مرة. بل نريد تخزين الطول في كل نسخة للوصول إليه بكفاءة.
يتيح نظام النماذج الأولية في JavaScript إنشاء صنف جديد، شبيه كثيراً بالصنف القديم، لكن بتعريفات جديدة لبعض خصائصه. فنموذج الصنف الجديد الأولي ينحدر من النموذج الأولي القديم لكنه يضيف تعريفاً جديداً لـgetter الخاص بـlength مثلاً.
وفي مصطلحات البرمجة كائنية التوجه، يسمى هذا وراثة. فالصنف الجديد يورث خصائص وسلوكاً من الصنف القديم.
class LengthList extends List {
#length;
constructor(value, rest) {
super(value, rest);
this.#length = super.length;
}
get length() {
return this.#length;
}
}
console.log(LengthList.fromArray([1, 2, 3]).length);
// → 3
يشير استخدام كلمة extends إلى أن هذا الصنف لا ينبغي أن يستند مباشرةً إلى النموذج الأولي Object الافتراضي بل إلى صنف آخر. ويسمى هذا الصنف الأصلي (superclass). أما الصنف المنحدر فهو الصنف الفرعي (subclass).
لتشغيل نسخة LengthList، تستدعي دالة البناء دالة بناء صنفها الأصلي عبر الكلمة super. وهذا ضروري لأنه إذا أردنا لهذا الكائن الجديد أن يتصرف (تقريباً) مثل List، فسيحتاج إلى خصائص النسخة التي تملكها القوائم.
ثم تخزّن دالة البناء طول القائمة في خاصية خاصة. ولو كنا كتبنا this.length هناك، لاستُدعي getter الصنف نفسه، وهو لا يعمل بعد لأن #length لم يُملأ بعد. ويمكننا استخدام super.something لاستدعاء الطرق والـgetters على النموذج الأولي للصنف الأصلي، وهذا مفيد غالباً.
تتيح لنا الوراثة بناء أنواع بيانات مختلفة قليلاً من أنواع بيانات موجودة بجهد قليل نسبياً. وهي جزء أساسي من التقليد كائني التوجه، إلى جانب التغليف وتعدد الأشكال. لكن في حين يُنظر إلى الأخيرين الآن عموماً كفكرتين رائعتين، فإن الوراثة أكثر إثارة للجدل.
فبينما يمكن استخدام التغليف وتعدد الأشكال لفصل قطع الشيفرة عن بعضها، مما يقلل تشابك البرنامج ككل، تربط الوراثة الأصناف بعضها ببعض ربطاً جوهرياً، فتخلق تشابكاً أكبر. وعند الوراثة من صنف، عليك عادةً معرفة المزيد عن كيفية عمله أكثر مما تحتاج عند مجرد استخدامه. ويمكن أن تكون الوراثة أداة مفيدة لجعل بعض أنواع البرامج أكثر إيجازاً، لكن ينبغي ألا تكون أول أداة تمد يدك إليها، والأرجح ألا تسعى بنشاط إلى البحث عن فرص لبناء هرميات أصناف (شجرات عائلات الأصناف).
المعامل instanceof
من المفيد أحياناً معرفة ما إذا كان كائن منحدراً من صنف محدد. ولهذا توفر JavaScript معاملاً ثنائياً يسمى instanceof.
console.log(
new LengthList(1, null) instanceof LengthList);
// → true
console.log(new LengthList(2, null) instanceof List);
// → true
console.log(new List(3, null) instanceof LengthList);
// → false
console.log([1] instanceof Array);
// → true
يرى هذا المعامل عبر الأنواع الموروثة، لذا فإن LengthList نسخة من List. ويمكن تطبيق المعامل أيضاً على دوال بناء قياسية مثل Array. فكل كائن تقريباً نسخة من Object.
الخلاصة
لا تقتصر الكائنات على حمل خصائصها الخاصة فحسب. فلديها نماذج أولية، هي كائنات أخرى. وستتصرف كما لو كانت تملك خصائص لا تملكها ما دام نموذجها الأولي يملك تلك الخاصية. وللكائنات البسيطة Object.prototype نموذجاً أولياً.
دوال البناء، وهي دوال تبدأ أسماؤها عادةً بحرف كبير، يمكن استخدامها مع المعامل new لإنشاء كائنات جديدة. وسيكون النموذج الأولي للكائن الجديد هو الكائن الموجود في خاصية prototype لدالة البناء. ويمكنك الاستفادة من ذلك جيداً بوضع الخصائص المشتركة بين جميع قيم نوع معين في نموذجها الأولي. وهناك صيغة class توفر طريقة واضحة لتعريف دالة بناء ونموذجها الأولي.
يمكنك تعريف getters وsetters لاستدعاء طرق سراً في كل مرة يُوصَل فيها إلى خاصية كائن. والطرق الساكنة طرق مخزّنة في دالة بناء الصنف لا في نموذجه الأولي.
يستطيع المعامل instanceof، بمعلومية كائن ودالة بناء، أن يخبرك ما إذا كان ذلك الكائن نسخة من دالة البناء تلك.
من الأمور المفيدة التي يمكن فعلها بالكائنات تحديد واجهة لها وإخبار الجميع بأنه يُفترض ألا يتحدثوا إلى كائنك إلا عبر تلك الواجهة. أما بقية التفاصيل التي يتكون منها كائنك فهي الآن مغلَّفة، مخفية خلف الواجهة. ويمكنك استخدام الخصائص الخاصة لإخفاء جزء من كائنك عن العالم الخارجي.
قد ينفّذ أكثر من نوع الواجهة نفسها. والشيفرة المكتوبة لاستخدام واجهة تعرف تلقائياً كيف تعمل مع أي عدد من الكائنات المختلفة التي توفر تلك الواجهة. ويسمى هذا تعدد الأشكال.
عند تنفيذ أصناف متعددة تختلف في بعض التفاصيل فقط، قد يكون من المفيد كتابة الأصناف الجديدة كأصناف فرعية لصنف موجود، ترث جزءاً من سلوكه.
التمارين
نوع متجه
اكتب صنفاً Vec يمثل متجهاً في فضاء ثنائي الأبعاد. يأخذ وسيطين هما x وy (عددان)، يحفظهما في خاصيتين بالاسم نفسه.
أعطِ النموذج الأولي لـVec طريقتين، plus وminus، تأخذان متجهاً آخر كوسيط وتُرجعان متجهاً جديداً يحمل مجموع أو فرق قيمتي x وy للمتجهين (this والوسيط).
أضف خاصية getter باسم length إلى النموذج الأولي تحسب طول المتجه — أي المسافة من النقطة (x, y) إلى نقطة الأصل (0, 0).
// شيفرتك هنا.
console.log(new Vec(1, 2).plus(new Vec(2, 3)));
// → Vec{x: 3, y: 5}
console.log(new Vec(1, 2).minus(new Vec(2, 3)));
// → Vec{x: -1, y: -1}
console.log(new Vec(3, 4).length);
// → 5
إظهار التلميح
ارجع إلى مثال صنف Rabbit إن لم تكن متأكداً من شكل إعلانات class.
يمكن إضافة خاصية getter إلى دالة البناء بوضع الكلمة get قبل اسم الطريقة. ولحساب المسافة من (0, 0) إلى (x, y)، يمكنك استخدام مبرهنة فيثاغورس، التي تنص على أن مربع المسافة التي نبحث عنها يساوي مربع الإحداثي x زائد مربع الإحداثي y. ومن ثم، فإن √(x2 + y2) هو العدد الذي تريده. وMath.sqrt هي طريقة حساب الجذر التربيعي في JavaScript، ويمكن استخدام x ** 2 لتربيع عدد.
المجموعات
توفر بيئة JavaScript القياسية بنية بيانات أخرى تسمى Set. ومثل نسخة Map، تحمل المجموعة مجموعة من القيم. وخلافاً لـMap، فإنها لا تربط بها قيماً أخرى — بل تتبع فقط أي القيم جزء من المجموعة. ويمكن أن تكون القيمة جزءاً من المجموعة مرة واحدة فقط — فإضافتها مجدداً ليس له أي أثر.
اكتب صنفاً يسمى Group (لأن Set محجوز بالفعل). ومثل Set، يملك طرق add وdelete وhas. دالة بنائه تُنشئ مجموعة فارغة، وadd تضيف قيمة إلى المجموعة (لكن فقط إن لم تكن عضواً فيها بالفعل)، وdelete تحذف معطاها من المجموعة (إن كان عضواً)، وhas تُرجع قيمة منطقية تشير إلى ما إذا كان معطاها عضواً في المجموعة.
استخدم المعامل ===، أو ما يكافئه مثل indexOf، لتحديد ما إذا كانت قيمتان متماثلتين.
أعطِ الصنف طريقة ساكنة from تأخذ كائناً قابلاً للتكرار كمعطى لها وتنشئ مجموعة تحتوي جميع القيم الناتجة عن الطواف عليه.
class Group {
// شيفرتك هنا.
}
let group = Group.from([10, 20]);
console.log(group.has(10));
// → true
console.log(group.has(30));
// → false
group.add(10);
group.delete(10);
console.log(group.has(10));
// → false
إظهار التلميح
أسهل طريقة لفعل ذلك تخزين مصفوفة بأعضاء المجموعة في خاصية نسخة. ويمكن استخدام الطريقتين includes أو indexOf للتحقق مما إذا كانت قيمة معينة موجودة في المصفوفة.
يمكن لدالة بناء صنفك ضبط مجموعة الأعضاء على مصفوفة فارغة. وعند استدعاء add، يجب أن تتحقق مما إذا كانت القيمة المعطاة موجودة في المصفوفة أو تضيفها خلاف ذلك، ربما باستخدام push.
حذف عنصر من مصفوفة، في delete، أقل بساطة، لكن يمكنك استخدام filter لإنشاء مصفوفة جديدة بدون القيمة. ولا تنسَ أن تكتب فوق الخاصية الحاملة للأعضاء بالنسخة المصفَّاة الجديدة من المصفوفة.
يمكن لطريقة from استخدام حلقة for/of لاستخراج القيم من الكائن القابل للتكرار واستدعاء add لوضعها في مجموعة منشأة حديثاً.
مجموعات قابلة للتكرار
اجعل صنف Group من التمرين السابق قابلاً للتكرار. ارجع إلى القسم الخاص بواجهة المكرِّر في وقت سابق من هذا الفصل إن لم يكن شكل الواجهة الدقيق واضحاً لك بعد.
إذا استخدمت مصفوفة لتمثيل أعضاء المجموعة، فلا تُرجع فقط المكرِّر الناتج عن استدعاء طريقة Symbol.iterator على المصفوفة. فذلك سيعمل، لكنه يبطل الغرض من هذا التمرين.
لا بأس إن تصرف مكرِّرك بغرابة عند تعديل المجموعة أثناء الطواف.
// شيفرتك هنا (وشيفرة التمرين السابق)
for (let value of Group.from(["a", "b", "c"])) {
console.log(value);
}
// → a
// → b
// → c
إظهار التلميح
يجدر على الأرجح بتعريف صنف جديد GroupIterator. وينبغي لنسخ المكرِّر أن تملك خاصية تتبع الموضع الحالي في المجموعة. وفي كل مرة تُستدعى next، تتحقق مما إذا كانت قد انتهت، وإن لم تكن، تتجاوز القيمة الحالية وتُرجعها.
ويحصل صنف Group نفسه على طريقة مسماة بـSymbol.iterator تُرجع عند استدعائها نسخة جديدة من صنف المكرِّر الخاص بتلك المجموعة.