۱۲ فوریه ۲۰۲۳

وراثت پروتوتایپی

در برنامه‌نویسی، اغلب اوقات ما می‌خواهیم که چیزی را دریافت کنیم و آن را گسترش دهیم.

برای مثال، ما یک شیء user همراه با ویژگی‌ها و متدهای آن داریم و می‌خواهیم admin و guest را به عنوان نمونه‌هایی از آن که تغییر کمی دارند بسازیم. ما می‌خواهیم چیزی را که در user داریم را دوباره استفاده کنیم، نه اینکه متدهای آن را کپی/دوباره پیاده‌سازی کنیم، فقط یک شیء جدید را بر اساس آن بسازیم.

وراثت پروتوتایپی(prototypal inheritance) یک ویژگی زبان است که به این موضوع کمک می‌کند.

ویژگی [[Prototype]]

در جاوااسکریپت، شیءها یک ویژگی پنهانی [[Prototype]] (دقیقا همانطور که در مشخصات زبان نام‌گذاری شده) دارند که یا null است یا به شیء دیگر رجوع می‌کند. آن شیء «یک پروتوتایپ (prototype)» نامیده می‌شود:

زمانی که ما یک شیء را از object می‌خوانیم و وجود ندارد، جاوااسکریپت به طور خودکار آن را از پروتوتایپ دریافت می‌کند. در برنامه‌نویسی، به این کار «وراثت پروتوتایپی» می‌گویند. و به زودی ما مثال‌های زیادی از چنین وراثتی را خواهیم دید، درست مانند خصوصیت‌های خفن‌تر زبان که بر اساس آن ساخته شده‌اند.

ویژگی [[Prototype]] درونی و پنهان است اما راه‌هایی برای مقداردهی آن وجود دارد.

یکی از آن راه‌ها استفاده از نام خاص __proto__ است، مثلا اینگونه:

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

rabbit.__proto__ = animal; // را تنظیم می‌کند rabbit.[[Prototype]] = animal

حالا اگر ما ویژگی‌ای را از rabbit بخوانیم و وجود نداشته باشد، جاوااسکریپت به طور خودکار آن را از animal دریافت می‌کند.

برای مثال:

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

rabbit.__proto__ = animal; // (*)

// پیدا کنیم rabbit حالا می‌توانیم هر دو ویژگی را در
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

اینجا خط (*) شیء animal را به عنوان پروتوتایپ rabbit تنظیم می‌کند.

سپس زمانی که alert سعی می‌کند تا ویژگی rabbit.eats (**) را بخواند، درون rabbit نیست پس جاوااسکریپت مرجع [[Prototype]] را دنبال می‌کند و ویژگی را درون animal پیدا می‌کند (از پایین به بالا نگاه کنید):

اینجا می‌توانیم بگوییم که “animal” پروتوتایپ rabbit است یا “rabbit” به صورت پروتوتایپی از animal ارث‌بری کرده است.

بنابراین اگر animal تعداد زیادی ویژگی و متد مفید داشته باشد، سپس آن‌ها به طور خودکار درون rabbit هم موجود می‌شوند. چنین ویژگی‌هایی را «موروث یا به ارث‌رسیده» می‌گویند.

اگر ما یک متد درون animal داشته باشیم، می‌تواند با rabbit هم فراخوانی شود:

let animal = {
  eats: true,
  walk() {
    alert("جانور راه می‌رود");
  }
};

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

// از پروتوتایپ برداشته شده است walk
rabbit.walk(); // جانور راه می‌رود

متد به طور خودکار از پروتوتایپ دریافت می‌شود، به این صورت:

زنجیره‌ی پروتوتایپ می‌تواند طولانی‌تر باشد:

let animal = {
  eats: true,
  walk() {
    alert("جانور راه می‌رود");
  }
};

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

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// از رنجیره‌ی پروتوتایپ برداشته شده است walk
longEar.walk(); // جانور راه می‌رود
alert(longEar.jumps); // true (rabbit از)

حالا اگر ما چیزی را از longEar بخوانیم و وجود نداشته باشد، جاوااسکریپت درون rabbit و سپس درون animal به دنبال آن می‌گردد.

