١٥ ديسمبر ٢٠٢١

الوراثة النموذجية (Prototypal inheritance)

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

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

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

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

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

When we read a property from object, and it’s missing, JavaScript automatically takes it from the prototype. In programming, such thing is called “prototypal inheritance”. And soon we’ll study many examples of such inheritance, as well as cooler language features built upon it.

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

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

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

rabbit.__proto__ = animal; // sets rabbit.[[Prototype]] = animal

Now if we read a property from rabbit, and it’s missing, JavaScript will automatically take it from 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 (from rabbit)

Now if we read something from longEar, and it’s missing, JavaScript will look for it in rabbit, and then in animal.

There are only two limitations:

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

**proto**is a historical getter/setter for[[Prototype]]

It’s a common mistake of novice developers not to know the difference between these two.

Please note that __proto__ is not the same as the internal [[Prototype]] property. It’s a getter/setter for [[Prototype]]. Later we’ll see situations where it matters, for now let’s just keep it in mind, as we build our understanding of JavaScript language.

The __proto__ property is a bit outdated. It exists for historical reasons, modern JavaScript suggests that we should use Object.getPrototypeOf/Object.setPrototypeOf functions instead that get/set the prototype. We’ll also cover these functions later.

By the specification, __proto__ must only be supported by browsers. In fact though, all environments including server-side support __proto__, so we’re quite safe using it.

As the __proto__ notation is a bit more intuitively obvious, we use it in the examples.

Writing doesn’t use 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 مباشرةً وتُنفّذه دون استعمال كائن النموذج الأولي:

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

لهذا السبب نرى الخاصية 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'; // (**)

alert(admin.fullName); // Alice Cooper, state of admin modified
alert(user.fullName); // John Smith, state of user protected

هنا في السطر (*) نرى أن 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); // غير معرّف (لا يوجد خاصية معرفة في كائن النموذج الأولي بهذا الأسم)‫

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

لو كانت هناك كائنات أخرى (مثل الطيور 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, then 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.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 على خاصيات الكائن والخاصيات الموروثة، بينما لا تعمل توابِع جلب المفاتيح/القيم إلّا على الكائن نفسه.

مهمه

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

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

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, إذ ليس هناك خاصية بهذا الاسم بعد الآن.

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

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

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، وفي المرة التالية ستبحث هناك مباشرة. كما أنّها ذكية لتُحدّث ذاكرتها الداخلية ما إن يتغيّر شيء ما لذا فإن الأداء الأمثل في أمان.

لدينا الكائن 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.

لدينا هامسترين، واحد سريع 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 ); // <nothing>

الآن يعمل كلّ شيء كما يجب، إذ لا تبحث عملية الإسناد 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 ); // <nothing>

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

خريطة الدورة التعليمية