إلكونت جافاسكريبت الحياة السرية للكائنات في جافاسكريبت


أسامه دمراني
اقتباس

يلاحَظ نوع البيانات المجرد بكتابة برنامج خاص يعرِّف النوع من حيث العمليات التي يمكن تنفيذها عليه.

__ باربرا ليزكوف Barbara Liskov، البرمجة بالأنواع المجردة للبيانات.

chapter_picture_6.jpg

تحدثنا في المقال الرابع عن الكائنات في جافاسكربت، ولدينا في ثقافة البرمجة شيء يسمى بالبرمجة كائنية التوجه، وهي مجموعة تقنيات تستخدم الكائنات والمفاهيم المرتبطة بها مثل مبدأ مركزي لتنظيم البرامج. ورغم عدم وجود إجماع على التعريف الدقيق للبرمجة كائنية التوجه هذه، إلا أنها قد غيرت شكل لغات برمجة كثيرة من حيث تصميمها، بما فيها جافاسكربت، وسنتعرض في هذا المقال للطرق التي يمكن تطبيق أفكار هذا المفهوم في جافاسكربت.

التغليف Encapsulation

تتلخص فكرة البرمجة كائنية التوجه في تقسيم البرامج إلى أجزاء صغيرة وجعل كل جزء مسؤولًا عن إدارة حالته الخاصة، وهكذا يمكن حفظ المعلومات الخاصة بالأسلوب الذي يعمل به جزء ما من البرنامج داخل ذلك الجزء فقط محليًا، بحيث إذا عمل شخص ما على جزء آخر من البرنامج، فليس عليه معرفة أو إدراك حتى هذه البيانات والمعلومات؛ وإذا تغيرت تلك التفاصيل المحلية، فلن نحتاج سوى إلى تعديل جزء الشيفرة المتعلق بها فقط. ويُطلق على فصل الواجهة عن الاستخدام نفسه أو التطبيق بـالتغليف، وهو فكرة عظيمة.

تتفاعل الأجزاء المختلفة من البرامج مع بعضها البعض من خلال واجهات interfaces، وهي مجموعات محدودة من الدوال أو الرابطات bindings التي توفر أداءً مفيدًا في المستويات العليا التجريدية التي تخفي استخدامها الدقيق والمباشر. كما نُمذِجت مثل تلك الأجزاء باستخدام كائنات، وواجهاتها مكونة من مجموعات محددة من التوابع والخصائص، حيث يوجد نوعان من هذه الخصائص، إما عامة عندما تكون جزءًا من الواجهة، أو خاصة يجب ألا يقربها أي شيء خارج الشيفرة.

توفر الكثير من اللغات طريقةً للتمييز بين الخصائص العامة والخاصة، وذلك لمنع الشيفرات الخارجية من الوصول إلى الخصائص الخاصة؛ أما جافاسكربت فلا تفعل ما سبق، إذ تتبع أسلوبها البسيط في ذلك حاليًا، ويوجد ثمة أعمال لإضافة ذلك إليها.

رغم عدم دعم اللغة لهذه الخاصية في التفرقة، إلا أنّ مبرمجي جافاسكربت يفعلون ذلك من حيث المبدأ، فالواجهة المتاحة موصوفة ومشروحة في التوثيق أو التعليقات، ومن الشائع كذلك وضع شرطة سفلية (_) في بداية أسماء الخصائص للإشارة إلى أنها "خاصة".

التوابع Methods

التوابع ليست إلا خصائص حاملة لقيم الدوال، انظر المثال التالي لتابع بسيط:

let rabbit = {};
rabbit.speak = function(line) {
  console.log(`The rabbit says '${line}'`);
};

rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'

يُتوقع من التابع فعل شيء بالكائن الذي استدعي له، فحين تُستدعى دالة على أساس تابع -يُبحث عنها على أساس خاصية، ثم تُستدعى مباشرةً كما في حالة object.method()‎-، ستشير الرابطة التي استدعت this في متنها مباشرةً إلى الكائن الذي استُدعي عليه.

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 ears and whiskers, " +
                  "how late it's getting!");
// → The white rabbit says 'Oh my ears and whiskers, how
//   late it's getting!'
hungryRabbit.speak("I could use a carrot right now.");
// → The hungry rabbit says 'I could use a carrot right now.'

فكر في this على أساس معامِل إضافي يُمرَّر في صورة مختلف، فإذا أردت تمريره صراحةً، فستستخدِم تابع call الخاص بالدالة والذي يأخذ قيمة this على أساس وسيطها الأول، وتعامِل الوسائط التالية على أساس معامِلات عادية.

speak.call(hungryRabbit, "Burp!");
// → The hungry rabbit says 'Burp!'