فقط دو محدودیت وجود دارد:

  1. مرجع‌ها نمی‌توانند درون چرخه قرار بگیرند. اگر ما تلاش کنیم که __proto__ را درون یک حلقه مقداردهی کنیم، جاوااسکریپت ارور ایجاد می‌کند.
  2. مقدار __proto__ می‌تواند شیء یا null باشد. انواع دیگر داده نادیده گرفته می‌شوند.

همچنین ممکن است واضح باشد اما باز هم: فقط یک [[Prototype]] می‌تواند وجود داشته باشد. یک شیء نمی‌تواند از دو شیء دیگر ارث‌بری کند.

ویژگی __proto__ یک getter/setter قدیمی برای [[Prototype]] است

این یک اشتباه توسعه‌دهندگان تازه‌وارد است که تفاوت میان این دو را ندانند.

لطفا توجه کنید که __proto__ با ویژگی درونی [[Prototype]] یکسان نیست. این ویژگی یک getter/setter برای [[Prototype]] است. بعدا ما موقعیت‌هایی را خواهیم دید که این موضوع اهمیت دارد، اما چون فهم خود را از زبان جاوااسکریپت می‌سازیم، بیایید فقط این را در ذهن خود داشته باشیم.

ویژگی __proto__ کمی منسوخ شده است. بنا به دلایلی مربوط به گذشته هنوز وجود دارد، جاوااسکریپت مدرن پیشنهاد می‌کند که ما باید از تابع‌های Object.getPrototypeOf/Object.setPrototypeOf به جای آن دریافت/مقداردهی کردن پروتوتایپ استفاده کنیم. این تابع‌ها را هم در آینده پوشش می‌دهیم.

بر اساس مشخصات زبان، __proto__ فقط باید توسط مرورگرها پشتیبانی شود. اگرچه در واقع تمام محیط‌ها شامل سمت سرور از __proto__ پشتیبانی می‌کند، پس ما برای استفاده از آن به مشکلی بر نخواهیم خورد.

به دلیل اینکه نشان __proto__ از لحاظ درک کردن کمی بیشتر واضح است، در مثال‌ها از آن استفاده می‌کنیم.

نوشتن از پروتوتایپ استفاده نمی‌کند

پروتوتایپ فقط برای خواندن ویژگی‌ها استفاده می‌شود.

عمل‌های نوشتن/حذف کردن به صورت مستقیم با شیء کار می‌کنند.

در مثال پایین، ما متد walk را در خود rabbit مقداردهی می‌کنیم:

let animal = {
  eats: true,
  walk() {
    /* استفاده نخواهد شد rabbit این متد توسط */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("خرگوش! بپر-بپر!");
};

rabbit.walk(); // !خرگوش! بپر-بپر

از این پس، فراخوانی rabbit.walk() بدون اینکه از پروتوتایپ استفاده کند، بلافاصله متد را در شیء پیدا و آن را اجرا می‌کند:

ویژگی‌های اکسسر استثنا هستند، مقداردهی توسط تابع setter انجام می‌شود. پس نوشتن در چنین ویژگی‌ای در واقع با فراخوانی تابع یکسان است.

به همین دلیل 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 (*)

// !فعال می‌شود setter
admin.fullName = "Alice Cooper"; // (**)

alert(admin.fullName); // Alice Cooper ،تغییر یافت admin وضعیت
alert(user.fullName); // John Smith ،حفظ شد user وضعیت

اینجا در خط (*) ویژگی admin.fullName در پروتوتایپ user دارای یک getter است، پس این تابع فراخوانی می‌شود. و در خط (**) ویژگی در پروتوتایپ دارای یک setter است پس این تابع فراخوانی می‌شود.

مقدار “this”

در مثال بالا ممکن است یک مثال جالب مطرح شود: مقدار this درون set fullName(value) چیست؟ ویژگی‌های this.name و this.surname در کجا نوشته می‌شوند: درون user یا admin؟

جواب ساده است: پروتوتایپ‌ها بر روی this هیچ تاثیری ندارند.

مهم نیست که متد کجا پیدا شده است: درون شیء یا پروتوتایپ آن. در فراخوانی یک متد، this همیشه برابر با شیء قبل از نقطه است.

پس فراخوانی setter admin.fullName= از admin به عنوان this استفاده می‌کند نه user.

در واقع این یک موضوع بسیار مهم است چون ما ممکن است شیءای بزرگ با متدهایی زیاد و شیءهایی که از آن ارث‌بری می‌کنند داشته باشیم. و زمانی که شیءهای وارث از متدهای به ارث‌برده‌شده استفاده می‌کنند، آن‌ها فقط وضعیت خودشان را تغییر می‌دهند نه وضعیت شیء بزرگ را.

برای مثال، اینجا animal نشان دهنده یک «حافظه متد» است و rabbit از آن استفاده می‌کند.

فراخوانی rabbit.sleep() ویژگی this.isSleeping را در شیء rabbit مقداردهی می‌کند:

// متدهایی دارد animal
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); // undefined (چنین ویژگی‌ای درون پروتوتایپ نیست)

