دليل تعلم جافاسكربت الوراثة النموذجية (Prototypal inheritance) في جافاسكربت، الجزء الأول


صفا الفليج

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

فمثلًا لدينا كائن مستخدم user له خاصيات وتوابِع، وأردنا إنشاء نسخ عنه (مدراء admin وضيوف guest) لكن معدّلة قليلًا. سيكون رائعًا لو أعدنا استعمال الموجود في كائن المستخدم بدل نسخه أو إعادة كتابة توابِعه، سيكون رائعًا لو صنعنا كائنًا جديدًا فوق كائن user.

الوراثة النموذجية (تدعى أيضًا الوراثة عبر كائن النموذج الأولي prototype)* هي الميزة الّتي تساعدنا في تحقيق هذا الأمر.

الخاصية [[Prototype]]

لكائنات جافاسكربت خاصية مخفية أخرى باسم [[Prototype]] (هذا اسمها في المواصفات القياسية للغة جافاسكربت)، وهي إمّا أن تكون null أو أن تشير إلى كائن آخر. نسمّي هذا الكائن بِـ”prototype“ (نموذج أولي).

object-prototype-empty.png

إن كائن النموذج الأولي ”سحريٌ“ إن صحّ القول، فحين نريد قراءة خاصية من كائن object ولا يجدها محرّك جافاسكربت، يأخذها تلقائيًا من كائن النموذج الأولي لذاك الكائن. يُسمّى هذا في علم البرمجة ”بالوراثة النموذجية“ (‏Prototypal inheritance)، وهناك العديد من المزايا الرائعة في اللغة وفي التقنيات البرمجية مبنية عليها.

الخاصية [[Prototype]] هي خاصية داخلية ومخفية، إلّا أنّ هناك طُرق عديدة لنراها. ‎
إحداها استعمال __proto__ هكذا:

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // هنا

__proto__ هو الجالب والضابط القديم للخاصية [[Prototype]] كانت تستخدم قديمًا، ولكن في اللغة الحديثة استبدلت بالدالتين Object.getPrototypeOf/Object.setPrototypeOf وهي أيضًا تعمل عمل الجالب والضابط للنموذج الأولي (سندرس هذه الدوالّ لاحقًا في هذا الدرس).

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

فمثلاً لو بحثنا الآن عن خاصية ما في كائن rabbit ولم تكُ موجودة، ستأخذها لغة جافاسكربت تلقائيًا من كائن animal.

مثال على ذلك:

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // (*)

// الآن كلتا الخاصيتين في الأرنب:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

هنا نضبط (في السطر (*)) كائن animal ليكون النموذج الأولي (Prototype) للكائن rabbit.

بعدها متى ما حاولت التعليمة alert قراءة الخاصية rabbit.eats (انظر (**))، ولم يجدها في كائن rabbit ستتبع لغة جافاسكربت الخاصية [[Prototype]] لمعرفة ما هو كائن النموذج الأولي لكائن rabbit، وسيجده كائن animal (البحث من أسفل إلى أعلى):

proto-animal-rabbit.png

يمكن أن نقول هنا بأنّ الكائن animal هو النموذج الأولي للكائن rabbit، أو كائن rabbit هو نسخة نموذجية من الكائن animal.

وبهذا لو كان للكائن animal خاصيات وتوابِع كثيرة مفيدة، تصير مباشرةً موجودة عند كائن rabbit. نسمّي هذه الخاصيات بأنّها ”موروثة“.

لو كان للكائن animal تابِعًا فيمكننا استدعائه في كائن rabbit:

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// نأخذ ‫ walk من كائن النموذج الأولي
rabbit.walk(); // Animal walk

يُؤخذ التابِع تلقائيًا من كائن النموذج الأولي، هكذا:

proto-animal-rabbit-walk.png

يمكن أيضًا أن تكون سلسلة الوراثة النموذجية (النموذج الأولي) أطول:

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal // (*)
};