وبما أنّ كل دالة لها رابطة this الخاصة بها، والتي تعتمد قيمتها على الطريقة التي المًستدعاة بها، فلا تستطيع الإشارة إلى this بنطاق مغلِّف في دالة عادية معرَّفة بكلمة function المفتاحية؛ أما الدوال السهمية فتختلف في عدم ارتباط this الخاص بها، لكنها تستطيع رؤية رابطة this للنطاق الذي حولها، وعليه ستستطيع تنفيذ شيء مثل ما في الشيفرة التالية، حيث تشير إلى this مرجعيًا من داخل دالة محلية:

function normalize() {
  console.log(this.coords.map(n => n / this.length));
}
normalize.call({coords: [0, 2, 3], length: 5});
// → [0, 0.4, 0.6]

فلو كتبنا الوسيط إلى map باستخدام كلمة function المفتاحية، فلن تعمل الشيفرة.

الوراثة عبر سلسلة prototype

انظر المثال التالي:

let empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

أرأيت كيف سحبنا خاصيةً من كائن فارغ؟! حيث لم نزد على الطريقة التي تعمل بها كائنات جافاسكربت في حفظ البيانات الخاصة، لأن أغلب الكائنات ترث من سلسلة prototype بكل ما تحويه من دوال وخاصيات وكائنات أخرى إضافةً إلى مجموعة خصائصها، وتلك ما هي إلا كائنات أخرى مستخدَمة على أساس مصدر احتياطي fallback للخصائص، فإذا طُلِب من كائن خاصية لا يملكها، فسيُبحث في سلسلة prototype عن تلك الخاصية، ثم في سلاسل prototype الخاصة بكل واحدة فيها على حدى، وهكذا.

طيب، ما سلسلة prototype لذاك الكائن الفارغ؟ إنه object.prototype الذي يسبق الكائنات كلها، فإذا قلنا أنّ علاقات سلاسل prototype في جافاسكربت تكوِّن هيكلًا شجريًا، فسيكون جذر تلك الشجرة هو Object.prototype، إذ يوفِّر بعضَ التوابع التي تظهر في جميع الكائنات الأخرى مثل toString الذي يحول الكائن إلى تمثيل نصي string representation.

لا تملك العديد من الكائنات Object.prototype مثل نموذجها الأولي (يطلق على سلسلة prototype نموذج أولي مجازًا بوصفه أول نموذج يمثل خاصيات ودوال يرثه الكائن عند إنشائه)، بل يكون لها كائنٌ آخر يوفر مجموعةً مختلفةً من الخصائص الافتراضية.

