وراثت کلاس (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) دورشان جا میگیرند.