تصویر حاصل:

اگر ما شیءهای دیگری هم داشتیم، مثل bird، snake و غیره، که از animal ارث‌بری می‌کردند، آن‌ها هم به متدهای animal دسترسی پیدا می‌کردند. اما this در هر فراخوانی متد، شیء متناظر خواهد بود، که در زمان فراخوانی ارزیابی می‌شود (قبل از نقطه)، نه animal. پس زمانی که ما درون 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); // eats سپس ،jumps

اگر این چیزی نیست که ما می‌خواهیم و دوست داریم که شامل ویژگی‌های به ارث‌برده‌شده نشود، یک متد درون‌ساخت obj.hasOwnProperty(key) وجود دارد: این متد اگر obj ویژگی خودش (نه به ارث‌برده‌شده) به نام key را داشته باشد true برمی‌گرداند.

پس می‌تواند ویژگی‌های به ارث‌برده‌شده را جداسازی کنیم (یا کاری دیگر با آن‌ها کنیم):

let animal = {
  eats: true
};

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

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

  if (isOwn) {
    alert(`برای ما: ${prop}`); // jumps :برای ما
  } else {
    alert(`به ارث‌برده‌شده: ${prop}`); // eats :به ارث‌برده‌شده
  }
}

اینجا ما زنجیره ارث‌بری پایین را داریم: rabbit از animal ارث‌بری می‌کند که خود آن از Object.prototype ارث‌بری می‌کند (چون animal یک شیء لیترال {...} است، پس این موضوع پیش‌فرض انجام می‌شود) و سپس null در بالای آن:

در نظر داشته باشید که یک موضوع جالب وجود دارد. متد rabbit.hasOwnProperty از کجا می‌آید؟ ما آن را تعریف نکردیم. با نگاه به زنجیره می‌توانیم ببینیم که متد توسط Object.prototype.hasOwnProperty فراهم شده. به عبارتی دیگر، به ارث برده شده است.

…اما اگر for..in ویژگی‌های به ارث‌برده‌شده را لیست می‌کند، چرا hasOwnProperty مثل eats و jumps که در حلقه for..in ظاهر شدند، عمل نکرد؟

جواب ساده است: این ویژگی غیر قابل شمارش است. درست ماند تمام ویژگی‌های دیگر Object.prototype، این ویژگی پرچم enumerable: false دارد. و for..in فقط ویژگی‌های قابل شمارش را لیست می‌کند. به همین دلیل این ویژگی و دیگر ویژگی‌های Object.prototype لیست نشده‌اند.

تقریبا تمام متدهای دریافت کلید/مقدار دیگر هم ویژگی‌های به ارث‌برده‌شده را نادیده می‌گیرند

تقریبا تمام متدهای دریافت کلید/مقدار دیگر، مانند Object.keys، Object.values و بقیه، ویژگی‌های به ارث‌برده‌شده را نادیده می‌گیرند.

آن‌ها فقط روی خود شیء کارشان را انجام می‌دهند. ویژگی‌های پروتوتایپ به حساب نمی‌آیند.