console.log(Object.getPrototypeOf({}) ==
            Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

كما تتوقع من المثال السابق، سيُعيد Object.getPrototypeOf سلسلة 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 killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'

تُعَدّ خاصية مثل speak(line)‎ في تعبير الكائن طريقةً مختصرةً لتعريف تابع ما، إذ تنشِئ خاصيةً اسمها speak، وتعطيها دالةً على أساس قيمة لها؛ كما يتصرف الأرنب "proto" في المثال السابق على أساس حاوية للخصائص التي تشترك فيها جميع الأرانب؛ أما في حالة مثل الأرنب القاتل killer rabbit، فيحتوي على خصائص لا تنطبق إلا عليه -نوعه في هذه الحالة-، كما يأخذ خصائصًا مشتركةً من نموذجه الأولي.

الأصناف Classes

يحاكي نظام النماذج الأولية في جافاسكربت (أي سلسلة prototype كما أشرنا في الأعلى) مفهوم الأصناف Classes في البرمجة كائنية التوجه، حيث تحدِّد هذه الأصناف الشكل الذي سيكون عليه نوع ما من كائن، وذلك بتحديد توابعه وخصائصه، كما يُدعى مثل ذلك الكائن بنسخة instance من الصنف.

تُعَدّ النماذج الأولية مفيدةً هنا في تحديد الخصائص المشتركة بين جميع نُسَخ الصنف التي لها القيمة نفسها مثل التوابع؛ أما الخصائص المختلفة بين كل نسخة -كما في حالة خاصية type لأرنبنا في المثال السابق-، فيجب تخزينها في الكائن نفسه مباشرةً.

لذا عليك إنشاء كائنًا مشتقًا من النموذج الأولي المناسب من أجل إنشاء نسخة من صنف ما، لكن في الوقت نفسه يجب التأكد من امتلاكه الخصائص الواجب وجودها في نُسَخ ذلك الصنف، وهذا ما يمثل وظيفة دالة الباني constructor، انظر ما يلي:

function makeRabbit(type) {
  let rabbit = Object.create(protoRabbit);
  rabbit.type = type;
  return rabbit;
}

توفر جافاسكربت طريقةً لتسهيل تعريف هذا النوع من الدوال، فإذا وضعتَ كلمة new المفتاحية أمام استدعاء الدالة مباشرةً، فستُعامَل الدالة على أساس باني، وهذا يعني أنه سيُنشَأ الكائن الذي يحمل النموذج الأولي المناسب تلقائيًا، بحيث يكون مقيدًا بـ this في الدالة، ثم يُعاد في نهاية الدالة، ويمكن العثور على كائن النموذج الأولي المستخدَم عند بناء الكائنات من خلال أخذ خاصية protoype لدالة الباني.

function Rabbit(type) {
  this.type = type;
}
Rabbit.prototype.speak = function(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
};

let weirdRabbit = new Rabbit("weird");

تحصل البواني، بل كل الدوال، على خاصية اسمها prototype تحمل بدورها كائنًا فارغًا مشتقًا من Object.prototype، وتستطيع استبدال كائن جديد به إن شئت أو إضافة خصائص إلى الكائن الجديد كما في المثال.

تتكوّن أسماء البواني من الحروف الكبيرة لتمييزها عما سواها، ومن المهم إدراك الفرق بين الطريقة التي يرتبط بها النموذج الأولي بالباني من خلال خاصية prototype، والطريقة التي يكون للكائنات فيها نماذج أولية -والتي يمكن إيجادها باستخدام Object.getPrototypeOf.

Function.Prototype هو النموذج الأولي الفعلي للباني بما أنّ البواني ما هي إلا دوال في الأصل، وتحمل خاصية prototype الخاصة به النموذج الأولي المستخدَم للنسخ التي أنشِئت من خلاله.

console.log(Object.getPrototypeOf(Rabbit) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf(weirdRabbit) ==
            Rabbit.prototype);
// → true

صياغة الصنف Class Notation

ذكرنا أن أصناف جافاسكربت ما هي إلا دوال بانية مع خاصية النموذج الأولي، وقد كان ذلك حتى عام 2015؛ أما الآن فقد تحسنت الصيغة التي صارت عليها كثيرًا، انظر إلى ما يلي:

class Rabbit {
  constructor(type) {
    this.type = type;
  }
  speak(line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
  }
}

let killerRabbit = new Rabbit("killer");
let blackRabbit = new Rabbit("black");

تبدأ كلمة class المفتاحية تصريح صنفٍ يسمح لنا بتعريف باني ومجموعة توابع في مكان واحد، كما يمكن كتابة أيّ عدد من التوابع بين قوسي التصريح، لكن يُعامَل التابع الحامل لاسم constructor معاملةً خاصةً، إذ يوفِّر وظيفة الباني الفعلية التي ستكون مقيدة بالاسم Rabbit، في حين تُحزَّم التوابع الأخرى في النموذج الأولي لذلك الباني، ومن ثم يكون تصريح الصنف الذي ذكرناه قبل قليل مكافئًا لتعريف الباني من القسم السابق، كونه يبدو أفضل للقارئ.

ولا تسمح تصريحات الأصناف حاليًا إلا بإضافة التوابع إلى النموذج الأولي، وهي الخصائص التي تحمل دوالًا، رغم أن ذلك قد يكون مرهقًا إذا أردت حفظ قيمة غير دالّية non-function هناك، وقد يتحسن ذلك في الإصدار القادم من اللغة، لكن حتى ذلك الحين تستطيع إنشاء مثل تلك الخصائص بتغيير النموذج الأولي مباشرةً بعد تعريف الصنف.

يمكن استخدام class في التعليمات والتعابير على حد سواء، وشأنها في ذلك شأن function، حيث لا تعرِّف رابطةً عند استخدامها على أساس تعبير، وإنما تنتج الباني كقيمة فقط. وتستطيع إهمال اسم الصنف في تعبير الصنف، كما في المثال التالي:

let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello

إعادة تعريف الخصائص المشتقة

تُضاف الخاصية إلى الكائن نفسه عند إضافتها إليه سواءً كان موجودًا في النموذج الأولي أم غير موجود، فإن كان ثمة خاصية موجودة بالاسم نفسه في النموذج الأولي، فلن تؤثِّر هذه الخاصية في الكائن بما أنها مخفية الآن خلف الخاصية التي يملكها الكائن.

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(blackRabbit.teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

يبيّن المخطط التالي الموقف بعد تشغيل الشيفرة السابقة، إذ يقبع النموذجين الأوليَين لـ Rabbit، وObject خلف killerRabbit على أساس حاجز خلفي له، بينما يُبحث عن الخصائص التي ليست موجودة في الكائن.

rabbits.png

وتبدو فائدة إعادة تعريف الخصائص overriding properties الموجودة في النموذج الأولي في التعبير عن الخصائص الاستثنائية في نُسَخ الأصناف العامة للكائنات، كما في مثال أسنان الأرنب rabbit teeth السابق، مع السماح للكائنات غير الاستثنائية بأخذ قيمة قياسية من نموذجها الأولي.

كما يمكن استخدام إعادة التعريف لإعطاء تابع 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]

الخرائط Maps

استخدمنا كلمة map في المقال السابق في عملية تحويل هيكل البيانات بتطبيق دالة على عناصره، رغم بعد معنى الكلمة نفسها، التحويل، الدال عن الفعل الذي تنفذه، وهنا أيضًا وفي البرمجة عمومًا، فتُستخدَم هذه الكلمة كذلك لغرض مختلف لكنه قريب مما رأينا، وكلمة map على أساس اسم هي أحد أنواع هياكل البيانات الذي يربط القيم (المفاتيح) بقيم أخرى، فإذا أردت ربط الأسماء بأعمار مقابلة لها، فتستطيع استخدام كائنات لذلك، كما في المثال التالي:

let ages = {
  Ziad: 39,
  Hasan: 22,
  Sumaia: 62
};

console.log(`Sumaia is ${ages["Sumaia"]}`);
// → Sumaia 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 فيبدو الأمر وكأن الخاصية موجودة هناك، لهذا فمن الخطر معاملة الكائنات العادية مثل معاملة خرائط -النوع Map- هنا.

لدينا عدة طرق مختلفة لتجنب هذه المشكلة، فمن الممكن مثلًا إنشاء كائنات بدون نموذج أولي، حتى إذا مرّرت null إلى Object.create، فلن يكون الكائن الناتج مشتقًا من Object.prototype، وعليه يمكن استخدامه بأمان على أساس خارطة.

console.log("toString" in Object.create(null));
// → false

يجب أن تكون أسماء خصائص الكائنات سلاسل نصية، فإن أردت ربطًا لا يمكن تحويل مفاتيحه بسهولة إلى سلاسل نصية -مثل الكائنات- فلا تستخدم كائنًا على أساس خارطة، ولحسن الحظ فتملك جافاسكربت صنفًا اسمه Map مكتوب لهذا الغرض خاصة، حيث يخزِّن حالة الربط ويسمح بأي نوع من المفاتيح.

let ages = new Map();
ages.set("Ziad", 39);
ages.set("Hasan", 22);
ages.set("Sumaia", 62);

console.log(`Sumaia is ${ages.get("Sumaia")}`);
// → Sumaia 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، فليس من السهل كتابة هيكل بيانات لتحديث مجموعة كبيرة من القيم والبحث فيها، ولكن لا تقلق، فقد كفانا شخص آخر مؤنة ذلك، حيث نستطيع استخدام ما كتبه من خلال تلك الواجهة البسيطة.

إذا أردت معاملة كائن عادي لديك على أساس خارطة (النوع Map) لسبب ما، فمن المهم معرفة أن Object.keys يعيد المفاتيح الخاصة بالكائن فقط، وليس تلك الموجودة في النموذج الأولي، كما تستطيع استخدام التابع hasOwnProperty على أساس بديل لعامِل in، حيث يتجاهل النموذج الأولي للكائن، كما في المثال التالي:

console.log({x: 1}.hasOwnProperty("x"));
// → true
console.log({x: 1}.hasOwnProperty("toString"));
// → false

تعددية الأشكال Polymorphism

إذا استدعيتَ دالة String -التي تحوِّل القيمة إلى سلسلة نصية- على كائن ما، فستستدعي التابع toString على ذلك الكائن لمحاولة إنشاء سلسلة نصية مفيدة منه.

كما ذكرنا سابقًا، تعرِّف بعض النماذج الأولية القياسية (سلاسل prototype) إصدارًا من toString خاصًا بها، وذلك لتستطيع إنشاء سلسلة نصية تحتوي بيانات مفيدة أكثر من "[object Object]"، كما تستطيع فعل ذلك بنفسك إن شئت.

Rabbit.prototype.toString = function() {
  return `a ${this.type} rabbit`;
};

console.log(String(blackRabbit));
// → a black rabbit

وهذه صورة بسيطة من مفهوم بالغ القوة والأثر، فإن كُتب جزء من شيفرة ما ليعمل مع كائنات بها واجهة معينة -تابع toString في هذه الحالة-، فيمكن إلحاق أي نوع من الكائنات الداعمة لتلك الواجهة بالشيفرة، حيث ستعمل دون مشاكل؛ وتسمى تلك التقنية بتعددية الأشكال، وتعمل الشيفرة المتعددة الأشكال مع قيم ذات أشكال مختلفة طالما أنها تدعم الواجهة التي تتوقعها.

كما ذكرنا في المقال الرابع، تستطيع حلقة for/of التكرار على عدة أنواع من هياكل البيانات، وتلك حالة أخرى من تعددية الأشكال، حيث تتوقع مثل تلك الحلقات التكرارية من هيكل البيانات أن يكشف واجهة معينة، وهو ما تفعله المصفوفات والسلاسل النصية؛ كما نستطيع إضافة تلك الواجهة إلى كائناتنا الخاصة، لكننا نحتاج إلى معرفة ما هي الرموز symbols قبل فعل ذلك.

الرموز Symbols

تستطيع عدة واجهات استخدام اسم الخاصية نفسها لأشياء عدة، فمثلًا، نستطيع تعريف واجهة بحيث يحوِّل فيها التابع toString الكائن إلى قطعة من خيوط الغزل، لكن من غير الممكن لكائن أن يتوافق مع تلك الواجهة ومع الاستخدام القياسي لـ toString.

هذه المشكلة سيئة لكنها لا تشغل بال من يكتب بجافاسكربت لأنها غير شائعة، ورغم هذا فقد وفر مصممو جافاسكربت لنا حلًا لهذه المشكلة، إذ أن تلك من وظيفتهم على أي حال.

حين زعمنا أن أسماء الخصائص هي سلاسل نصية لم نكن محقين 100%، فرغم أنها حقًا سلاسل نصية إلا قد تكون رموزًا أيضًا، وهي -أي الرموز- قيم أنشِئت بواسطة دالة Symbol، كما تُعَدّ الرموز المنشَئة حديثًا فريدةً، على عكس السلاسل النصية، بحيث لا تستطيع إنشاء الرمز نفسه مرتين.

let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(blackRabbit[sym]);
// → 55

تُضمَّن السلسلة النصية الممررة إلى Symbol تلقائيًا حين تحوّلها إلى سلسلة نصية، كما تسهِّل التعرف على الرمز عند عرضه في الطرفية console مثلًا؛ ولأن الرموز فريدة ويمكن استخدامها على أساس أسماء للخصائص، فهي مناسبة لتعريف الواجهات التي يمكن وجودها مع الخصائص الأخرى مهما كانت أسماؤها.

const toStringSymbol = Symbol("toString");
Array.prototype[toStringSymbol] = function() {
  return `${this.length} cm of blue yarn`;
};

console.log([1, 2].toString());
// → 1,2
console.log([1, 2][toStringSymbol]());
// → 2 cm of blue yarn

ومن الممكن إضافة خصائص رمز ما في الأصناف وتعبيرات الكائنات باستخدام أقواس مربعة حول اسم الخاصية، وبسبب ذلك سيُقيَّم اسم الخاصية مثل صيغة الوصول إلى الخاصية التي تستخدِم قوسين مربعين، حيث سيسمح لنا هذا بالإشارة إلى الرابطة التي تحمل الرمز.

let stringObject = {
  [toStringSymbol]() { return "a jute rope"; }
};
console.log(stringObject[toStringSymbol]());
// → a jute rope

واجهة المكرر

يُتوقع من الكائن المعطى لحلقة for/of قابليته للتكرار، ويعني ذلك أنّ به تابعًا مسمى مع الرمز 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}