let longEar = {
  earLength: 10,
  __proto__: rabbit // (*)
};

// نأخذ الدالّة ‫walk من سلسلة الوراثة النموذجية
longEar.walk(); // Animal walk
alert(longEar.jumps); // true (من rabbit)

 

proto-animal-rabbit-chain.png

ولكن، هناك مُحددان للوراثة النموذجية وهما:

  1. لا يمكن أن تكون سلسلة الوراثة النموذجية دائرية (على شكل حلقة). ما إن تُسند __proto__ بطريقة دائرية فسترمي لغة جافاسكربت خطأً.
  2. يمكن أن تكون قيمة __proto__ إمّا كائنًا أو null، وتتجاهل لغة جافاسكربت الأنواع الأخرى.

ومن الواضح جليًا أيضًا أي كائن سيرث كائن [[Prototype]] واحد وواحد فقط، لا يمكن للكائن وراثة كائنين.

كائن النموذج الأولي للقراءة فقط

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

في المثال أسفله نُسند التابِع walk إلى الكائن rabbit:

let animal = {
  eats: true,
  walk() {
    /* لن يستعمل الكائن‫ `rabbit` هذا التابِع */  
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("Rabbit! Bounce-bounce!");
};

rabbit.walk(); // Rabbit! Bounce-bounce!

من الآن فصاعدًا فستجد استدعاء التابع rabbit.walk()‎ سيكون من داخل كائن rabbit مباشرةً وتُنفّذه دون استعمال كائن النموذج الأولي:

proto-animal-rabbit-walk-2.png

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

لهذا السبب نرى الخاصية admin.fullName في الشيفرة أسفله تعمل كما ينبغي لها:

let user = {
  name: "John",
  surname: "Smith",

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  },

  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

let admin = {
  __proto__: user,
  isAdmin: true
};

alert(admin.fullName); // John Smith (*)

// عمل الضابِط!
admin.fullName = "Alice Cooper"; // (**)

هنا في السطر (*) نرى أن admin.fullName استدعت الجالِب داخل الكائن user، ولهذا استُدعيت الخاصية. وفي السطر (**) نرى عملية إسناد للخاصية admin.fullName ولهذا استدعيَ الضابِط داخل الكائن user.

ماذا عن "this"؟

بعدما تتمعّن في المثال أعلاه، يمكن أن تتساءل ما قيمة this داخل set fullName(value)‎؟ أين كُتبت القيم الجديدة this.name و this.surname؟ داخل الكائن user أم داخل الكائن admin؟

جواب هذا السؤال المحيّر بسيط: لا تؤثّر كائنات النموذج الأولي على قيمة this.

أينما كان التابِع موجودًا أكان في الكائن أو في كائن النموذج الأولي، سيكون تأثير this على الكائن الّذي قبل النقطة (الكائن المستدعى من خلاله هذه الخاصية) دائمًا وأبدًا.

لهذا فالضابِط الّذي يستدعي admin.fullName=‎ يستعمل كائن admin عوضًا عن this وليس الكائن user.

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

فمثلًا هنا، يمثّل كائن animal ”مخزّنَ توابِع“ وكائن rabbit يستغلّ هذا المخزن.

فاستدعاء rabbit.sleep()‎ يضبط this.isSleeping على كائن rabbit:

// للحيوان توابِع
let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`I walk`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "White Rabbit",
  __proto__: animal
};

// يعدّل rabbit.isSleeping
rabbit.sleep();

alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // غير معرّف (لا يوجد خاصية معرفة في كائن النموذج الأولي بهذا الأسم)‫ 

الصورة الناتجة:

proto-animal-rabbit-walk-3.png