خلاصه

  • در جاوااسکریپت، تمام شیءها یک ویژگی پنهان [[Prototype]] دارند که یا برابر با شیء است یا null.
  • ما می‌توانیم از obj.__proto__ برای دسترسی به آن استفاده کنیم (یک getter/setter قدیمی، راه‌های دیگری هم وجود دارد که به زودی پوشش داده می‌شوند).
  • شیءای که توسط [[Prototype]] به آن رجوع می‌شود «پروتوتایپ (prototype)» نام دارد.
  • اگر ما بخواهیم ویژگی‌ای از obj را بخوانیم یا متدی از آن را فراخوانی کنیم و وجود نداشته باشد، سپس جاوااسکریپت سعی می‌کند که آن را درون پروتوتایپ پیدا کند.
  • عملیات نوشتن/حذف کردن به طور مستقیم روی شیء انجام می‌شوند، آن‌ها از پروتوتایپ استفاده نمی‌کنند (با فرض اینکه یک ویژگی داده‌ای است، نه یک setter).
  • اگر ما obj.method() را فراخوانی کنیم و method از پروتوتایپ گرفته شود، this هنوز هم به obj رجوع می‌کند. پس متدها همیشه با شیء کنونی کار می‌کنند حتی اگر آن‌ها به ارث‌برده‌شده باشند.
  • حلقه for..in هم درون ویژگی‌های خود شیء و هم درون ویژگی‌های به ارث‌برده‌شده حلقه می‌زند. تمام متدهای گرفتن کلید/مقدار فقط روی خود شیء کارشان را انجام می‌دهند.

تمارین

اهمیت: 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)

باید 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__ برای مقداردهی پروتوتایپ‌ها استفاده کنید به طوری که جست و جوی هر ویژگی این مسیر را دنبال کند: pocketsbedtablehead. برای مثال، 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 را تغییر می‌دهد.

جست و جوی ویژگی و اجرا کردن دو موضوع جدا هستند.

متد 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("سیب");
alert( speedy.stomach ); // سیب

// این همستر هم غذا را دارد، چرا؟ لطفا این را درست کنید.
alert( lazy.stomach ); // سیب

بیایید با دقت نگاه کنیم که در فراخوانی speedy.eat("سیب") چه اتفاقی می‌افتد.

  1. متد speedy.eat درون پروتوتایپ (=hamster) پیدا شده، سپس با this=speedy اجرا می‌شود (شیء قبل از نقطه).

  2. سپس this.stomach.push() باید ویژگی stomach را پیدا کند و push را روی آن فراخوانی کند. به نظر می‌رسد این متد درون this (=speedy) به دنبال stomach می‌گردد، اما چیزی پیدا نشد.

  3. سپس زنجیره پروتوتایپ را دنبال می‌کند و stomach را درون hamster پیدا می‌کند.

  4. سپس push را روی آن فراخوانی می‌کند، که غذا را به stomach درون پروتوتایپ اضافه می‌کند.

پس تمام همسترها شکم (stomach) یکسانی را به اشتراک می‌گذارند!

هم برای lazy.stomach.push(...) و speedy.stomach.push()، ویژگی stomach درون پروتوتایپ پیدا شده است (چون درون خود شیء وجود ندارد)، سپس داده جدید به داخل آن فرستاده می‌شود.

لطفا توجه کنید که در صورت وجود یک مقداردهی ساده this.stomach= چنین چیزی اتفاق نمی‌افتد:

let hamster = {
  stomach: [],

  eat(food) {
    // this.stomach.push به جای this.stomach برابر قرار دادن با
    this.stomach = [food];
  }
};

let speedy = {
   __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// غذا را پیدا کرد Speedy همستر
speedy.eat("سیب");
alert( speedy.stomach ); // سیب

// خالی است Lazy شکم همستر
alert( lazy.stomach ); // <هیچی>

حالا همه چیز به درستی کار می‌کند، چون this.stomach= در جست و جوی stomach نیست. مقدار به صورت مستقیم درون شیء this نوشته می‌شود.

همچنین می‌توانیم با اطمینان از اینکه هر همستر stomach خودش را دارا می‌باشد از این مشکل جلوگیری کنیم:

let hamster = {
  stomach: [],

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

let speedy = {
  __proto__: hamster,
  stomach: []
};

let lazy = {
  __proto__: hamster,
  stomach: []
};

// غذا را پیدا کرد Speedy همستر
speedy.eat("سیب");
alert( speedy.stomach ); // سیب

// خالی است Lazy شکم همستر
alert( lazy.stomach ); // <هیچی>

به عنوان یک راه‌حل عام، تمام ویژگی‌هایی که وضعیت یک شیء خاص را توصیف می‌کنند، مانند stomach بالا، باید درون همان شیء نوشته شوند. این کار از بروز مشکلات جلوگیری می‌کند.

نقشه آموزش