دعنا نطبق هنا هيكل بيانات قابلًا للتكرار، حيث سنبني صنفَ matrix يتصرف على أساس مصفوفة ثنائية الأبعاد.

class Matrix {
  constructor(width, height, element = (x, y) => undefined) {
    this.width = width;
    this.height = height;
    this.content = [];

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        this.content[y * width + x] = element(x, y);
      }
    }
  }

  get(x, y) {
    return this.content[y * this.width + x];
  }
  set(x, y, value) {
    this.content[y * this.width + x] = value;
  }
}

يخزِّن الصنف محتوياته في مصفوفة واحدة من عنصرين فقط، هما: العرض، والطول width*height، وتُخزَّن العناصر صفًا صفًا، فيُخزن العنصر الثالث في الصف الخامس مثلًا -باستخدام الفهرسة الصفرية التي تبدأ من الصفر- في الموضع ‎4 * width + 2‎.

تأخذ دالة الباني العرض، والطول، ودالة element اختيارية ستُستخدم لكتابة القيم الابتدائية؛ أما لجلب العناصر وتحديثها في المصفوفة الثنائية، فلدينا التابعان get، وset.

حين نكرر على مصفوفة ما، فنحن بحاجة إلى معرفة موضع العناصر إضافة إلى العناصر نفسها، لذا سنجعل المكرِّر ينتج كائنات لها خصائص x، وy، وvalue.