لو كانت هناك كائنات أخرى (مثل الطيور bird والأفاعي snake وغيرها) ترث الكائنanimal، فسيمكنها الوصول إلى توابِع الكائن animal، إلّا أنّ قيمة this في كلّ استدعاء للتوابِع سيكون على الكائن الّذي استُدعيت منه، وستعرِفه لغة جافاسكربت أثناء الاستدعاء (أي سيكون الكائن الّذي قبل النقطة) ولن يكون animal. لذا متى كتبنا البيانات من خلال this، فستُخزّن في تلك الكائنات الّتي استدعيت عليها this.

وبهذا نخلص إلى أنّ التوابِع مشتركة، ولكن حالة الكائن ليست مشتركة.

حلقة for..in

كما أنّ حلقة for..in تَمرُّ على الخاصيات الموروثة هي الأخرى.

مثال:

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// يُعيد التابع ‫Object.keys خصائص الكائن نفسه فقط
alert(Object.keys(rabbit)); // jumps

*!*
// تدور حلقة‫ for..in على خصائص الكائن نفسه والخصائص الموروثة معًا
for(let prop in rabbit) alert(prop); // jumps ثمّ eats
*/!*

لو لم تكن هذه النتيجة ما نريد (أي نريد استثناء الخاصيات الموروثة)، فيمكن استعمال التابِع obj.hasOwnProperty(key) المضمّن في اللغة: إذ يُعيد true لو كان للكائن obj نفسه (وليس للموروث منه) خاصية بالاسم key.

بهذا يمكننا ترشيح الخاصيات الموروثة (ونتعامل معها على حدة):

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop);

  if (isOwn) {
    alert(`Our: ${prop}`); // تخصّنا:‫ jumps
  } else {
    alert(`Inherited: ${prop}`); // ورثناها: ‫eats
  }
}

هنا نرى سلسلة الوراثة الآتية: يرث كائن rabbit كائنَ animal، والّذي يرثه هكذا Object.prototype (إذ أنّه كائن مجرّد {...}، وهذا السلوك المبدئي)، وبعدها يرث null:

rabbit-animal-object.png

ملاحظة لطيفة في هذا السياق وهي: من أين أتى التابِع rabbit.hasOwnProperty؟ لم نعرّفه يدويًا! لو تتبّعناه في السلسلة لرأينا بأنّ كائن النموذج الأولي Object.prototype.hasOwnProperty هو من قدّم التابِع، أي بعبارة أخرى، ورث كائن rabbit هذا التابِع من كائن النموذج الأولي.

ولكن لحظة… لماذا لم يظهر تابع hasOwnProperty في حلقة for..in كما ظهرت eats و jumps طالما تُظهر حلقات for..in الخاصيات الموروثة؟

الإجابة هنا بسيطة أيضًا: لإنه مُنع من قابلية العدّ (من خلال إسناده لقيمة الراية enumerable:false). في النهاية هي مِثل غيرها من الخاصيات في Object.prototype- تملك الراية enumerable:false، وحلقة for..in لا تمرّ إلّا على الخاصيات القابلة للعدّ. لهذا السبب لم نراها لا هي ولا خاصيات Object.prototype الأخرى.

كلّ التوابِع الّتي تجلب المفتاح/القيمة تُهمل الخاصيات الموروثة، تقريبًا مثل تابِع Object.keys أو تابِع Object.values وما شابههم. إذ إنهم يتعاملون مع خصائص الكائن نفسه ولا يأخذون بعين الاعتبار الخصائص الموروثة

