وراثت کلاس (class inheritance) راهی برای یک کلاس است تا کلاس دیگری را تعمیم دهد (extend).
پس میتوانیم عملکرد جدیدی را بر اساس کلاس موجود بسازیم.
کلمه کلیدی “extends”
فرض کنیم ما کلاس Animal (به معنی حیوان) را داریم:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} با سرعت ${this.speed} میدود.`);
}
stop() {
this.speed = 0;
alert(`${this.name} ایستاده است.`);
}
}
let animal = new Animal("جانور من");
ما میتوانیم شیء animal و کلاس Animal را اینگونه به صورت هندسی نمایش دهیم:
…و ما میخواهیم که class Rabbit دیگری هم بسازیم.
چون خرگوشها هم جانور هستند، کلاس Rabbit (به معنی خرگوش) باید بر اساس Animal باشد و به متدهای جانور دسترسی داشته باشد تا خرگوشها بتوانند کاری که جانوران “معمولی” انجام میدهند را انجام دهند.
سینتکس تعمیم دادن کلاس اینگونه است: class Child extends Parent.
بیایید کلاس class Rabbit را بسازیم که از Animal ارثبری میکند:
class Rabbit extends Animal {
hide() {
alert(`${this.name} قایم میشود!`);
}
}
let rabbit = new Rabbit("خرگوش سفید");
rabbit.run(5); // خرگوش سفید با سرعت 5 میدود
rabbit.hide(); // !خرگوش سفید قایم میشود
شیء ساخته شده از کلاس Rabbit هم به متدهای Rabbit، مانند rabbit.hide()، دسترسی دارد و هم به متدهای Animal، مانند rabbit.run().
از دورن، کلمه کلیدی extends با استفاده از مکانیزمهای خوب و قدیمی پروتوتایپ کار میکند. این کلمه Rabbit.prototype.[[Prototype]] را برابر با Animal.prototype قرار میدهد. پس اگر متدی در Rabbit.prototype پیدا نشد، جاوااسکریپت آن را از Animal.prototype دریافت میکند.
برای مثال، برای پیدا کردن متد rabbit.run، موتور اینها را بررسی میکند (در تصویر از پایین به بالا است):
- شیء
rabbit(runندارد). - پروتوتایپ آن، یعنی
Rabbit.prototype(hideرا دارد، اماrunرا نه). - پروتوتایپ آن، یعنی (با توجه به
extends) همانAnimal.prototypeکه در نهایت متدrunرا دارد.
همانطور که از فصل پروتوتایپهای نیتیو (Native prototypes) به یاد داریم، خود جاوااسکریپت از وراثت پروتوتایپی برای شیءهای درونساخت استفاده میکند. برای مثال Date.prototype.[[Prototype]] برابر با Object.prototype است. به همین دلیل است که تاریخها به متدهای عمومی شیء دسترسی دارند.
extends مجاز استسینتکس کلاس اجازه میدهد که نه تنها یک کلاس بلکه هر عبارتی را بعد از extends قرار دهیم.
برای مثال، فراخوانی تابعی که کلاس والد را تولید میکند:
function f(phrase) {
return class {
sayHi() { alert(phrase); }
};
}
class User extends f("سلام") {}
new User().sayHi(); // سلام
اینجا class User از نتیجه f("سلام") ارثبری میکند.
این موضوع ممکن است برای الگوهای برنامهنویسی پیشرفته مفید باشد، زمانی که بر اساس چند شرط ما از تابعها برای ایجاد کلاسها استفاده میکنیم و میتوانیم از آنها ارثبری کنیم.
بازنویسی متد
حالا بیایید جلوتر برویم و یک متد را بازنویسی کنیم. به طور پیشفرض، تمام متدهایی که در class Rabbit مشخص نشدهاند به صورت مستقیم از class Animal «بدون تغییر» دریافت میشوند.
اما اگر ما متد خودمان را درون Rabbit مشخص کنیم، مثل stop()، در عوض این متد استفاده خواهد شد:
class Rabbit extends Animal {
stop() {
// استفاده خواهد شد rabbit.stop() حالا این متد برای...
// Animal از کلاس stop() به جای
}
}
معمولا ما نمیخواهیم که یک متد والد را به طور کلی جایگزین کنیم بلکه میخواهیم متدی بر اساس آن بسازیم تا عملکرد آن را تغییر یا گسترش بدهیم. ما کاری را درون متد خود انجام میدهیم اما متد والد را قبل/بعد از آن یا در حین فرایند فراخوانی میکنیم.
کلاسها کلمه کلیدی "super" را برای این موضوع فراهم میکنند.
- متد
super.method(...)برای فراخوانی یک متد والد - تابع
super(...)برای فراخوانی سازنده والد (فقط درون سازنده خودمان).
برای مثال، بیایید بگذاریم خرگوش ما زمانی که میایستد به طور خودکار قایم شود:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} با سرعت ${this.speed} میدود.`);
}
stop() {
this.speed = 0;
alert(`${this.name} ایستاده است.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name} قایم میشود!`);
}
stop() {
super.stop(); // والد را فراخوانی کن stop متد
this.hide(); // را فراخوانی کن hide و سپس
}
}
let rabbit = new Rabbit("خرگوش سفید");
rabbit.run(5); // خرگوش سفید با سرعت 5 میدود
rabbit.stop(); // !خرگوش سفید ایستاده است. خرگوش سفید قایم میشود
حالا Rabbit متد stop را دارد که در فرایند خودش super.stop() والد را فراخوانی میکند.
super ندارندهمانطور که در فصل سرکشی دوباره از تابعهای کمانی گفته شد، تابعهای کمانی super ندارند.
اگر به آن دسترسی پیدا کنیم، از تابع بیرونی گرفته میشود. برای مثال:
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // والد بعد از 1 ثانیه stop فراخوانی
}
}
مقدار super در تابع کمانی با مقدار آن در stop() یکسان است و همانطور که توقع میرود کار میکند. اگر ما یک تابع «معمولی» را اینجا مشخص کرده بودیم، ارور ایجاد میشد:
// غیرمنتظره super
setTimeout(function() { super.stop() }, 1000);
بازنویسی سازنده
با سازندهها این کار کمی پیچیده میشود.
تا حالا، Rabbit تابع constructor خودش را نداشت.
با توجه به مشخصات زبان، اگر کلاسی یک کلاس دیگر را تعمیم دهد و constructor نداشته باشد، سپس constructor «خالی» زیر ایجاد میشود:
class Rabbit extends Animal {
// برای توابعی که بدون سازنده خودشان تعمیم داده میشوند ایجاد میشود
constructor(...args) {
super(...args);
}
}
همانطور که میتوانیم ببینیم، این سازنده با پاس دادن تمام آرگومانها constructor والد را فراخوانی میکند. اگر ما سازنده خودمان را ننویسیم این اتفاق میافتد.
حالا بیایید یک سازنده سفارشی به Rabbit اضافه کنیم. این سازنده علاوه بر name ویژگی earLength را هم مشخص میکند:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLength;
}
// ...
}
// !کار نمیکند
let rabbit = new Rabbit("خرگوش سفید", 10); // تعریف نشده است this :ارور
ای وای! ارور گرفتیم. حالا نمیتوانیم خرگوشی بسازیم. چه چیزی اشتباه است؟
جواب کوتاه:
- سازندههای درون کلاسهای ارثبر باید
super(...)را فراخوانی کنند و (!) قبل از استفاده ازthisاین کار را انجام دهند.
…اما چرا؟ چه چیزی در حال اتفاق افتادن است؟ واقعا چیزی که لازم است عجیب به نظر میرسد.
قطعا توضیحی وجود دارد. بیایید وارد جزئیات شویم تا شما کاملا متوجه شوید که چه چیزی در حال رخ دادن است.
در جاوااسکریپت، تفاوتی بین تابع سازنده یک کلاس ارثبر (به «سازنده مشتق شده» هم شناخته میشود) و بقیه تابعها وجود دارد. سازنده مشتق شده یک ویژگی درونی خاص [[ConstructorKind]]:"derived" را دارد. این یک برچسب درونی خاص است.
این برچسب بر رفتار آن همراه با new تأثیر میگذارد.
- زمانی که یک تابع معمولی همراه با
newاجرا میشود، شیءای خالی میسازد وthisرا برابر با آن قرار میدهد. - اما زمانی که یک سازنده مشتق شده اجرا میشود، این کار را نمیکند. این سازنده توقع دارد که سازنده والد این کار را انجام دهد.
پس یک سازنده مشتق شده باید super را برای اجرای سازنده والد (پایه) خود فراخوانی کند، در غیر این صورت شیءای برای this ساخته نخواهد شد. و ما ارور دریافت خواهیم کرد.
برای اینکه سازنده Rabbit کار کند، باید قبل از استفاده کردن از this تابع super() را فراخوانی کند، مانند اینجا:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// حالا درست است
let rabbit = new Rabbit("خرگوش سفید", 10);
alert(rabbit.name); // خرگوش سفید
alert(rabbit.earLength); // 10
بازنویسی فیلدهای کلاس: نکتهای فریبنده
این نکته فرض میکند شما درباره کلاسها مقداری تجربه دارید، شاید در زبانهای برنامهنویسی دیگر.
این نکته باعث بینش بهتر درون زبان میشود و همچنین رفتاری را که ممکن است منشا باگها باشد (اما نه اغلب اوقات) را توضیح میدهد.
اگر فهمیدن آن است برای شما سخت است، فقط جلو بروید، به خواندن ادامه دهید، سپس پس مدتی به سراغ آن بیایید.
ما نه تنها میتوانیم متدها را بازنویسی کنیم، بلکه فیلدهای کلاس را هم میتوانیم.
اگرچه زمانی که ما به یک فیلد بازنویسی شده درون سازنده والد دسترسی پیدا میکنیم رفتاری عجیب وجود دارد که نسبت به اکثر زبانهای برنامهنویسی دیگر خیلی تفاوت دارد.
این مثال را در نظر بگیرید:
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
اینجا، کلاس Rabbit کلاس Animal را تعمیم میدهد و فیلد name را با مقدار خودش بازنویسی میکند.
هیچ سازندهای درون Rabbit وجود ندارد، پس سازده Animal فراخوانی میشود.
موضوع جالب این است که در هر دو مورد: new Animal() و new Rabbit()، تابع alert در خط (*) مقدار animal را نشان میدهد.
به عبارتی دیگر، سازنده والد همیشه از مقدار فیلد خودش استفاده میکند، نه فیلد بازنویسی شده.
چه چیزی درباره این عجیب است؟
اگر هنوز واضح نیست، لطفا با متدها مقایسه کنید.
اینجا کدی مشابه داریم اما به جای this.name ما متد this.showName() را فراخوانی میکنیم:
class Animal {
showName() { // this.name = 'animal' به جای
alert('animal');
}
constructor() {
this.showName(); // alert(this.name); به جای
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
لطفا توجه کنید: حالا خروجی فرق دارد.
و این چیزی است که ما طبیعتا توقع داریم. زمانی که سازنده والد در کلاس مشتق شده فراخوانی میشود، از متد بازنویسی شده استفاده میکند.
…اما برای فیلدهای کلاس اینگونه نیست. همانطور که گفته شد، سازنده والد همیشه از فیلد والد استفاده میکند.
چرا تفاوت وجود دارد؟
خب دلیل آن درون ترتیب مقداردهی اولیه به فیلدها است. فیلد کلاس اینگونه مقداردهی اولیه میشود:
- قبل از سازنده برای کلاس پایه (که چیزی را تعمیم نمیدهد)،
- بلافاصله بعد از
super()برای کلاس مشتق شده.
در این مورد ما، Rabbit کلاس مشتق شده است. تابع constructor() درون آن وجود ندارد. همانطور که قبلا هم گفته شد، درست مانند این است که یک سازنده خالی فقط حاوی super(...args) وجود داشته باشد.
پس new Rabbit() تابع super() را فراخوانی میکند، به همین ترتیب سازنده والد را اجرا میکند و (بنا به دلیل موجود برای کلاسهای مشتق شده) فقط بعد از آن فیلدهای کلاس خودش مقداردهی اولیه میشوند. در زمان اجرای سازنده والد، فیلدهای کلاس Rabbit هنوز وجود ندارند، به همین دلیل فیلدهای Animal استفاده میشوند.
این تفاوت جزئی بین فیلدها و متدها مختص به جاوااسکریپت است.
خوشبختانه این موضوع فقط زمانی خودش را نشان میدهد که یک فیلد بازنویسی شده درون سازنده والد استفاده شده باشد. سپس ممکن است فهمیدن اینکه چه چیزی در حال رخ دادن است دشوار باشد، به همین دلیل اینجا آن را توضیح دادیم.
اگر این مشکلی ایجاد کند، میتوانید با استفاده از متدها یا getter/setterها به جای فیلدها آن را برطرف کنید.
تابع Super: از درون، [[HomeObject]]
اگر اولین بار است که این آموزش را میگذرانید – این بخش میتوانید از قلم بیاندازید.
این بخش درباره مکانیزم داخلی ارثبری و super است.
بیایید در چگونگی نحوه کار کردن super کمی عمیقتر شویم. ما چیزهای جالبی را طی مسیر خواهیم دید.
اول باید بگوییم، با توجه به تمام چیزهایی که تا حالا یاد گرفتیم، غیر ممکن است که super اصلا کار کند!
بله، جِدا، بیایید از خودمان بپرسیم، از لحاظ فنی چگونه باید کار کند؟ زمانی که یک متد شیء اجرا میشود، شیء کنونی را به عنوان this دریافت میکند. اگر ما super.method() را فراخوانی کنیم، موتور باید method را از پروتوتایپ شیء کنونی دریافت کند. اما چگونه؟
این کار ممکن است ساده به نظر برسد، اما نیست. موتور، شیء کنونی this را میشناسد، پس method والد را میتوانست به صورت this.__proto__.method دریافت کند. متاسفانه، چنین راهحل سادهای کار نمیکند.
بیایید مشکل را نشان دهیم. به منظور ساده بودن، از کلاسها استفاده نمیکنیم و از طریق شیءهای ساده این کار را انجام میدهیم…
اگر نمیخواهید جزئیات را بدانید میتوانید از این بخش بگذرید و به زیربخش [[HomeObject]] بروید. این کار ضرری نمیرساند. یا اگر به دانستن عمیق آنها علاقه دارید به خواندن ادامه دهید.
در مثال پایین، rabbit.__proto__ = animal برقرار است. حالا بیایید این را امتحان کنیم: در rabbit.eat() ما با استفاده از this.__proto__ متد animal.eat() را فراخوانی خواهیم کرد:
let animal = {
name: "جانور",
eat() {
alert(`${this.name} غذا میخورد.`);
}
};
let rabbit = {
__proto__: animal,
name: "خرگوش",
eat() {
// اینگونه کار کند super.eat() احتمالا
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // .خرگوش غذا میخورد
در خط (*) ما eat را از پروتوتایپ (animal) دریافت میکنیم و آن را در زمینهی شیء کنونی فراخوانی میکنیم. لطفا در نظر داشته باشید که .call(this) اینجا مهم است، چون یک this.__proto__.eat() ساده متد eat والد را در زمینهی پروتوتایپ اجرا میکند نه شیء کنونی.
و در کد بالا این متد همانطور که میخواهیم کار میکند: ما alert درستی را داریم.
حالا بیایید یک شیء دیگر را به زنجیره اضافه کنیم. میبینیم که چگونه همه چیز بهم میریزد:
let animal = {
name: "جانور",
eat() {
alert(`${this.name} غذا میخورد.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// را فراخوانی کن (animal) مانند خرگوش بپر بپر کن و متد والد...
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// را فراخوانی کن (rabbit) کاری کن و متد والد (long ear) با گوش درازها
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // Error: Maximum call stack size exceeded
این کد دیگر کار نمیکند. ما میتوانیم ببینیم که ارور سعی دارد longEar.eat() را فراخوانی کند.
ممکن است آنقدر واضح نباشد اما اگر ما فراخوانی longEar.eat() را دنبال کنیم، سپس دلیل آن را میبینیم. در هر دو خط (*) و (**) مقدار this برابر با شیء کنونی (longEar) است. این یک موضوع اساسی است: تمام متدهای شیء، شیء کنونی را به عنوان this دریافت میکنند نه پروتوتایپ یا چیز دیگری را.
پس در هر دو خط (*) و (**) مقدار this.__proto__ یکسان است: rabbit. آنها هر دو بدون اینکه در حلقه بینهایت زنجیره را بالا بروند، rabbit.eat را فراخوانی میکنند.
اینجا تصویری از اینکه چه اتفاقی افتاده موجود است:
-
درون
longEar.eat()، خط(**)متدrabbit.eatرا با برقرار کردنthis=longEarفراخوانی میکند.// this = longEar داریم longEar.eat() درون this.__proto__.eat.call(this) // (**) // که تبدیل میشود به longEar.__proto__.eat.call(this) // که یعنی rabbit.eat.call(this); -
سپس در خط
(*)ازrabbit.eat، ما میخواهیم که درون زنجیره، فراخوانی را حتی بالاتر بفرستیم اماthis=longEar، پسthis.__proto__.eatدوباره برابرrabbit.eatاست!// this = longEar هم داریم rabbit.eat() درون this.__proto__.eat.call(this) // (*) // که تبدیل میشود به longEar.__proto__.eat.call(this) // یا (دوباره) rabbit.eat.call(this); -
…پس
rabbit.eatخودش را درون حلقهای بینهایت فراخوانی میکند چون نمیتواند بالاتر برود.
این مشکل فقط با استفاده کردن از this برطرف نمیشود.
ویژگی [[HomeObject]]
برای فراهم آوردن راهحل، جاوااسکریپت یک ویژگی درونی خاص دیگر را هم برای تابعها اضافه میکند: [[HomeObject]].
زمانی که تابعی به عنوان یک کلاس یا متد شیء مشخص شود، ویژگی [[HomeObject]] برابر با آن شیء قرار داده میشود.
سپس super از آن برای رفعکردن پروتوتایپ والد و متدهای آن استفاده میکند.
بیایید ببینیم چگونه کار میکند، ابتدا با استفاده از شیءهای ساده:
let animal = {
name: "جانور",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} غذا میخورد.`);
}
};
let rabbit = {
__proto__: animal,
name: "خرگوش",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "گوش دراز",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// به درستی کار میکند
longEar.eat(); // گوش دراز غذا میخورد
به دلیل مکانیزمهای [[HomeObject]] همانطور که انتظار میرود کار میکند. یک متد، مثل longEar.eat، ویژگی [[HomeObject]] خودش را میشناسد و متد والد را از پروتوتایپ آن دریافت میکند.
متدها «آزاد» نیستند
همانطور که قبلا هم دیدیم، به طور کلی تابعها «آزاد» هستند و درون جاوااسکریپت به شیءها پیوند زده شده نیستند. پس میتوانند بین شیءهای مختلف کپی و همراه با this دیگری فراخوانی شوند.
وجود [[HomeObject]] این قاعده را نقض میکند چون متدهای شیءهای خود را به یاد دارند. [[HomeObject]] نمیتواند تغییر کند پس این پیوند ابدی است.
تنها جایی در زبان که [[HomeObject]] استفاده میشود super است. پس اگر متدی از super استفاده نمیکند، هنوز هم میتوانیم آن را آزاد فرض کنیم و بین شیءها کپی کنیم. اما همراه با super ممکن است مشکلاتی پیش بیاید.
این یک دمو از نتیجه اشتباه super بعد از کپی کردن داریم:
let animal = {
sayHi() {
alert(`من یک جانور هستم`);
}
};
// ارثبری میکند animal از rabbit
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("من یک گیاه هستم");
}
};
// ارثبری میکند plant از tree
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // من یک جانور هستم (؟!؟)
فراخوانی tree.sayHi() پیام «من یک جانور هستم» را نشان میدهد. قطعا اشتباه است.
دلیل آن ساده است:
- در خط
(*)، متدtree.sayHiازrabbitکپی شد. شاید ما فقط میخواهیم از تکرار کد جلوگیری کنیم؟ - ویژگی
[[HomeObject]]آنrabbitاست، چون درونrabbitساخته شد. راهی برای تغییر[[HomeObject]]وجود ندارد. - کد
tree.sayHi()درون خودشsuper.sayHi()را دارد. این متد ازrabbitبالا رفته و متد را ازanimalدریافت میکند.
اینجا تصویری از اینکه چه اتفاقی میافتد را داریم:
متدها، نه ویژگیهای تابعی
ویژگی [[HomeObject]] هم درون کلاسها و هم درون شیءهای ساده برای متدها تعریف شده است. اما برای شیءها، متدها باید دقیقا به صورت method() مشخص شوند نه به صورت "method: function()".
ممکن است که تفاوت برای ما مهم نباشد اما برای جاوااسکریپت مهم است.
ذر مثال پایین برای مقایسه، سینتکس غیرمتدی استفاده شده است. ویژگی [[HomeObject]] تنظیم نشده است و ارثبری کار نمیکند:
let animal = {
eat: function() { // اینگونه مینویسیم eat() {...} از قصد به جای
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // (وجود ندارد [[HomeObject]] چون) ارور گرفتیم super برای فراخوانی
خلاصه
- برای تعمیم دادن یک کلاس:
class Child extends Parent:- که یعنی
Child.prototype.__proto__برابر باParent.prototypeخواهد بود، پس متدها به ارث برده میشوند.
- که یعنی
- زمانی که یک سازنده را بازنویسی میکنیم:
- باید سازنده والد را درون سازنده
Child(فرزند) قبل از استفاده کردن ازthisبه صورتsuper()فراخوانی کنیم.
- باید سازنده والد را درون سازنده
- زمانی که متد دیگری را بازنویسی میکنیم:
- میتوانیم برای فراخوانی متد
Parent، ازsuper.method()درون متدChildاستفاده کنیم.
- میتوانیم برای فراخوانی متد
- چیزهای درونی:
- متدهای کلاس/شیء خود را درون ویژگی
[[HomeObject]]به یاد دارند. به همین صورتsuperمتدهای والد را رفع میکند. - پس کپی کردن متد حاوی
superاز شیء به شیء دیگر کار مطمئنی نیست.
- متدهای کلاس/شیء خود را درون ویژگی
همچنین:
- تابعهای کمانی
thisیاsuperخودشان را ندارند پس آنها به صورت پنهانی در زمینه (context) دورشان جا میگیرند.
نظرات
<code>استفاده کنید، برای چندین خط – کد را درون تگ<pre>قرار دهید، برای بیش از ده خط کد – از یک جعبهٔ شنی استفاده کنید. (plnkr، jsbin، codepen…)