class MatrixIterator {
  constructor(matrix) {
    this.x = 0;
    this.y = 0;
    this.matrix = matrix;
  }

  next() {
    if (this.y == this.matrix.height) return {done: true};

    let value = {x: this.x,
                 y: this.y,
                 value: this.matrix.get(this.x, this.y)};
    this.x++;
    if (this.x == this.matrix.width) {
      this.x = 0;
      this.y++;
    }
    return {value, done: false};
  }
}

يتتبع الصنف سير التكرار على المصفوفة الثنائية في الخصائص x، وy، ويبدأ التابع next بالتحقق من الوصول لأسفل المصفوفة الثنائية، فإن لم يصل إليه، فسينشِئ الكائن الذي يحمل القيمة الحالية أولًا، ثم يحدِّث موضعه، وبعد ذلك ينقله إلى السطر التالي إن تطلب الأمر.

دعنا نهيئ صنف Matrix ليكون قابلًا للتكرار، وانتبه إلى استخدامنا المعالجة اللاحقة للنموذج الأولي بين الحين والآخر في هذه السلسلة لإضافة توابع إلى الأصناف، وذلك لتبقى الأجزاء المفردة من الشيفرة صغيرةً ومستقِلة؛ أما في البرامج العادية التي لا تحتاج فيها إلى تقسيم الشيفرة إلى أجزاء صغيرة، فستصرِّح عن هذه التوابع مباشرةً في الصنف.