خلاصة‎

  • لكلّ كائنات جافاسكربت خاصية [[Prototype]] مخفية قيمتها إمّا أحد الكائنات أو null.
  • يمكننا استعمال obj.__proto__‎ للوصول إلى هذه الخاصية (وهي خاصية جالِب/ضابِطة). هناك طرق أخرى سنراها لاحقًا.
  • الكائن الّذي تُشير إليه الخاصية [[Prototype]] يسمّى كائن النموذج الأولي.
  • لو أردنا قراءة خاصية داخل كائن ما obj أو استدعاء تابِع، ولم تكن موجودة/يكن موجودًا، فسيحاول محرّك جافاسكربت البحث عنه/عنها في كائن النموذج الأولي.
  • عمليات الكتابة والحذف تتطبّق مباشرة على الكائن المُستدعي ولا تستعمل كائن النموذج الأولي (إذ يعدّ أنّها خاصية بيانات وليست ضابِطًا).
  • لو استدعينا التابِع ‎obj.method()‎‏ وأخذ المحرّك التابِع method من كائن النموذج الأولي، فلن تتغير إشارة this وسيُشير إلى obj، أي أنّ التوابِع تعمل على الكائن الحالي حتّى لو كانت التوابِع نفسها موروثة.
  • تمرّ حلقة for..in على خاصيات الكائن والخاصيات الموروثة، بينما لا تعمل توابِع جلب المفاتيح/القيم إلّا على الكائن نفسه.

تمارين

العمل مع prototype

الأهمية: 5

إليك شيفرة تُنشئ كائنين وتعدّلها.

ما القيم الّتي ستظهر في هذه العملية؟

let animal = {
  jumps: null
};
let rabbit = {
  __proto__: animal,
  jumps: true
};

alert( rabbit.jumps ); // ? (1)

delete rabbit.jumps;

alert( rabbit.jumps ); // ? (2)

delete animal.jumps;

alert( rabbit.jumps ); // ? (3)

يجب أن هنالك ثلاث إجابات.

الحل

  1. true، تأتي من rabbit.
  2. null، تأتي من animal.
  3. undefined، إذ ليس هناك خاصية بهذا الاسم بعد الآن.

خوارزمية بحث

الأهمية: 5

ينقسم هذا التمرين إلى قسمين.

لديك الكائنات التالية:

let head = {
  glasses: 1
};

let table = {
  pen: 3
};

let bed = {
  sheet: 1,
  pillow: 2
};

let pockets = {
  money: 2000
};
  1. استعمل __proto__ لإسناد كائنات النموذج الأولي بحيث يكون البحث عن الخاصيات بهذه الطريقة: pockets ثمّ bed ثمّ table ثمّ head (من الأسفل إلى الأعلى على التتالي). فمثلًا، قيمة pockets.pen تكون 3 (من table)، وقيمة bed.glasses تكون 1 (من head).
  2. أجِب عن هذا السؤال: ما الأسرع، أن نجلب glasses هكذا pockets.glasses أم هكذا head.glasses؟ قِس أداء كلّ واحدة لو لزم.

الحل

  1. لنُضيف خاصيات __proto__:

    let head = {
      glasses: 1
    };
    
    let table = {
      pen: 3,
      __proto__: head
    };
    
    let bed = {
      sheet: 1,
      pillow: 2,
      __proto__: table
    };
    
    let pockets = {
      money: 2000,
      __proto__: bed
    };
    
    alert( pockets.pen ); // 3
    alert( bed.glasses ); // 1
    alert( table.money ); // undefined

     

  2. حين نتكلّم عن المحرّكات الحديثة، فليس هناك فرق (من ناحية الأداء) لو أخذنا الخاصية من الكائن أو من النموذج الأولي، فهي تتذكّر مكان الخاصية وتُعيد استعمالها عند طلبها ثانيةً.

فمثلًا ستتذكّر التعليمة pockets.glasses بأنّها وجدت glasses في كائن head، وفي المرة التالية ستبحث هناك مباشرة. كما أنّها ذكية لتُحدّث ذاكرتها الداخلية ما إن يتغيّر شيء ما لذا فإن الأداء الأمثل في أمان.

أين سيحدث التعديل؟

الأهمية: 5

لدينا الكائن rabbit يرث من الكائن animal.

لو استدعينا rabbit.eat()‎ فأيّ الكائنين ستُعدل به الخاصية full، الكائن animal أم الكائن rabbit؟

let animal = {
  eat() {
    this.full = true;
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.eat();

الحل

الإجابة هي: الكائن rabbit.

لأنّ قيمة this هي الكائن قبل النقطة، بذلك يُعدّل rabbit.eat()‎.

عملية البحث عن الخاصيات تختلف تمامًا عن عملية تنفيذ تلك الخاصيات.

نجد التابِع rabbit.eat سيُستدعى أولًا من كائن النموذج الأولي، وبعدها نُنفّذه على أنّ this=rabbit.

لماذا أصابت التخمة كِلا الهامسترين؟

الأهمية: 5

لدينا هامسترين، واحد سريع speedy وآخر كسول lazy، والاثنين يرثان كائن الهامستر العمومي hamster.

حين نُعطي أحدهما الطعام، نجد الآخر أُتخم أيضًا. لماذا ذلك؟ كيف نُصلح المشكلة؟

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// وجد هذا الهامستر الطعامَ قبل الآخر
speedy.eat("apple");
alert( speedy.stomach ); // apple

// هذا أيضًا وجده. لماذا؟ أصلِح الشيفرة.
alert( lazy.stomach ); // apple

الحل

لنرى ما يحدث داخل الاستدعاء ‎speedy.eat("apple")‏ بدقّة.

  1. نجد التابِع speedy.eat في كائن النموذج الأولي الهامستر (=hamster)، وبعدها ننفّذه بقيمة this=speedy (الكائن قبل النقطة).

  2. بعدها تأتي مهمة البحث للتابِع this.stomach.push()‎ ليجد خاصية المعدة stomach ويستدعي عليها push. يبدأ البحث عن stomach في this (أي في speedy)، ولكنّه لا يجد شيئًا.

  3. بعدها يتبع سلسلة الوراثة ويجد المعدة stomach في hamster.

  4. ثمّ يستدعي push عليها ويذهب الطعام في معدة النموذج الأولي.

بهذا تتشارك الهامسترات كلها معدةً واحدة!

أكان lazy.stomach.push(...)‎ أم speedy.stomach.push()‎، لا نجد خاصية المعدة stomach إلّا في كائن النموذج الأولي (إذ ليست موجودة في الكائن نفسه)، بذلك ندفع البيانات الجديدة إلى كائن النموذج الأولي.

لاحظ كيف أنّ هذا لا يحدث لو استعملنا طريقة الإسناد البسيط this.stomach=‎:

let hamster = {
  stomach: [],

  eat(food) {
    // نُسند إلى this.stomach بدلًا من this.stomach.push
    this.stomach = [food];
  }
};

let speedy = {
   __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// وجد الهامستر السريع الطعام
speedy.eat("apple");
alert( speedy.stomach ); // apple

// معدة ذاك الكسول فارغة
alert( lazy.stomach ); // <لا شيء>

الآن يعمل كلّ شيء كما يجب، إذ لا تبحث عملية الإسناد this.stomach=‎ عن خاصية stomach، بل تكتبها مباشرةً في كائن الهامستر الّذي وجد الطعام (المستدعى قبل النقطة).

ويمكننا تجنّب هذه المشكلة من الأساس بتخصيص معدة لكلّ هامستر (كما الطبيعي):

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster,
  stomach: [] // هنا
};

let lazy = {
  __proto__: hamster,
  stomach: [] // هنا
};

// وجد الهامستر السريع الطعام
speedy.eat("apple");
alert( speedy.stomach ); // apple

// معدة ذاك الكسول فارغة
alert( lazy.stomach ); // <لا شيء>

يكون الحلّ العام هو أن تُكتب الخاصيات كلّها الّتي تصف حالة الكائن المحدّد ذاته (مثل stomach أعلاه) - أن تُكتب في الكائن ذاته، وبهذا نتجنّب مشاكل تشارك المعدة.

ترجمة -وبتصرف- للفصل Prototypal inheritance من كتاب The JavaScript language





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


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



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

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

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


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

تسجيل الدخول

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


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