Matrix.prototype[Symbol.iterator] = function() {
  return new MatrixIterator(this);
};

نستطيع الآن تطبيق التكرار على مصفوفة ما باستخدام for/of.

let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`);
for (let {x, y, value} of matrix) {
  console.log(x, y, value);
}
// → 0 0 value 0,0
// → 1 0 value 1,0
// → 0 1 value 0,1
// → 1 1 value 1,1

التوابع الجالبة والضابطة والساكنة

تتكون الواجهات من التوابع غالبًا، وقد تتضمن خصائص بها قيم غير دالية، فمثلًا، تملك كائنات Map خاصية size، والتي تخبرك كم عدد المفاتيح المخزَّنة فيها.

ليس من الضروري لمثل هذا الكائن أن يحسب ويخزن خاصية مشابهة لتلك مباشرةً في النسخة instance التي لديه، بل حتى الخصائص التي يمكن الوصول إليها مباشرةً قد تخفي استدعاءً إلى تابع، حيث تسمى مثل تلك التوابع بالتوابع الجالبة 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 في المثال أعلاه بقراءة درجة الحرارة وكتابتها سواءً بمقياس السليزيوس أو الفهرنهايت، لكنها تخزِّن داخلها درجات السليزيوس فقط، وتحوِّل من وإلى سليزيوس في التابع الجالب والضابط لـ fahrenheit تلقائيًا.

قد تحتاج أحيانًا إلى إلحاق بعض الخصائص لدالة الباني الخاصة بك مباشرةً بدلًا من النموذج الأولي، ولا تملك مثل تلك التوابع وصولًا إلى نسخة صنف، لكن يمكن استخدامها لتوفير طرق بديلة وإضافية لإنشاء النسخ.

تُخزَّن التوابع المكتوبة قبل اسمها static على الباني، وذلك داخل التصريح عن الصنف، وعليه فيسمح لك صنف Temperature بكتابة Temperature.fromFahrenheit(100)‎ لإنشاء درجة حرارة باستخدام مقياس فهرنهايت.

الوراثة Inheritance

تتميز بعض المصفوفات بأنها تماثلية symmetric، بحيث إذا عكست إحداها حول قطرها الذي يبدأ من أعلى اليسار، فستبقى كما هي ولا تتغير، أي ستبقى القيمة المخزنة في الموضع (x،y) كما هي في الموضع (y،x).

تخيل أننا نحتاج إلى هيكل بيانات مثل Matrix، لكن يجب ضمان تماثلية المصفوفة وبقائها كذلك، وهنا نستطيع كتابة هذا من الصفر، لكننا سنكرر شيفرةً مشابهةً كثيرًا لما كتبناه سابقًا.

يسمح نظام النموذج الأولي في جافاسكربت بإنشاء صنف جديد محاكي لصنف قديم لكن مع تعريفات جديدة لبعض خصائصه، ويكون النموذج الأولي للصنف الجديد مشتقًا من القديم لكن مع إضافة تعريف جديد إلى التابع set مثلًا، ويسمى ذلك بالاكتساب أو الوراثة inheritance، إذ يرث الصنف الجديد خصائصه وسلوكه من الصنف القديم.

class SymmetricMatrix extends Matrix {
  constructor(size, element = (x, y) => undefined) {
    super(size, size, (x, y) => {
      if (x < y) return element(y, x);
      else return element(x, y);
    });
  }

  set(x, y, value) {
    super.set(x, y, value);
    if (x != y) {
      super.set(y, x, value);
    }
  }
}

let matrix = new SymmetricMatrix(5, (x, y) => `${x},${y}`);
console.log(matrix.get(2, 3));
// → 3,2

يشير استخدام كلمة extends إلى وجوب عدم اعتماد هذا الصنف على النموذج الأولي الافتراضي Object مباشرةً، وإنما على صنف آخر يسمى بالصنف الأب superclass؛ أما الصنف المشتق فيكون اسمه الصنف الفرعي، أو الابن subclass.

يستدعي الباني لتهيئة نسخة من SymmetricMatrix باني صنف الأب من خلال كلمة super المفتاحية، وهذا ضروري لأنّ الكائن الجديد سيحتاج إلى خصائص النسخة التي تملكها المصفوفات، إذا تصرَّف مثل Matrix. كما يغلِّف الباني دالة element لتبديل إحداثيات القيم أسفل خط القطر، وذلك لضمان تماثل المصفوفة.

يُستخدَم super مرةً أخرى من التابع set، وذلك لاستدعاء تابع معين من مجموعة توابع الصنف الأب؛ كما سنعيد تعريف set لكن لن نستخدم السلوك الأصلي، حيث لن ينجح استدعاؤه بسبب إشارة this.set إلى set الجديد، كذلك يوفر super الواقع داخل توابع الصنف، طريقةً لاستدعاء التوابع كما عُرِّفت في الصنف الأب.

وتسمح لنا الوراثة ببناء أنواع بيانات مختلفة من أنواع موجودة مسبقًا بقليل من الجهد، وهذه -أي الوراثة- جزء أساسي في ثقافة البرمجة كائنية التوجه جنبًا إلى جنب مع التغليف وتعددية الأشكال، لكن لأن هذين الآخرَين يُعتد بهما كثيرًا في البرمجة على أساس أساليب مهمة ومفيدة، فإنّ الوراثة قد صارت محل نظر، ففي حين يُستخدَم كل من التغليف وتعددية الأشكال في فصل أجزاء الشيفرات عن بعضها مما يقلل من تعقيد البرنامج عمومًا، فالوراثة على العكس من ذلك، إذ تربط الأصناف معًا منشِئًة مزيدًا من التعقيد، لأن عليك في الغالب معرفة كيفية عمل ذلك الصنف حين تحتاج إلى الوراثة منه، بخلاف إن لم تفعل شيئًا سوى استخدامه.

وإننا نستخدمه بين الحين والآخر في برامجنا، لكن لا يحملنك ذلك على التفكير فيه أول شيء، فليس من الحكمة جعل بناء هرميات من الأصناف (شجرة عائلة من الأصناف) خيارك الأول في حل المشاكل.

عامل instanceof

توفر جافاسكربت عاملًا ثنائيًا يسمى instanceof، حيث نستخدمه إذا أردنا معرفة إن كان الكائن مشتقًا من صنف بعينه.

console.log(
  new SymmetricMatrix(2) instanceof SymmetricMatrix);
// → true
console.log(new SymmetricMatrix(2) instanceof Matrix);
// → true
console.log(new Matrix(2, 2) instanceof SymmetricMatrix);
// → false
console.log([1] instanceof Array);
// → true

سينظر العامل في الأنواع المكتسبة، وسيجد أن symmetricMatrix نسخةٌ من Matrix، كما يمكن استخدام العامل مع البواني القياسية مثل Array، فكل كائن تقريبًا ما هو إلا نسخة من Object.

خاتمة

لقد رأينا أنّ نطاق تأثيرالكائنات يتعدى حمل خصائصها، إذ لها نماذج أولية -والتي بدورها كائنات أيضًا-، وتتصرف كما لو كان لديها خصائص ليست لديها على الحقيقة طالما أن النموذج الأولي به تلك الخصائص، كما تمكّنا من معرفة الكائنات البسيطة لها Object.prototype على أساس نموذج أولي لها.

يمكن استخدام البواني -وهي دوال تبدأ أسماؤها بحرف إنجليزي كبير- مع عامل new لإنشاء كائنات جديدة، وسيكون النموذج الأولي للكائن هو الكائن الموجود في خاصية prototype للباني، ونستطيع الاستفادة من ذلك بوضع جميع الخصائص التي تتشاركها القيم المعطاة -من النوع نفسه- في نماذجها الأولية. كذلك عرفنا صيغة class التي توفر طريقةً واضحةً لتعريف الباني ونموذجه الأولي.

تستطيع تعريف الجالبات والضابطات لاستدعاء التوابع سرًا في كل مرة يصل فيها إلى خاصية كائن ما، وقد عرفنا أن التوابع الساكنة ما هي إلا توابع مخزَّنة في باني الصنف بدلًا من نموذجه الأولي، ثم شرحنا كيف أن عامل instanceof يستطيع إخبارك إن أعطيته كائنًا وبانيًا، وما إذا كان الكائن نسخةً من الباني أم لا.

واعلم أنك تستطيع باستخدام الكائنات تحديد واجهة لها، وتخبر جميع أجزاء الشيفرة بأن عليهم التحدث إلى كائنك من خلال تلك الواجهة فقط، كما تُغلَّف بقية التفاصيل التي يتكون منها الكائن وتختفي خلف الواجهة. ويمكن لأكثر من نوع استخدام تلك الواجهة، فتعرف الشيفرة التي كُتبت لتستخدِم واجهةً ما كيف تعمل مع أي عدد من الكائنات المختلفة التي توفر الواجهة تلقائيًا ، وهذا ما يسمى بتعددية الأشكال.

حين تستخدم عدة أصناف لا تختلف فيما بينها إلا في بعض التفاصيل، فيمكن كتابة أصناف جديدة منها على أساس أصناف فرعية، ترث جزءًا من سلوكها.

تدريبات

النوع المتجهي

اكتب الصنف Vec الذي يمثل متجهًا في فضاء ثنائي الأبعاد، حيث يأخذ المعامِلين x، وy -وهما أرقام-، ويحفظهما في خصائص بالاسم نفسه.

أعطِ النموذج الأولي للصنف Vec تابعَين، هما: plus، وminus، اللذان يأخذان متجهًا آخر على أساس معامِل، ويُعيدان متجهًا جديدًا له مجموع قيم x، وy للمتجهين (this، والمعامِل)؛ أو الفرق بينهما.

أضف الخاصية الجالبة length إلى النموذج الأولي الذي يحسب طول المتجه، وهو المسافة بين النقطة (x,y) والإحداثيات الصفرية (0,0).

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

// ضع شيفرتك هنا.

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

إرشادات للحل

  • إذا لم تكن تعرف كيف تبدو تصريحات class، فانظر إلى مثال صنف Rabbit.
  • يمكن إضافة خاصية جالبة إلى الباني من خلال وضع كلمة get قبل اسم التابع، ولحساب المسافة من (0,0) إلى (x,y)، فيمكن استخدام نظرية فيثاغورث التي تقول: أن مربع المسافة التي نريدها يساوي مجموع مربعي x و y، وعلى ذلك يكون ‎√(x2 + y2)‎ هو العدد الذي نريده، ويُحسَب الجذر التربيعي في جافاسكربت باستخدام Math.sqrt.

المجموعات

توفر بيئة جافاسكربت القياسية هيكل بيانات اسمه Set، إذ يحمل مجموعةً من القيم مثل نسخة من Map، لكن على عكس Map فهو لا يربط قيمًا أخرى بها، بل يتتبع القيم ليعرف أيها تكون جزءًا من المجموعة. ولا يمكن للقيمة الواحدة أن تكون جزءًا من مجموعة ما أكثر من مرة واحدة، ولا يحدث أي تأثير حين تضاف مرةً أخرى.

اكتب صنفًا اسمه Group -بما أنّ Set مأخوذ من قبل-، واجعل له التوابع الآتية: add، وdelete، وhas، ليكون مثل Set، بحيثما ينشئ بانيه مجموعةً فارغةً، ويضيف add قيمةً إلى المجموعة فقط إن لم تكن عضوًا بالفعل في المجموعة، كما يحذف delete وسيطه من المجموعة إن كان عضوًا فيها، ويعيد has قيمةً بوليانيةً توضح هل وسيطه عضو في المجموعة أم لا.

استخدم عامِل ===، أو شيئًا يحاكيه مثل indexof، لمعرفة ما إذا كانت قيمتان متطابقين، وأعط الصنف التابع الساكن from الذي يأخذ كائنًا قابلًا للتكرار على أساس وسيط، كما ينشئ مجموعةً تحتوي على جميع القيم المنتَجة من خلال التكرار عليها.

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

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 على المصفوفة، فهذا وإن كان سيعمل بدون مشاكل، إلا أنه سينافي الهدف من التدريب.

لا بأس إن تصرَّف المكرر الخاص بك تصرفًا غير مألوف عند تعديل المجموعة أثناء التكرار.

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

// ضع شيفرتك هنا، والشيفرة التي من .المثال السابق

for (let value of Group.from(["a", "b", "c"])) {
  console.log(value);
}
// → a
// → b
// → c

إرشادات للحل

ربما من الأفضل تعريف صنف GroupIterator جديد، كما يجب أن يكون لنُسَخ المكرر خاصية تتبع الموضع الحالي في المجموعة، بحيث تتحقق في كل مرة يُستدعى فيها next مما إذا كانت قد انتهت أم لا، فإن لم تنته فستتحرك متجاوزةً القيمة الحالية وتعيدها.

يحصل الصنف Group على تابع يسمى من قِبل Symbol.iterator`، ويعيد عند استدعائه نسخةً جديدةً من صنف المكرر لتلك المجموعة.

استعارة تابع

ذكرنا أعلاه هنا أن hasOwnProperty لكائن يمكن استخدامه على أساس بديل قوي لعامِل in إذا أردت تجاهل خصائص النموذج الأولي، لكن ماذا لو كانت خارطتك map تحتاج إلى كلمة hasOwnProperty؟ لن تستطيع حينها استدعاء هذا التابع بما أن خاصية الكائن تخفي قيمة التابع.

هل تستطيع التفكير في طريقة لاستدعاء hasOwnProperty على كائن له خاصية بهذا الاسم؟ تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

let map = {one: true, two: true, hasOwnProperty: true};

// أصلح هذا الاستدعاء
console.log(map.hasOwnProperty("one"));
// → true

إرشادات للحل

تذكّر أن التوابع الموجودة في الكائنات المجردة تأتي من Object.prototype، كما تستطيع استدعاء دالة مع رابطة this خاصة من خلال استخدام التابع call.

ترجمة -بتصرف- للفصل السادس من كتاب Elequent Javascript لصاحبه Marijn Haverbeke.

اقرأ أيضًا



1 شخص أعجب بهذا


تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن